/** * KHAELOR * File: tests/agent/interruption.test.ts * Description: Interruption tests — mid-stream abort closes dangling tool_use; the session resumes cleanly from the JSONL log. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { rmSync } from "node:fs"; import { afterEach, describe, expect, it } from "vitest"; import { recoverDanglingOnResume } from "../../src/agent/index.js"; import { CANCELLED_RESULT_CONTENT } from "../../src/tools/index.js"; import { findDanglingToolUseIds } from "../../src/session/index.js"; import { assertPairingSafe, durableTypes, makeHarness, textTurn, waitFor, } from "./fixtures.js"; import type { AgentHarness } from "./fixtures.js"; const harnesses: AgentHarness[] = []; const dirs = new Set(); afterEach(async () => { for (const harness of harnesses.splice(0)) { await harness.log.close().catch(() => undefined); dirs.add(harness.dir); } for (const dir of dirs) rmSync(dir, { recursive: true, force: true }); dirs.clear(); }); describe("interrupt mid-stream", () => { it("aborts the stream, records Interrupted, closes the dangling tool_use, and stays resumable", async () => { const harness = await makeHarness({ turns: [ { // The model settles a text block and a tool_use, then the stream // hangs — the user hits Esc before message_stop ever arrives. events: [ { type: "started", requestId: "req_1" }, { type: "text-block-completed", blockIndex: 0, text: "Let me read the config." }, { type: "tool-call-completed", blockIndex: 1, toolUseId: "toolu_hang", toolName: "read", input: { file_path: "config.json" }, }, ], hangUntilAbort: true, }, ], }); harnesses.push(harness); harness.user("Inspect the config"); const running = harness.kernel.runTurn(); await waitFor(() => harness.events().some((event) => event.type === "tool.requested")); expect(harness.interruption.turnActive).toBe(true); expect(harness.interruption.interrupt()).toBe(true); expect(harness.interruption.interrupt()).toBe(false); // idempotent const outcome = await running; expect(outcome).toEqual({ kind: "interrupted" }); const events = harness.events(); const interrupted = events.find((event) => event.type === "user.interrupted"); if (interrupted?.type !== "user.interrupted") throw new Error("expected user.interrupted"); expect(interrupted.payload.pendingToolUseIds).toEqual(["toolu_hang"]); const cancelled = events.find((event) => event.type === "tool.cancelled"); if (cancelled?.type !== "tool.cancelled") throw new Error("expected tool.cancelled"); expect(cancelled.payload.toolUseId).toBe("toolu_hang"); expect(cancelled.payload.reason).toBe("interrupted"); expect(cancelled.payload.modelText).toBe(CANCELLED_RESULT_CONTENT); const failed = events.find((event) => event.type === "model.request-failed"); if (failed?.type !== "model.request-failed") throw new Error("expected model.request-failed"); expect(failed.payload.kind).toBe("cancelled"); // History is protocol-valid at all times — no dangling tool_use. assertPairingSafe(events); }); it("resumes cleanly from the JSONL log after an interrupted turn", async () => { const first = await makeHarness({ turns: [ { events: [ { type: "started", requestId: "req_1" }, { type: "tool-call-completed", blockIndex: 0, toolUseId: "toolu_a", toolName: "glob", input: { pattern: "*.md" }, }, ], hangUntilAbort: true, }, ], }); harnesses.push(first); first.user("Find the docs"); const running = first.kernel.runTurn(); await waitFor(() => first.events().some((event) => event.type === "tool.requested")); first.interruption.interrupt(); expect((await running).kind).toBe("interrupted"); const recordedSeqs = first.events().length; await first.log.close(); // flush every append to disk // Resume: replay the SAME log file through a fresh harness. const resumed = await makeHarness({ dir: first.dir, resume: { sessionsDir: first.sessionsDir, sessionId: first.log.sessionId }, turns: [textTurn("req_2", "Docs are in docs/.")], }); harnesses.push(resumed); expect(resumed.log.replayedEvents).toHaveLength(recordedSeqs); // The interrupt already closed everything — resume recovery finds nothing. expect(findDanglingToolUseIds(resumed.events())).toEqual([]); expect(recoverDanglingOnResume(resumed.session)).toEqual([]); resumed.user("Continue: where are the docs?"); const outcome = await resumed.kernel.runTurn(); expect(outcome.kind).toBe("done"); assertPairingSafe(resumed.events()); expect(durableTypes(resumed.events())).toContain("task.completed"); }); it("resume recovery closes tool_use blocks left dangling by a crash", async () => { // Simulate a crash: record a ToolRequested with no terminal event, then reopen. const first = await makeHarness({ turns: [] }); harnesses.push(first); first.user("Crash mid-tool"); first.session.publishDurable({ type: "tool.requested", payload: { requestId: "req_1", blockIndex: 0, toolUseId: "toolu_crashed", toolName: "bash", input: { command: "sleep 100" }, }, }); await first.log.close(); const resumed = await makeHarness({ dir: first.dir, resume: { sessionsDir: first.sessionsDir, sessionId: first.log.sessionId }, turns: [], }); harnesses.push(resumed); expect(findDanglingToolUseIds(resumed.events())).toEqual(["toolu_crashed"]); expect(recoverDanglingOnResume(resumed.session)).toEqual(["toolu_crashed"]); const cancelled = resumed.events().find((event) => event.type === "tool.cancelled"); if (cancelled?.type !== "tool.cancelled") throw new Error("expected tool.cancelled"); expect(cancelled.payload.reason).toBe("resume-recovery"); assertPairingSafe(resumed.events()); }); });