diff --git a/packages/app/e2e/regression/session-queue.spec.ts b/packages/app/e2e/regression/session-queue.spec.ts index 5e88ec91001..589284321aa 100644 --- a/packages/app/e2e/regression/session-queue.spec.ts +++ b/packages/app/e2e/regression/session-queue.spec.ts @@ -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[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") }) +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 3836672cabb..fc2a1dc84e9 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -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}` (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( diff --git a/packages/app/src/composer/adapter.ts b/packages/app/src/composer/adapter.ts index 32f02b2d96d..bb67e5d4020 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -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; release: () => void } | undefined } type ComposerAdapterBase = { diff --git a/packages/app/src/composer/submit.test.ts b/packages/app/src/composer/submit.test.ts index c0875664786..cc9de33af70 100644 --- a/packages/app/src/composer/submit.test.ts +++ b/packages/app/src/composer/submit.test.ts @@ -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 } = {}) { + 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() diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 82eece03e54..32bde027025 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -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 diff --git a/packages/app/src/session/composer/adapter.ts b/packages/app/src/session/composer/adapter.ts index 23ab0afb475..15fc9a5d18e 100644 --- a/packages/app/src/session/composer/adapter.ts +++ b/packages/app/src/session/composer/adapter.ts @@ -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 submitted: () => void setEditor: (element: HTMLDivElement) => void + background: NonNullable }) { 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 diff --git a/packages/app/src/session/composer/controller.ts b/packages/app/src/session/composer/controller.ts index 2e5cfe8e73d..3160b8ee3a2 100644 --- a/packages/app/src/session/composer/controller.ts +++ b/packages/app/src/session/composer/controller.ts @@ -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 diff --git a/packages/app/src/session/composer/queue.ts b/packages/app/src/session/composer/queue.ts index e5337e82498..86d6a4306c7 100644 --- a/packages/app/src/session/composer/queue.ts +++ b/packages/app/src/session/composer/queue.ts @@ -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 behavior: Accessor restoreFocus: (cursor: number) => void + background: NonNullable }) { 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) => { diff --git a/packages/app/src/session/requests/model.ts b/packages/app/src/session/requests/model.ts index cc230d1d607..219588ed1b5 100644 --- a/packages/app/src/session/requests/model.ts +++ b/packages/app/src/session/requests/model.ts @@ -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, } diff --git a/packages/app/src/session/requests/steer-background.test.ts b/packages/app/src/session/requests/steer-background.test.ts new file mode 100644 index 00000000000..1a5c8f5287e --- /dev/null +++ b/packages/app/src/session/requests/steer-background.test.ts @@ -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"]) + }) +}) diff --git a/packages/app/src/session/requests/steer-background.ts b/packages/app/src/session/requests/steer-background.ts new file mode 100644 index 00000000000..d8183a2fedd --- /dev/null +++ b/packages/app/src/session/requests/steer-background.ts @@ -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 +}) { + const moved = new Set() + + 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), + } + } +}