SPB Git

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%
6.2 KB · 171 lines typescript
Raw Blame History
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