diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index 59339bbd6..81329a5b1 100644 --- a/packages/core/src/loop.ts +++ b/packages/core/src/loop.ts @@ -485,6 +485,7 @@ export function createLoop(options: LoopOptions): LoopSession { writer.sync(); emit({ type: "call", call }); await host.toolStart({ type: "tool_execution_start", toolCallId: call.id, toolName: call.name, args: call.arguments }, signal); + if (signal.aborted) return stoppedCall; const output = await execute(offered, call, signal); await host.toolResult({ type: "tool_result", toolCallId: call.id, toolName: call.name, input: call.arguments, isError: output.isError }, signal); diff --git a/packages/core/tests/conformance.test.ts b/packages/core/tests/conformance.test.ts index 92fbaba94..3d525c993 100644 --- a/packages/core/tests/conformance.test.ts +++ b/packages/core/tests/conformance.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtempSync } from "node:fs"; +import { existsSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Type } from "typebox"; @@ -54,6 +54,33 @@ for (const engine of engines) { expect(session.outcome()).toBe("completed"); }); + test("stopping during a tool start handler prevents the write and records a stopped call", async () => { + const cwd = mkdtempSync(join(tmpdir(), "tesota-core-cwd-")); + let entered: () => void = () => undefined; + let release: () => void = () => undefined; + const started = new Promise((resolve) => { entered = resolve; }); + const waiting = new Promise((resolve) => { release = resolve; }); + const session = await engine.createSession({ cwd, + script: [{ tool: "write", args: { path: "after-stop.md", content: "after stop" } }, "never reached"], + extensions: [(pi) => { + pi.on("tool_execution_start", async () => { + entered(); + await waiting; + }); + }], + }); + open.push(session); + const run = session.prompt("go"); + await started; + const stopping = session.abort(); + release(); + await Promise.all([run, stopping]); + expect(session.outcome()).toBe("aborted"); + expect(existsSync(join(cwd, "after-stop.md"))).toBe(false); + expect(session.messages().filter((message) => message.role === "toolResult")) + .toMatchObject([{ toolName: "write", isError: true }]); + }); + test("a settle handler sends the agent back once, and the agent answers again", async () => { const session = await start(["first", "second"], [sendBackOnce("gate")]); await session.prompt("go");