Compare commits

...
4 changed files with 53 additions and 5 deletions
+11 -3
View File
@@ -18,8 +18,12 @@ 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
const BACKGROUND_STARTED =
"The command has not completed; it is now running in the background."
// Rendered in clients and persisted in the transcript, so it must stay accurate
// after the command later finishes; do not claim the command is currently running.
// The model-facing behavioral instruction lives in modelOutput instead.
const BACKGROUND_STARTED = "The command was moved to the background."
const BACKGROUND_INSTRUCTION =
"You will be notified automatically when the command 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" }),
@@ -54,10 +58,11 @@ const Output = Schema.Struct({
type Output = typeof Output.Type
const modelOutput = (output: Output): string | undefined => {
if (output.status === "running") return undefined
const warnings = output.warnings?.length
? `\n\nWarnings:\n${output.warnings.map((warning) => `- ${warning}`).join("\n")}`
: ""
if (output.status === "running")
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}${BACKGROUND_INSTRUCTION}`
if (output.timeout) return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command timed out before completion.`
return `${warnings.trimStart()}${warnings ? "\n\n" : ""}Command exited with code ${output.exit}.`
}
@@ -124,8 +129,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>`,
})
}),
+3
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>`,
})
})
+38 -2
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()),
@@ -482,9 +513,14 @@ describe("ShellTool", () => {
const structured = settled.output?.structured as Record<string, unknown> | undefined
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
expect(settled.output?.structured).toMatchObject({ truncated: false })
expect(settled.output?.content[0]).toMatchObject({
// content[0] is rendered by clients; the model-facing instruction stays in content[1].
expect(settled.output?.content[0]).toEqual({
type: "text",
text: expect.stringContaining("running in the background"),
text: "The command was moved to the background.",
})
expect(settled.output?.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("DO NOT sleep, poll"),
})
expect(shellID).toStartWith("sh_")
+1
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")
}),
),
),