fix(core): keep background shell notices accurate after completion

This commit is contained in:
Kit Langton 2026-07-02 00:31:04 -04:00
commit 15227b57a9
4 changed files with 42 additions and 2 deletions

View file

@ -18,8 +18,10 @@ export const DEFAULT_TIMEOUT_MS = 2 * 60 * 1_000
export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
// Persisted in the transcript, so it must stay accurate after the command later
// finishes; do not claim the command is currently running.
const BACKGROUND_STARTED =
"The command has not completed; it is now running in the background."
"The command was moved to the background. You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress."
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
@ -124,8 +126,11 @@ export const Plugin = {
: state === "error"
? (result.info!.error ?? "Command failed")
: "Command cancelled"
// The description makes the completion visible in clients; synthetic
// messages without one are model-facing context only.
return runtime.session.synthetic({
sessionID,
description: `Background command ${state}: ${command.split("\n")[0]}`,
text: `<shell id="${callID}" state="${state}" command="${command}">\n${text}\n</shell>`,
})
}),

View file

@ -67,8 +67,11 @@ export const Plugin = {
state: "completed" | "error" | "cancelled",
text: string,
) {
// The description makes the completion visible in clients; synthetic
// messages without one are model-facing context only.
yield* runtime.session.synthetic({
sessionID: parentID,
description: `Subagent ${state}: ${description}`,
text: `<subagent id="${childID}" state="${state}" description="${description}">\n${text}\n</subagent>`,
})
})

View file

@ -455,6 +455,37 @@ describe("ShellTool", () => {
),
)
it.live("notifies with a visible description when a background command completes", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const sessions = yield* SessionV2.Service
yield* settleTool(registry, call({ command: helloCommand, background: true }))
const awaitNotice = (remaining = 1000): Effect.Effect<SessionMessage.Message, Error> =>
Effect.gen(function* () {
const notice = (yield* sessions.context(sessionID)).find((message) => message.type === "synthetic")
if (notice) return notice
if (remaining <= 0)
return yield* Effect.fail(new Error("Timed out waiting for background completion notice"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* awaitNotice(remaining - 1)
})
const notice = yield* awaitNotice()
expect(notice).toMatchObject({
type: "synthetic",
description: `Background command completed: ${helloCommand}`,
text: expect.stringContaining('state="completed"'),
})
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("backgrounds a foreground command when the session is signaled", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@ -484,7 +515,7 @@ describe("ShellTool", () => {
expect(settled.output?.structured).toMatchObject({ truncated: false })
expect(settled.output?.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("running in the background"),
text: expect.stringContaining("moved to the background"),
})
expect(shellID).toStartWith("sh_")

View file

@ -279,6 +279,7 @@ describe("SubagentTool", () => {
expect(synthetic).toHaveLength(1)
expect(synthetic[0]?.text).toContain(`<subagent id="${childID}" state="completed"`)
expect(synthetic[0]?.text).toContain(childText)
expect(synthetic[0]?.description).toBe("Subagent completed: background review")
}),
),
),