Skip to content

Commit e2bca21

Browse files
committed
feat: background blocking tools
1 parent a10733d commit e2bca21

15 files changed

Lines changed: 392 additions & 66 deletions

File tree

packages/client/src/generated-effect/client.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,17 @@ type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["params"]["se
237237
const Endpoint4_18 = (raw: RawClient["server.session"]) => (input: Endpoint4_18Input) =>
238238
raw["session.interrupt"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
239239

240-
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
241-
type Endpoint4_19Input = {
242-
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
243-
readonly messageID: Endpoint4_19Request["params"]["messageID"]
244-
}
240+
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
241+
type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
245242
const Endpoint4_19 = (raw: RawClient["server.session"]) => (input: Endpoint4_19Input) =>
243+
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError))
244+
245+
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
246+
type Endpoint4_20Input = {
247+
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
248+
readonly messageID: Endpoint4_20Request["params"]["messageID"]
249+
}
250+
const Endpoint4_20 = (raw: RawClient["server.session"]) => (input: Endpoint4_20Input) =>
246251
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
247252
Effect.mapError(mapClientError),
248253
Effect.map((value) => value.data),
@@ -268,7 +273,8 @@ const adaptGroup4 = (raw: RawClient["server.session"]) => ({
268273
history: Endpoint4_16(raw),
269274
events: Endpoint4_17(raw),
270275
interrupt: Endpoint4_18(raw),
271-
message: Endpoint4_19(raw),
276+
background: Endpoint4_19(raw),
277+
message: Endpoint4_20(raw),
272278
})
273279

274280
type Endpoint5_0Request = Parameters<RawClient["server.message"]["session.messages"]>[0]

