From dc08129e12c87038eed013e9c4507dfbfa314619 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 4 Nov 2026 23:28:01 +0000 Subject: [PATCH] fix(codemode): report a durable call cancelled with its run to tool.after, and document suspend and resume hooks --- packages/codemode/README.md | 7 ++++++ packages/codemode/src/interpreter/driver.ts | 2 +- packages/codemode/src/tool-runtime.ts | 13 +++++++--- packages/codemode/test/suspend.test.ts | 28 +++++++++++++++++++++ 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/packages/codemode/README.md b/packages/codemode/README.md index bfe506a2457..e92e69ede52 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -143,6 +143,13 @@ const build = Tool.make({ }) ``` +Every call sees its key: a tool's `execute` gets it as `execute(input, { key })`, and both hooks as `call.key`. A durable +call's `tool.before` runs when it starts and its `tool.after` when its work ends, possibly in a later resume; a call +detached by a suspend reports nothing until then, while one cancelled because its run ended reports `interrupted`. +Tools are `retry: "unsafe"` by default. + +Nothing in OpenCode suspends runs yet; this is the capability a host builds on. + Set `CODEMODE_ROUNDTRIP=1` (`bun run test:roundtrip`) to run the tests with every run saved to bytes and restored into a brand-new runtime at every host call and every few steps. diff --git a/packages/codemode/src/interpreter/driver.ts b/packages/codemode/src/interpreter/driver.ts index 17b632fc252..e998e875382 100644 --- a/packages/codemode/src/interpreter/driver.ts +++ b/packages/codemode/src/interpreter/driver.ts @@ -195,7 +195,7 @@ export class Driver { private attach(call: HostCall): void { const request = call.request if (request.kind !== "tool") return - this.fork(call.id, this.tools.attach(request.path, request.args, call.key, call.handle!)) + this.fork(call.id, this.tools.attach(request.path, request.args, call.key, call.handle!, () => this.suspending)) } // A durable call that may have started before the run was saved: reattach to its work if it did, else start it. diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 041a7a7f5ff..1bf3879db7a 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -405,12 +405,17 @@ export type ToolRuntime = { key: string, again?: boolean, ) => Effect.Effect - /** Waits for a durable tool's work: its output, as `execute` gives it. Interrupting it detaches. */ + /** + * Waits for a durable tool's work: its output, as `execute` gives it. Interrupting it detaches. `detaching` says + * whether an interruption detaches to suspend the run, which `tool.after` doesn't see, or ends the call, which it + * sees as interrupted. + */ readonly attach: ( path: ReadonlyArray, args: Array, key: string, handle: Handle, + detaching: () => boolean, ) => Effect.Effect /** The handle of a durable tool's work started under `key`, if it was. */ readonly lookup: (path: ReadonlyArray, key: string) => Effect.Effect @@ -522,7 +527,7 @@ export const make = ( return yield* failure(Effect.suspend(() => found.durable.start(input, { key }))) }) }), - attach: (path, args, key, handle) => + attach: (path, args, key, handle, detaching) => Effect.suspend(() => { const found = durableTool(path) const after = hooks["tool.after"] @@ -531,12 +536,12 @@ export const make = ( return yield* output(found.name, found.tool, raw) }) if (after === undefined) return run - // Detaching is not an outcome: `after` sees how the work ended, once it has. + // Detaching to suspend is not an outcome: `after` sees how the work ended, once it has. return Effect.flatMap(decode(found.name, found.tool, args), (input) => Effect.onExit(run, (exit) => { const call = { name: found.name, input, key } if (Exit.isSuccess(exit)) return after(call, { status: "success", value: exit.value }) - if (Cause.hasInterruptsOnly(exit.cause)) return Effect.void + if (Cause.hasInterruptsOnly(exit.cause)) return detaching() ? Effect.void : after(call, { status: "interrupted" }) return after(call, { status: "failure", error: Cause.squash(exit.cause) }) }), ) diff --git a/packages/codemode/test/suspend.test.ts b/packages/codemode/test/suspend.test.ts index 17cf480bde5..f03a0db0991 100644 --- a/packages/codemode/test/suspend.test.ts +++ b/packages/codemode/test/suspend.test.ts @@ -401,6 +401,34 @@ describe("suspend and resume", () => { expect(keys.toSorted()).toEqual(["after echo k:1", "after job k:0", "before echo k:1", "before job k:0"]) }) + test("tool.after sees a durable call that ends with its run as interrupted, but not one detached to suspend", async () => { + const jobs = durableJobs() + const events: Array = [] + const hooks = { + "tool.after": (call: CodeMode.ToolInvocation, result: CodeMode.CallResult) => + Effect.sync(() => void events.push(`${call.key} ${result.status}`)), + } + const ended = await Effect.runPromise( + CodeMode.make({ tools: { job: jobs.tool }, hooks }).start(`tools.job({ value: 1 }); return 1`, undefined, { + runId: "a", + }).result, + ) + expect(ended).toMatchObject({ ok: true }) + expect(events).toEqual(["a:0 interrupted"]) + + events.length = 0 + jobs.reset() + const run = CodeMode.make({ tools: { job: jobs.tool }, hooks }).start(`return (await tools.job({ value: 2 })).value`, undefined, { + runId: "b", + }) + Effect.runFork(run.result) + await jobs.started() + await Bun.sleep(5) + const suspended = await Effect.runPromise(run.suspend) + expect(suspended).toMatchObject({ ok: "suspended", pending: [{ key: "b:0", status: "started" }] }) + expect(events).toEqual([]) + }) + test("the snapshot codec does not import Effect", async () => { for (const file of ["codec.ts", "snapshot.ts"]) { expect(await Bun.file(`${import.meta.dir}/../src/snapshot/${file}`).text()).not.toMatch(/from "effect"/)