mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
feat(app): move running work to the background when steering (#57142)
This commit is contained in:
committed by
GitHub
Unverified
parent
15b5948601
commit
e79de6666b
@@ -130,6 +130,9 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
delivery: "steer",
|
||||
})
|
||||
},
|
||||
onBackground: () => {
|
||||
log.push("background")
|
||||
},
|
||||
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
|
||||
changes.push({ inboxID: input.inboxID, action: input.action })
|
||||
log.push(`${input.action}:${input.inboxID}`)
|
||||
@@ -161,9 +164,8 @@ async function openQueue(
|
||||
followUpBehavior?: "queue" | "steer",
|
||||
revert?: string,
|
||||
status: "idle" | "running" = "running",
|
||||
model = { id: "queue-model", name: "Queue Model" },
|
||||
) {
|
||||
const model = { id: "queue-model", name: "Queue Model" }
|
||||
|
||||
const options: Parameters<typeof openSession>[1] = {
|
||||
name: "SessionQueueRegression",
|
||||
sessions: [
|
||||
@@ -181,6 +183,7 @@ async function openQueue(
|
||||
onPrompt: mock.onPrompt,
|
||||
onCompact: mock.onCompact,
|
||||
onInboxChange: mock.onInboxChange,
|
||||
onBackground: mock.onBackground,
|
||||
events: mock.events,
|
||||
}
|
||||
|
||||
@@ -1095,3 +1098,94 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
test("a steer moves a blocking shell to the background after its admission, so the next step reads it", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const model = { id: "alpha", providerID: "opencode" }
|
||||
const assistantID = "msg_steer_background_assistant"
|
||||
const shell = { sessionID, assistantMessageID: assistantID, id: "prt_steer_background_shell" }
|
||||
const steer = "Skip the slow suites and run only the queue tests."
|
||||
|
||||
const mock = createQueueMock(
|
||||
[],
|
||||
[
|
||||
{
|
||||
id: "msg_steer_background_user",
|
||||
type: "user",
|
||||
text: "Run the full test suite.",
|
||||
time: { created: 1700000000000 },
|
||||
},
|
||||
{
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model,
|
||||
content: [
|
||||
{
|
||||
type: "tool",
|
||||
id: shell.id,
|
||||
name: "shell",
|
||||
state: {
|
||||
status: "running",
|
||||
input: { command: "bun test --timeout 600000", description: "Run the full test suite" },
|
||||
metadata: { shellID: "sh_steer_background" },
|
||||
},
|
||||
time: { created: 1700000000002 },
|
||||
},
|
||||
],
|
||||
time: { created: 1700000000001 },
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
const view = await openQueue(page, mock, "steer", undefined, "running", { id: "alpha", name: "Alpha" })
|
||||
const transcript = page.locator("[data-timeline-virtual-content]")
|
||||
const hint = page.getByRole("button", { name: /move running work to the background/i })
|
||||
await expect(hint).toBeVisible()
|
||||
await transcript.screenshot({ path: testInfo.outputPath("before-steer.png") })
|
||||
|
||||
await view.input.fill(steer)
|
||||
await view.input.press("Enter")
|
||||
// Admission comes first: the steer already waits in the inbox when the moved shell ends the step.
|
||||
await expect.poll(() => mock.log).toEqual(["prompt:steer", "background"])
|
||||
|
||||
const inboxID = mock.rows[0].id
|
||||
const pending = transcript.locator(`[data-timeline-row="UserMessage"][data-message-id="${inboxID}"]`)
|
||||
await expect(pending.locator('[data-component="user-message"]')).toHaveAttribute("data-pending", "true")
|
||||
|
||||
// As the server does, the moved shell's call completes while the command keeps running.
|
||||
mock.emit("session.tool.success", {
|
||||
...shell,
|
||||
content: [{ type: "text", text: "Command moved to the background (shell ID: sh_steer_background)." }],
|
||||
metadata: { status: "running", shellID: "sh_steer_background" },
|
||||
executed: true,
|
||||
})
|
||||
await expect(hint).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "1 shell", exact: true })).toBeVisible()
|
||||
await transcript.screenshot({ path: testInfo.outputPath("moved-to-background.png") })
|
||||
|
||||
mock.emit("session.step.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: assistantID,
|
||||
finish: "tool-calls",
|
||||
cost: 0,
|
||||
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
mock.rows.splice(0, 1)
|
||||
mock.emit("session.inbox.delivered", { sessionID, inboxID })
|
||||
|
||||
const next = { sessionID, assistantMessageID: "msg_steer_background_next" }
|
||||
mock.emit("session.step.started", { ...next, agent: "build", model, started: Date.now() })
|
||||
mock.emit("session.text.started", { ...next, ordinal: 0 })
|
||||
mock.emit("session.text.ended", { ...next, ordinal: 0, text: "Running only the queue tests now." })
|
||||
|
||||
const response = transcript
|
||||
.locator('[data-timeline-row="AssistantPart"]')
|
||||
.filter({ hasText: "Running only the queue tests now." })
|
||||
|
||||
await expect(response).toHaveAttribute("data-message-id", inboxID)
|
||||
await expect(pending.locator('[data-component="user-message"]')).not.toHaveAttribute("data-pending", "true")
|
||||
expect(mock.log).toEqual(["prompt:steer", "background"])
|
||||
await transcript.screenshot({ path: testInfo.outputPath("steer-delivered.png") })
|
||||
})
|
||||
|
||||
@@ -140,6 +140,7 @@ export interface MockServerConfig {
|
||||
onCompact?: (input: { sessionID: string; body: Schema.JsonObject }) => void
|
||||
generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }>
|
||||
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
|
||||
onBackground?: (input: { sessionID: string }) => void
|
||||
// Serves `/api/pty*` and mock PTY WebSockets. Created IDs are the first unused `${prefix}<n>` (prefix must start with "pty").
|
||||
// `directory` is the owning workspace (Location); `cwd` is the reported working directory (default: `directory`).
|
||||
pty?: { prefix?: string; initial?: { id: string; title: string; directory?: string; cwd?: string }[] }
|
||||
@@ -1242,7 +1243,8 @@ function mockHandlers(
|
||||
}),
|
||||
sessionFormReply: () => noContent,
|
||||
sessionFormCancel: () => noContent,
|
||||
sessionBackground: () => noContent,
|
||||
sessionBackground: (ctx) =>
|
||||
Effect.sync(() => config.onBackground?.({ sessionID: ctx.params.sessionID })).pipe(Effect.andThen(noContent)),
|
||||
sessionInbox: (ctx) =>
|
||||
Effect.sync(() => ({
|
||||
data: resolve(config.inbox ?? []).filter(
|
||||
|
||||
@@ -92,6 +92,8 @@ export type ComposerSession = {
|
||||
| undefined
|
||||
>
|
||||
admitted: (messageID: string) => boolean
|
||||
// Claims the run's blocking work for a steer; undefined when there is none or it was already moved.
|
||||
background?: () => { move: () => Promise<void>; release: () => void } | undefined
|
||||
}
|
||||
|
||||
type ComposerAdapterBase = {
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
} from "./adapter"
|
||||
import { createMemoryComposerState, type Prompt } from "./state"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
import { createSteerBackground } from "@/session/requests/steer-background"
|
||||
import type { ComposerStateTarget } from "./submission-state"
|
||||
|
||||
// SAFETY: submission reads only the selected model's id, name, and provider id.
|
||||
@@ -117,9 +118,11 @@ function session(input: {
|
||||
command?: ComposerSession["api"]["command"]
|
||||
switchAgent?: ComposerSession["api"]["switchAgent"]
|
||||
switchModel?: ComposerSession["api"]["switchModel"]
|
||||
background?: ComposerSession["background"]
|
||||
}): ComposerSession {
|
||||
return {
|
||||
id: "session-1",
|
||||
background: input.background,
|
||||
directory: "C:/repo",
|
||||
handoff: input.handoff ?? { set() {}, clear() {} },
|
||||
current: input.current ?? (() => undefined),
|
||||
@@ -506,6 +509,94 @@ describe("Composer submission", () => {
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
})
|
||||
|
||||
describe("steering while foreground work runs", () => {
|
||||
// A long shell command blocks the run; moving it resolves only the move, never the admission.
|
||||
function running(input: { fail?: boolean; prompt?: () => Promise<void> } = {}) {
|
||||
const calls: string[] = []
|
||||
const failures: string[] = []
|
||||
const state = createMemoryComposerState({ prompt: "use Beta instead" }).capture()
|
||||
|
||||
const claim = createSteerBackground({
|
||||
sessionID: () => "session-1",
|
||||
blocking: () => [{ partID: "prt_shell" }],
|
||||
move: async () => {
|
||||
calls.push("background")
|
||||
|
||||
if (input.fail) throw new Error("server unavailable")
|
||||
},
|
||||
})
|
||||
|
||||
const target = session({ calls, prompt: input.prompt, background: () => claim("session-1") })
|
||||
const adapter = { ...active(state, target), working: () => true }
|
||||
const notify = { missingSelection() {}, unqueueable() {}, failed: () => void failures.push("failed") }
|
||||
|
||||
const submit = async (delivery: ComposerDelivery = "steer", working = true) => {
|
||||
state.set([{ type: "text", content: "use Beta instead", start: 0, end: 16 }])
|
||||
await submitInput(
|
||||
{ ...adapter, working: () => working },
|
||||
notify,
|
||||
"normal",
|
||||
() => [],
|
||||
[],
|
||||
undefined,
|
||||
delivery,
|
||||
).submit(new Event("submit"))
|
||||
// The send settles after submit returns; let it and the move that follows it finish.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
return { calls, failures, submit }
|
||||
}
|
||||
|
||||
test("moves the work to the background once the steer is admitted, and only once", async () => {
|
||||
const run = running()
|
||||
|
||||
await run.submit()
|
||||
expect(run.calls).toEqual(["admit", "switch-agent", "switch-model", "send", "background"])
|
||||
|
||||
await run.submit()
|
||||
expect(run.calls.filter((call) => call === "background")).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("leaves the work alone for a queued prompt or an idle session", async () => {
|
||||
const run = running()
|
||||
|
||||
await run.submit("queue")
|
||||
await run.submit("steer", false)
|
||||
expect(run.calls).not.toContain("background")
|
||||
})
|
||||
|
||||
test("still sends the steer when the move fails, and a later steer tries again", async () => {
|
||||
const run = running({ fail: true })
|
||||
|
||||
await run.submit()
|
||||
await run.submit()
|
||||
expect(run.failures).toEqual([])
|
||||
expect(run.calls.filter((call) => call === "send")).toHaveLength(2)
|
||||
expect(run.calls.filter((call) => call === "background")).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("a steer that fails to send leaves the work for the next one", async () => {
|
||||
const attempts = { count: 0 }
|
||||
|
||||
const run = running({
|
||||
prompt: async () => {
|
||||
attempts.count++
|
||||
|
||||
// The first submission's admission and its single retry both fail.
|
||||
if (attempts.count <= 2) throw new Error("network unavailable")
|
||||
},
|
||||
})
|
||||
|
||||
await run.submit()
|
||||
expect(run.failures).toEqual(["failed"])
|
||||
expect(run.calls).not.toContain("background")
|
||||
|
||||
await run.submit()
|
||||
expect(run.calls.filter((call) => call === "background")).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
test("hands off image-only first prompts and admits them before cleanup is ready", async () => {
|
||||
const draft = createMemoryComposerState().capture()
|
||||
|
||||
|
||||
@@ -165,11 +165,23 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
})
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
|
||||
// Like iOS, a steer moves the run's blocking shells and subagents to the background. The move follows
|
||||
// admission: the steer already waits in the inbox when the moved work ends the step, so that step reads it.
|
||||
const background = value.delivery === "steer" && !optimisticBusy ? session.background?.() : undefined
|
||||
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
() => {
|
||||
void background?.move()
|
||||
|
||||
return { ok: true as const }
|
||||
},
|
||||
(error) => {
|
||||
background?.release()
|
||||
|
||||
return { ok: false as const, error }
|
||||
},
|
||||
)
|
||||
|
||||
await started.cleanupReady
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ActiveComposerAdapter, ComposerControls } from "@/composer/adapter"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession } from "@/composer/adapter"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -12,6 +12,7 @@ export function createActiveComposerAdapter(input: {
|
||||
controls: Accessor<ComposerControls>
|
||||
submitted: () => void
|
||||
setEditor: (element: HTMLDivElement) => void
|
||||
background: NonNullable<ComposerSession["background"]>
|
||||
}) {
|
||||
const id = input.sessionID
|
||||
|
||||
@@ -38,6 +39,7 @@ export function createActiveComposerAdapter(input: {
|
||||
data,
|
||||
current: () => data.session.get(id),
|
||||
admitted: (messageID) => data.session.input.has(id, messageID) || !!data.session.message.get(id, messageID),
|
||||
background: input.background,
|
||||
}),
|
||||
interrupt: () =>
|
||||
server.api.session
|
||||
|
||||
@@ -20,11 +20,14 @@ export function createSessionComposerController(input: {
|
||||
const region = createSessionComposerRegionController(input.dock)
|
||||
let editor: HTMLDivElement | undefined
|
||||
|
||||
const background = () => input.dock.state.background.steer(input.sessionID)
|
||||
|
||||
const adapter = createActiveComposerAdapter({
|
||||
sessionID: input.sessionID,
|
||||
sessionKey: input.sessionKey,
|
||||
controls: input.controls,
|
||||
submitted: region.onResponseSubmit,
|
||||
background,
|
||||
setEditor: (element) => {
|
||||
editor = element
|
||||
region.setPromptRef(element)
|
||||
@@ -36,6 +39,7 @@ export function createSessionComposerController(input: {
|
||||
draft: adapter.state,
|
||||
working: adapter.working,
|
||||
behavior: settings.general.followUpBehavior,
|
||||
background,
|
||||
restoreFocus: (cursor) => {
|
||||
const target = editor
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useMutation } from "@tanstack/solid-query"
|
||||
import { Option, Schema } from "effect"
|
||||
import type { SessionInboxInfo } from "@opencode/client/promise"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { ComposerDelivery } from "@/composer/adapter"
|
||||
import type { ComposerDelivery, ComposerSession } from "@/composer/adapter"
|
||||
import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
import type { ContextItem, ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
|
||||
import { appendPrompt, clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
|
||||
@@ -33,6 +33,7 @@ export function createSessionQueue(input: {
|
||||
working: Accessor<boolean>
|
||||
behavior: Accessor<ComposerDelivery>
|
||||
restoreFocus: (cursor: number) => void
|
||||
background: NonNullable<ComposerSession["background"]>
|
||||
}) {
|
||||
const data = useData()
|
||||
const server = useServerSDK()
|
||||
@@ -101,7 +102,12 @@ export function createSessionQueue(input: {
|
||||
|
||||
if (!pending.some((item) => item.id === change.original && item.type === "user" && item.delivery === "queue"))
|
||||
throw new Error("Queued prompt was delivered before the edit")
|
||||
await data.session.prompt(admission)
|
||||
const background = input.background()
|
||||
await data.session.prompt(admission).catch((error) => {
|
||||
background?.release()
|
||||
throw error
|
||||
})
|
||||
void background?.move()
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.original })
|
||||
cancelEdit()
|
||||
},
|
||||
@@ -197,10 +203,15 @@ export function createSessionQueue(input: {
|
||||
|
||||
const steer = (id: string) => {
|
||||
if (state.editing?.id === id) cancelEdit()
|
||||
const background = input.background()
|
||||
|
||||
return server.api.session.inbox
|
||||
.update({ sessionID: input.sessionID, inboxID: id, delivery: "steer" })
|
||||
.catch(() => notify())
|
||||
return server.api.session.inbox.update({ sessionID: input.sessionID, inboxID: id, delivery: "steer" }).then(
|
||||
() => void background?.move(),
|
||||
() => {
|
||||
background?.release()
|
||||
notify()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const remove = (id: string) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { sessionPermissionRequest, sessionFormRequest, sessionTreeIDs } from "@/session/requests/session-request-tree"
|
||||
import { createWebSearchRequest } from "./websearch"
|
||||
import { createSessionBackground } from "@/session/requests/background"
|
||||
import { createSteerBackground } from "@/session/requests/steer-background"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
|
||||
export function createSessionRequestModel() {
|
||||
@@ -117,6 +118,12 @@ export function createSessionRequestModel() {
|
||||
})
|
||||
}
|
||||
|
||||
const steer = createSteerBackground({
|
||||
sessionID: () => params.id,
|
||||
blocking,
|
||||
move: (sessionID) => serverSDK.api.session.background({ sessionID }),
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{ responding: string | undefined }>({ responding: undefined })
|
||||
|
||||
const permissionResponding = createMemo(() => {
|
||||
@@ -158,6 +165,7 @@ export function createSessionRequestModel() {
|
||||
blocking,
|
||||
tasks,
|
||||
move: moveToBackground,
|
||||
steer,
|
||||
},
|
||||
decide,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createSteerBackground } from "./steer-background"
|
||||
|
||||
function setup(input: { fail?: boolean } = {}) {
|
||||
const moves: string[] = []
|
||||
const state = { sessionID: "ses_alpha", blocking: [{ partID: "prt_shell" }] }
|
||||
|
||||
const claim = createSteerBackground({
|
||||
sessionID: () => state.sessionID,
|
||||
blocking: () => state.blocking,
|
||||
move: async (sessionID) => {
|
||||
moves.push(sessionID)
|
||||
|
||||
if (input.fail) throw new Error("server unavailable")
|
||||
},
|
||||
})
|
||||
|
||||
return { claim, moves, state }
|
||||
}
|
||||
|
||||
describe("createSteerBackground", () => {
|
||||
test("claims each blocking work item once", async () => {
|
||||
const { claim, moves, state } = setup()
|
||||
|
||||
await claim("ses_alpha")?.move()
|
||||
expect(claim("ses_alpha")).toBeUndefined()
|
||||
|
||||
state.blocking = [{ partID: "prt_shell" }, { partID: "prt_subagent" }]
|
||||
await claim("ses_alpha")?.move()
|
||||
expect(moves).toEqual(["ses_alpha", "ses_alpha"])
|
||||
})
|
||||
|
||||
test("claims nothing without blocking work or for another session", () => {
|
||||
const { claim, state } = setup()
|
||||
|
||||
expect(claim("ses_beta")).toBeUndefined()
|
||||
state.blocking = []
|
||||
expect(claim("ses_alpha")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("a released claim or failed move lets a later steer move the work", async () => {
|
||||
const released = setup()
|
||||
|
||||
released.claim("ses_alpha")?.release()
|
||||
expect(released.claim("ses_alpha")).toBeDefined()
|
||||
|
||||
const failed = setup({ fail: true })
|
||||
|
||||
await failed.claim("ses_alpha")?.move()
|
||||
await failed.claim("ses_alpha")?.move()
|
||||
expect(failed.moves).toEqual(["ses_alpha", "ses_alpha"])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* A steer moves the run's blocking shells and subagents to the background, so the agent reads it at its next step.
|
||||
* Each work item is claimed once: work already moved, or being moved, isn't again. A claim the steer never acts on,
|
||||
* or a move that fails, is released so a later steer or the inline hint can move it.
|
||||
*/
|
||||
export function createSteerBackground(input: {
|
||||
sessionID: () => string | undefined
|
||||
blocking: () => readonly { partID: string }[]
|
||||
move: (sessionID: string) => Promise<void>
|
||||
}) {
|
||||
const moved = new Set<string>()
|
||||
|
||||
return (sessionID: string) => {
|
||||
if (input.sessionID() !== sessionID) return
|
||||
const parts = input.blocking().flatMap((task) => (moved.has(task.partID) ? [] : [task.partID]))
|
||||
|
||||
if (parts.length === 0) return
|
||||
parts.forEach((id) => moved.add(id))
|
||||
const release = () => parts.forEach((id) => moved.delete(id))
|
||||
|
||||
return {
|
||||
release,
|
||||
move: () => input.move(sessionID).then(() => undefined, release),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user