packages/client/src/generated/client.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import type {
4343
SessionEventsOutput,
4444
SessionInterruptInput,
4545
SessionInterruptOutput,
46+
SessionBackgroundInput,
47+
SessionBackgroundOutput,
4648
SessionMessageInput,
4749
SessionMessageOutput,
4850
MessageListInput,
@@ -561,6 +563,17 @@ export function make(options: ClientOptions) {
561563
},
562564
requestOptions,
563565
),
566+
background: (input: SessionBackgroundInput, requestOptions?: RequestOptions) =>
567+
request<SessionBackgroundOutput>(
568+
{
569+
method: "POST",
570+
path: `/api/session/${encodeURIComponent(input.sessionID)}/background`,
571+
successStatus: 204,
572+
declaredStatuses: [404, 400, 401],
573+
empty: true,
574+
},
575+
requestOptions,
576+
),
564577
message: (input: SessionMessageInput, requestOptions?: RequestOptions) =>
565578
request<{ readonly data: SessionMessageOutput }>(
566579
{

packages/client/src/generated/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1775,6 +1775,10 @@ export type SessionInterruptInput = { readonly sessionID: { readonly sessionID:
17751775

17761776
export type SessionInterruptOutput = void
17771777

1778+
export type SessionBackgroundInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
1779+
1780+
export type SessionBackgroundOutput = void
1781+
17781782
export type SessionMessageInput = {
17791783
readonly sessionID: { readonly sessionID: string; readonly messageID: string }["sessionID"]
17801784
readonly messageID: { readonly sessionID: string; readonly messageID: string }["messageID"]

packages/core/src/tool/shell.ts

Lines changed: 55 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const MAX_TIMEOUT_MS = 10 * 60 * 1_000
1919
export const MAX_CAPTURE_BYTES = 1024 * 1024
2020

2121
const BACKGROUND_STARTED =
22-
"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."
22+
"The command has not completed; it is now running in the background."
2323

2424
export const Input = Schema.Struct({
2525
command: Schema.String.annotate({ description: "Shell command string to execute" }),
@@ -185,75 +185,80 @@ export const Plugin = {
185185
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
186186

187187
const timeout = input.timeout ?? DEFAULT_TIMEOUT_MS
188+
const info = yield* shell.create({
189+
command: input.command,
190+
cwd: target.canonical,
191+
timeout,
192+
metadata: { sessionID: context.sessionID },
193+
})
188194

189-
if (input.background === true) {
190-
const background = yield* shell.create({
191-
command: input.command,
192-
cwd: target.canonical,
193-
timeout,
194-
metadata: { sessionID: context.sessionID },
195-
})
196-
const run = Effect.fn("ShellTool.run")(function* () {
197-
return yield* Effect.gen(function* () {
198-
const final = yield* shell.wait(background.id)
199-
const page = yield* shell.output(background.id, { limit: MAX_CAPTURE_BYTES })
195+
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
196+
const final = yield* shell.wait(info.id)
197+
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
200198

201-
if (final.status === "timeout")
202-
return `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
199+
if (final.status === "timeout") {
200+
return {
201+
exit: final.exit,
202+
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
203+
truncated: false,
204+
timeout: true,
205+
status: "completed" as const,
206+
}
207+
}
203208

204-
const truncated = page.size > page.cursor
205-
const body = page.output || "(no output)"
206-
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
207-
return `${body}${notice}`
208-
}).pipe(Effect.onInterrupt(() => shell.remove(background.id).pipe(Effect.ignore)))
209-
})
209+
const truncated = page.size > page.cursor
210+
const body = page.output || "(no output)"
211+
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
212+
return {
213+
exit: final.exit,
214+
output: `${body}${notice}`,
215+
truncated,
216+
status: "completed" as const,
217+
}
218+
})
210219

211-
const info = yield* runtime.job.start({
212-
id: context.toolCallID,
213-
type: name,
214-
title: input.command,
215-
metadata: { sessionID: context.sessionID },
216-
run: run(),
217-
})
218-
yield* runtime.job.background(info.id)
220+
const run = settleShell().pipe(
221+
Effect.map((output) => output.output),
222+
Effect.onInterrupt(() => shell.remove(info.id).pipe(Effect.ignore)),
223+
)
224+
const job = yield* runtime.job.start({
225+
id: context.toolCallID,
226+
type: name,
227+
title: input.command,
228+
metadata: { sessionID: context.sessionID, shellID: info.id },
229+
run,
230+
})
231+
232+
if (input.background === true) {
233+
yield* runtime.job.background(job.id)
219234
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
220235
return {
221236
output: BACKGROUND_STARTED,
222-
shellID: background.id,
237+
shellID: info.id,
223238
truncated: false,
224239
status: "running" as const,
225240
...(warnings.length ? { warnings } : {}),
226241
}
227242
}
228243

229-
const info = yield* shell.create({
230-
command: input.command,
231-
cwd: target.canonical,
232-
timeout,
233-
metadata: { sessionID: context.sessionID },
234-
})
235-
const final = yield* shell.wait(info.id)
236-
const page = yield* shell.output(info.id, { limit: MAX_CAPTURE_BYTES })
237-
238-
if (final.status === "timeout") {
244+
const result = yield* runtime.job.block({ id: job.id, sessionID: context.sessionID }).pipe(
245+
Effect.onInterrupt(() => runtime.job.cancel(job.id).pipe(Effect.ignore)),
246+
)
247+
if (result?.type === "backgrounded") {
248+
yield* notifyWhenDone(context.sessionID, context.toolCallID, input.command)
239249
return {
240-
exit: final.exit,
241-
output: `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
250+
output: BACKGROUND_STARTED,
251+
shellID: info.id,
242252
truncated: false,
243-
timeout: true,
244-
status: "completed" as const,
253+
status: "running" as const,
245254
...(warnings.length ? { warnings } : {}),
246255
}
247256
}
257+
if (result?.info.status === "error") return yield* Effect.fail(new Error(result.info.error ?? "Command failed"))
258+
if (result?.info.status === "cancelled") return yield* Effect.fail(new Error("Command cancelled"))
248259

249-
const truncated = page.size > page.cursor
250-
const body = page.output || "(no output)"
251-
const notice = truncated ? `\n\n[output truncated; full output saved to: ${final.file}]` : ""
252260
return {
253-
exit: final.exit,
254-
output: `${body}${notice}`,
255-
truncated,
256-
status: "completed" as const,
261+
...(yield* settleShell()),
257262
...(warnings.length ? { warnings } : {}),
258263
}
259264
}).pipe(Effect.mapError(() => new ToolFailure({ message: `Unable to execute command: ${input.command}` }))),

packages/core/test/tool-shell.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import fs from "fs/promises"
22
import { realpathSync } from "node:fs"
33
import path from "path"
44
import { describe, expect, test } from "bun:test"
5-
import { DateTime, Effect, Layer } from "effect"
5+
import { DateTime, Effect, Fiber, Layer, Scope } from "effect"
66
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
77
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
88
import { makeGlobalNode } from "@opencode-ai/core/effect/app-node"
@@ -454,6 +454,51 @@ describe("ShellTool", () => {
454454
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
455455
),
456456
)
457+
458+
it.live("backgrounds a foreground command when the session is signaled", () =>
459+
Effect.acquireUseRelease(
460+
Effect.promise(() => tmpdir()),
461+
(tmp) => {
462+
reset()
463+
return withSession(tmp.path, (registry) =>
464+
Effect.gen(function* () {
465+
const jobs = yield* Job.Service
466+
const scope = yield* Scope.Scope
467+
const waiting = yield* settleTool(registry, call({ command: idleCommand }, "call-background-signal")).pipe(
468+
Effect.forkIn(scope, { startImmediately: true }),
469+
)
470+
471+
const backgroundWhenReady = (remaining = 1000): Effect.Effect<Job.Info[], Error> =>
472+
Effect.gen(function* () {
473+
const backgrounded = yield* jobs.backgroundAll({ sessionID })
474+
if (backgrounded.length > 0) return backgrounded
475+
if (remaining <= 0) return yield* Effect.fail(new Error("Timed out waiting for foreground shell job"))
476+
yield* Effect.promise(() => Bun.sleep(1))
477+
return yield* backgroundWhenReady(remaining - 1)
478+
})
479+
expect(yield* backgroundWhenReady()).toMatchObject([{ id: "call-background-signal", type: "shell" }])
480+
481+
const settled = yield* Fiber.join(waiting)
482+
const structured = settled.output?.structured as Record<string, unknown> | undefined
483+
const shellID = typeof structured?.shellID === "string" ? structured.shellID : undefined
484+
expect(settled.output?.structured).toMatchObject({ truncated: false })
485+
expect(settled.output?.content[0]).toMatchObject({
486+
type: "text",
487+
text: expect.stringContaining("running in the background"),
488+
})
489+
expect(shellID).toStartWith("sh_")
490+
491+
const shell = yield* Shell.Service
492+
if (!shellID) return
493+
const id = ShellSchema.ID.make(shellID)
494+
expect((yield* shell.list()).map((info) => info.id)).toContain(id)
495+
yield* shell.remove(id)
496+
}),
497+
)
498+
},
499+
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
500+
),
501+
)
457502
})
458503

459504
test("keeps locked deferred parity TODOs visible", async () => {

packages/plugin/src/v2/effect/generated/api.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,18 @@ export type Endpoint4_18Input = { readonly sessionID: Endpoint4_18Request["param
182182
export type Endpoint4_18Output = EffectValue<ReturnType<RawClient["server.session"]["session.interrupt"]>>
183183
export type SessionInterruptOperation<E = never> = (input: Endpoint4_18Input) => Effect.Effect<Endpoint4_18Output, E>
184184

185-
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.message"]>[0]
186-
export type Endpoint4_19Input = {
187-
readonly sessionID: Endpoint4_19Request["params"]["sessionID"]
188-
readonly messageID: Endpoint4_19Request["params"]["messageID"]
185+
type Endpoint4_19Request = Parameters<RawClient["server.session"]["session.background"]>[0]
186+
export type Endpoint4_19Input = { readonly sessionID: Endpoint4_19Request["params"]["sessionID"] }
187+
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.background"]>>
188+
export type SessionBackgroundOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
189+
190+
type Endpoint4_20Request = Parameters<RawClient["server.session"]["session.message"]>[0]
191+
export type Endpoint4_20Input = {
192+
readonly sessionID: Endpoint4_20Request["params"]["sessionID"]
193+
readonly messageID: Endpoint4_20Request["params"]["messageID"]
189194
}
190-
export type Endpoint4_19Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
191-
export type SessionMessageOperation<E = never> = (input: Endpoint4_19Input) => Effect.Effect<Endpoint4_19Output, E>
195+
export type Endpoint4_20Output = EffectValue<ReturnType<RawClient["server.session"]["session.message"]>>["data"]
196+
export type SessionMessageOperation<E = never> = (input: Endpoint4_20Input) => Effect.Effect<Endpoint4_20Output, E>
192197

193198
export interface SessionApi<E = never> {
194199
readonly list: SessionListOperation<E>
@@ -210,6 +215,7 @@ export interface SessionApi<E = never> {
210215
readonly history: SessionHistoryOperation<E>
211216
readonly events: SessionEventsOperation<E>
212217
readonly interrupt: SessionInterruptOperation<E>
218+
readonly background: SessionBackgroundOperation<E>
213219
readonly message: SessionMessageOperation<E>
214220
}
215221

packages/protocol/src/groups/session.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,22 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
411411
}),
412412
),
413413
)
414+
.add(
415+
HttpApiEndpoint.post("session.background", "/api/session/:sessionID/background", {
416+
params: { sessionID: Session.ID },
417+
success: HttpApiSchema.NoContent,
418+
error: SessionNotFoundError,
419+
})
420+
.middleware(sessionLocationMiddleware)
421+
.annotateMerge(
422+
OpenApi.annotations({
423+
identifier: "v2.session.background",
424+
summary: "Background blocking session tools",
425+
description:
426+
"Move active foreground backgroundable tools for this session into background observation. Idle requests are a no-op.",
427+
}),
428+
),
429+
)
414430
.add(
415431
HttpApiEndpoint.get("session.message", "/api/session/:sessionID/message/:messageID", {
416432
params: { sessionID: Session.ID, messageID: SessionMessage.ID },

packages/schema/src/tui-event.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export const CommandExecute = Event.define({
1919
"session.new",
2020
"session.share",
2121
"session.interrupt",
22+
"session.background",
2223
"session.compact",
2324
"session.page.up",
2425
"session.page.down",

0 commit comments

Comments
 (0)