mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-15 20:52:01 +03:00
fix(codemode): report a durable call cancelled with its run to tool.after, and document suspend and resume hooks
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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"/)
|
||||
|
||||
Reference in New Issue
Block a user