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(`