feat: background blocking tools

This commit is contained in:
Dax Raad 2026-07-01 12:17:30 -04:00
commit e2bca216a2
15 changed files with 392 additions and 66 deletions

View file

@ -19,7 +19,7 @@ export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
export const MAX_CAPTURE_BYTES = 1024 * 1024
const BACKGROUND_STARTED =
"The command is running in the background. You will be notified automatically when it completes. DO NOT sleep, poll, or proactively check on its progress."
"The command has not completed; it is now running in the background."
export const Input = Schema.Struct({
command: Schema.String.annotate({ description: "Shell command string to execute" }),
@ -185,75 +185,80 @@ export const Plugin = {
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
if (input.background === true) {
const background = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
const run = Effect.fn("ShellTool.run")(function* () {
return yield* Effect.gen(function* () {
const final = yield* shell.wait(background.id)
const page = yield* shell.output(background.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout")
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return `${body}${notice}`
}).pipe(Effect.onInterrupt(() => shell.remove(background.id).pipe(Effect.ignore)))
})
const info = yield* runtime.job.start({
id: context.toolCallID,
type: name,
title: input.command,
metadata: { sessionID: context.sessionID },
run: run(),
})
yield* runtime.job.background(info.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: background.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
}
const info = yield* shell.create({
command: input.command,
cwd: target.canonical,
timeout,
metadata: { sessionID: context.sessionID },
})
const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout") {
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
const final = yield* shell.wait(info.id)
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
if (final.status === "timeout") {
return {
exit: final.exit,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
status: "completed" as const,
}
}
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
return {
exit: final.exit,
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
truncated: false,
timeout: true,
output: `${body}${notice}`,
truncated,
status: "completed" as const,
}
})
const run = settleShell().pipe(
Effect.map((output) => output.output),
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
)
const job = yield* runtime.job.start({
id: context.toolCallID,
type: name,
title: input.command,
metadata: { sessionID: context.sessionID, shellID: info.id },
run,
})
if (input.background === true) {
yield* runtime.job.background(job.id)
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
}
const truncated = page.size > page.cursor
const body = page.output || "(no output)"
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
)
if (result?.type === "backgrounded") {
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
return {
output: BACKGROUND_STARTED,
shellID: info.id,
truncated: false,
status: "running" as const,
...(warnings.length ? { warnings } : {}),
}
}
if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
return {
exit: final.exit,
output: `${body}${notice}`,
truncated,
status: "completed" as const,
...(yield* settleShell()),
...(warnings.length ? { warnings } : {}),
}
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),

View file

@ -2,7 +2,7 @@ import fs from "fs/promises"
import { realpathSync } from "node:fs"
import path from "path"
import { describe, expect, test } from "bun:test"
import { DateTime, Effect, Layer } from "effect"
import { DateTime, Effect, Fiber, Layer, Scope } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@ -454,6 +454,51 @@ describe("ShellTool", () => {
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
it.live("backgrounds a foreground command when the session is signaled", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const jobs = yield* Job.Service
const scope = yield* Scope.Scope
const waiting = yield* settleTool(registry, call({ command: idleCommand }, "call-background-signal")).pipe(
Effect.forkIn(scope, { startImmediately: true }),
)
const backgroundWhenReady = (remaining = 1000): Effect.Effect<Job.Info[], Error> =>
Effect.gen(function* () {
const backgrounded = yield* jobs.backgroundAll({ sessionID })
if (backgrounded.length > 0) return backgrounded
if (remaining <= 0) return yield* Effect.fail(new Error("Timed out waiting for foreground shell job"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* backgroundWhenReady(remaining - 1)
})
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
const settled = yield* Fiber.join(waiting)
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({
type: "text",
text: expect.stringContaining("running in the background"),
})
expect(shellID).toStartWith("sh_")
const shell = yield* Shell.Service
if (!shellID) return
const id = ShellSchema.ID.make(shellID)
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
yield* shell.remove(id)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
)
})
test("keeps locked deferred parity TODOs visible", async () => {