diff --git a/packages/core/src/tool/shell.ts b/packages/core/src/tool/shell.ts index e86f960ba5..a4ee10af5a 100644 --- a/packages/core/src/tool/shell.ts +++ b/packages/core/src/tool/shell.ts @@ -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: `\n${text}\n`, }) }), diff --git a/packages/core/src/tool/subagent.ts b/packages/core/src/tool/subagent.ts index 517d1ce65f..db4c5f2fe4 100644 --- a/packages/core/src/tool/subagent.ts +++ b/packages/core/src/tool/subagent.ts @@ -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: `\n${text}\n`, }) }) diff --git a/packages/core/test/tool-shell.test.ts b/packages/core/test/tool-shell.test.ts index 15bd741a2e..57396fa645 100644 --- a/packages/core/test/tool-shell.test.ts +++ b/packages/core/test/tool-shell.test.ts @@ -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 => + 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_") diff --git a/packages/core/test/tool-subagent.test.ts b/packages/core/test/tool-subagent.test.ts index 6274e329aa..86a4df0d5d 100644 --- a/packages/core/test/tool-subagent.test.ts +++ b/packages/core/test/tool-subagent.test.ts @@ -279,6 +279,7 @@ describe("SubagentTool", () => { expect(synthetic).toHaveLength(1) expect(synthetic[0]?.text).toContain(`