feat(app): move running work to the background when steering (#57142)

This commit is contained in:
Kit Langton
2026-10-19 12:13:23 -07:00
committed by GitHub
Unverified
parent 15b5948601
commit e79de6666b
11 changed files with 316 additions and 11 deletions
@@ -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") })
})
+3 -1
View File
@@ -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(
+2
View File
@@ -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 = {
+91
View File
@@ -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()
+14 -2
View File
@@ -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
+3 -1
View File
@@ -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
+16 -5
View File
@@ -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),
}
}
}