diff --git a/packages/cli/test/batch-cancellation.test.ts b/packages/cli/test/batch-cancellation.test.ts new file mode 100644 index 0000000..38ae99b --- /dev/null +++ b/packages/cli/test/batch-cancellation.test.ts @@ -0,0 +1,75 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { runLoop, type LoopCheckpoint, type LoopEvent } from "@motifcode/core"; +import { ScriptedTransport, doneBody, toolCallBody } from "@motifcode/replay"; +import { CORE_TOOLS } from "@motifcode/tools"; +import { ToolExecutor } from "../src/executor.js"; + +describe("tool batch cancellation", () => { + for (const cancelAt of ["tool_start", "tool_end", undefined] as const) { + const cancel = cancelAt !== undefined; + it(`preserves the transcript when cancellation occurs at ${cancelAt ?? "no boundary"}`, async () => { + const cwd = mkdtempSync(join(tmpdir(), "motif-batch-")); + writeFileSync(join(cwd, "input.txt"), "read completed"); + const executor = new ToolExecutor({ cwd }); + const controller = new AbortController(); + const events: LoopEvent[] = []; + const checkpoints: LoopCheckpoint[] = []; + const transport = new ScriptedTransport([ + toolCallBody("read", { path: "input.txt" }) + + '{"name":"write","arguments":{"path":"marker.txt","content":"written"}}', + doneBody("finished"), doneBody("finished", { confirm: true }), + ]); + try { + const result = await runLoop({ + transport, tools: [...CORE_TOOLS], system: () => "Test agent", userTask: "read then write", + executor, signal: controller.signal, + emit: (event) => { + events.push(event); + // Cancel synchronously at the completed first call's production + // boundary, without a race against a timeout or child process. + if (event.type === cancelAt) controller.abort(); + }, + onCheckpoint: (checkpoint) => checkpoints.push(checkpoint), + }); + expect(result.reason).toBe(cancel ? "aborted" : "done"); + expect(transport.seen).toHaveLength(cancel ? 1 : 3); + expect(events.filter((event) => event.type === "tool_start").map((event) => event.call.name)) + .toEqual(cancel ? ["read"] : ["read", "write"]); + expect(events.filter((event) => event.type === "tool_end")).toHaveLength(cancel ? 1 : 2); + expect(existsSync(join(cwd, "marker.txt"))).toBe(!cancel); + if (!cancel) expect(readFileSync(join(cwd, "marker.txt"), "utf8")).toBe("written"); + const last = checkpoints.at(-1)!; + expect(last.inFlightTool).toBeUndefined(); + expect(last.messages).toEqual(result.transcript); + if (cancel) { + expect(JSON.stringify(result.transcript)).toContain("Tool call not executed"); + expect(checkpoints.filter((checkpoint) => checkpoint.inFlightTool?.name === "write")).toHaveLength(0); + expect(events.some((event) => event.type === "repair")).toBe(false); + } + } finally { + executor.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }); + } + + it("refuses an already-cancelled direct write before approval", async () => { + const cwd = mkdtempSync(join(tmpdir(), "motif-aborted-")); + const confirm = vi.fn(async () => "allow" as const); + const executor = new ToolExecutor({ cwd, confirm }); + const controller = new AbortController(); + controller.abort(); + try { + const result = await executor.run({ id: "write", name: "write", arguments: { path: "marker.txt", content: "x" }, repaired: false, validated: true }, controller.signal); + expect(result.ok).toBe(false); + expect(confirm).not.toHaveBeenCalled(); + expect(existsSync(join(cwd, "marker.txt"))).toBe(false); + } finally { + executor.close(); + rmSync(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index 4f4ebf9..8102759 100644 --- a/packages/core/src/loop.ts +++ b/packages/core/src/loop.ts @@ -889,7 +889,23 @@ export async function runLoop(opts: LoopOptions): Promise { let anyFailure = false; let combinedOutput = ""; - for (const call of calls) { + for (const [index, call] of calls.entries()) { + if (signal?.aborted) { + // The assistant already advertised the entire batch. Close each unused + // call explicitly so a resumed transcript has no dangling tool calls, + // without emitting execution events or durable intent for skipped work. + for (const skipped of calls.slice(index)) { + session.appendAll(codec.serializeObservation({ + callId: skipped.id, + name: skipped.name, + arguments: skipped.arguments, + output: "Tool call not executed: the session was cancelled before this call started.", + ok: false, + })); + } + checkpoint(); + return finish("aborted"); + } emit({ type: "tool_start", call }); // Durable intent, written before anything runs. Without it, a crash mid // `apply_patch` is indistinguishable from a crash before it. @@ -920,6 +936,7 @@ export async function runLoop(opts: LoopOptions): Promise { checkpoint(); } + if (signal?.aborted) return finish("aborted"); const verdict = guard.observe(LoopGuard.signature(calls), combinedOutput); if (verdict.tripped) { emit({ type: "loop_detected", signature: verdict.signature.slice(0, 120), repeats: verdict.repeats });