fix(codemode): report a durable call cancelled with its run to tool.after, and document suspend and resume hooks

This commit is contained in:
James Long
2026-11-04 23:28:01 +00:00
Unverified
parent edbeec8c2c
commit dc08129e12
4 changed files with 45 additions and 5 deletions
+7
View File
@@ -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.
+1 -1
View File
@@ -195,7 +195,7 @@ export class Driver<R> {
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.
+9 -4
View File
@@ -405,12 +405,17 @@ export type ToolRuntime<R = never> = {
key: string,
again?: boolean,
) => Effect.Effect<Handle, unknown, R>
/** 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<string>,
args: Array<Json | undefined>,
key: string,
handle: Handle,
detaching: () => boolean,
) => Effect.Effect<Json | undefined, unknown, R>
/** The handle of a durable tool's work started under `key`, if it was. */
readonly lookup: (path: ReadonlyArray<string>, key: string) => Effect.Effect<Handle | undefined, unknown, R>
@@ -522,7 +527,7 @@ export const make = <R>(
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 = <R>(
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) })
}),
)
+28
View File
@@ -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<string> = []
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"/)