spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: tests/agent/interruption.test.ts4 * Description: Interruption tests — mid-stream abort closes dangling tool_use; the session resumes cleanly from the JSONL log.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { rmSync } from "node:fs";11import { afterEach, describe, expect, it } from "vitest";12import { recoverDanglingOnResume } from "../../src/agent/index.js";13import { CANCELLED_RESULT_CONTENT } from "../../src/tools/index.js";14import { findDanglingToolUseIds } from "../../src/session/index.js";15import {16 assertPairingSafe,17 durableTypes,18 makeHarness,19 textTurn,20 waitFor,21} from "./fixtures.js";22import type { AgentHarness } from "./fixtures.js";2324const harnesses: AgentHarness[] = [];25const dirs = new Set<string>();2627afterEach(async () => {28 for (const harness of harnesses.splice(0)) {29 await harness.log.close().catch(() => undefined);30 dirs.add(harness.dir);31 }32 for (const dir of dirs) rmSync(dir, { recursive: true, force: true });33 dirs.clear();34});3536describe("interrupt mid-stream", () => {37 it("aborts the stream, records Interrupted, closes the dangling tool_use, and stays resumable", async () => {38 const harness = await makeHarness({39 turns: [40 {41 // The model settles a text block and a tool_use, then the stream42 // hangs — the user hits Esc before message_stop ever arrives.43 events: [44 { type: "started", requestId: "req_1" },45 { type: "text-block-completed", blockIndex: 0, text: "Let me read the config." },46 {47 type: "tool-call-completed",48 blockIndex: 1,49 toolUseId: "toolu_hang",50 toolName: "read",51 input: { file_path: "config.json" },52 },53 ],54 hangUntilAbort: true,55 },56 ],57 });58 harnesses.push(harness);5960 harness.user("Inspect the config");61 const running = harness.kernel.runTurn();62 await waitFor(() => harness.events().some((event) => event.type === "tool.requested"));6364 expect(harness.interruption.turnActive).toBe(true);65 expect(harness.interruption.interrupt()).toBe(true);66 expect(harness.interruption.interrupt()).toBe(false); // idempotent6768 const outcome = await running;69 expect(outcome).toEqual({ kind: "interrupted" });7071 const events = harness.events();72 const interrupted = events.find((event) => event.type === "user.interrupted");73 if (interrupted?.type !== "user.interrupted") throw new Error("expected user.interrupted");74 expect(interrupted.payload.pendingToolUseIds).toEqual(["toolu_hang"]);7576 const cancelled = events.find((event) => event.type === "tool.cancelled");77 if (cancelled?.type !== "tool.cancelled") throw new Error("expected tool.cancelled");78 expect(cancelled.payload.toolUseId).toBe("toolu_hang");79 expect(cancelled.payload.reason).toBe("interrupted");80 expect(cancelled.payload.modelText).toBe(CANCELLED_RESULT_CONTENT);8182 const failed = events.find((event) => event.type === "model.request-failed");83 if (failed?.type !== "model.request-failed") throw new Error("expected model.request-failed");84 expect(failed.payload.kind).toBe("cancelled");8586 // History is protocol-valid at all times — no dangling tool_use.87 assertPairingSafe(events);88 });8990 it("resumes cleanly from the JSONL log after an interrupted turn", async () => {91 const first = await makeHarness({92 turns: [93 {94 events: [95 { type: "started", requestId: "req_1" },96 {97 type: "tool-call-completed",98 blockIndex: 0,99 toolUseId: "toolu_a",100 toolName: "glob",101 input: { pattern: "*.md" },102 },103 ],104 hangUntilAbort: true,105 },106 ],107 });108 harnesses.push(first);109110 first.user("Find the docs");111 const running = first.kernel.runTurn();112 await waitFor(() => first.events().some((event) => event.type === "tool.requested"));113 first.interruption.interrupt();114 expect((await running).kind).toBe("interrupted");115 const recordedSeqs = first.events().length;116 await first.log.close(); // flush every append to disk117118 // Resume: replay the SAME log file through a fresh harness.119 const resumed = await makeHarness({120 dir: first.dir,121 resume: { sessionsDir: first.sessionsDir, sessionId: first.log.sessionId },122 turns: [textTurn("req_2", "Docs are in docs/.")],123 });124 harnesses.push(resumed);125126 expect(resumed.log.replayedEvents).toHaveLength(recordedSeqs);127 // The interrupt already closed everything — resume recovery finds nothing.128 expect(findDanglingToolUseIds(resumed.events())).toEqual([]);129 expect(recoverDanglingOnResume(resumed.session)).toEqual([]);130131 resumed.user("Continue: where are the docs?");132 const outcome = await resumed.kernel.runTurn();133 expect(outcome.kind).toBe("done");134 assertPairingSafe(resumed.events());135 expect(durableTypes(resumed.events())).toContain("task.completed");136 });137138 it("resume recovery closes tool_use blocks left dangling by a crash", async () => {139 // Simulate a crash: record a ToolRequested with no terminal event, then reopen.140 const first = await makeHarness({ turns: [] });141 harnesses.push(first);142 first.user("Crash mid-tool");143 first.session.publishDurable({144 type: "tool.requested",145 payload: {146 requestId: "req_1",147 blockIndex: 0,148 toolUseId: "toolu_crashed",149 toolName: "bash",150 input: { command: "sleep 100" },151 },152 });153 await first.log.close();154155 const resumed = await makeHarness({156 dir: first.dir,157 resume: { sessionsDir: first.sessionsDir, sessionId: first.log.sessionId },158 turns: [],159 });160 harnesses.push(resumed);161162 expect(findDanglingToolUseIds(resumed.events())).toEqual(["toolu_crashed"]);163 expect(recoverDanglingOnResume(resumed.session)).toEqual(["toolu_crashed"]);164165 const cancelled = resumed.events().find((event) => event.type === "tool.cancelled");166 if (cancelled?.type !== "tool.cancelled") throw new Error("expected tool.cancelled");167 expect(cancelled.payload.reason).toBe("resume-recovery");168 assertPairingSafe(resumed.events());169 });170});171