From 5bab8b3bc3b70294e6b3b7cffd39b1ba944ef674 Mon Sep 17 00:00:00 2001 From: Canitas Date: Sun, 18 Oct 2026 02:32:12 -0700 Subject: [PATCH] fix: run no tool once the run is stopped during its start handlers The loop checked for a stop before the tool_execution_start handlers and not after, so a stop that came while a handler ran still let the tool execute: a write landed after the stop. It now checks again and reports the call as stopped. Implemented by Sol (gpt-6.1-sol), who found it reading loop.ts; reproduced and checked by Claude. --- packages/core/src/loop.ts | 1 + packages/core/tests/conformance.test.ts | 29 ++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/core/src/loop.ts b/packages/core/src/loop.ts index 59339bbd..81329a5b 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 92fbaba9..3d525c99 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");