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%
10.1 KB · 267 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: tests/agent/kernel.test.ts4 * Description: Kernel loop tests — happy-path turn, event order per EVENT_MODEL, permission-denied recovery, deriveNext purity.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { rmSync, writeFileSync } from "node:fs";11import * as path from "node:path";12import { afterEach, describe, expect, it } from "vitest";13import { deriveNext, foldTurnState } from "../../src/agent/index.js";14import { NON_INTERACTIVE_FEEDBACK } from "../../src/permissions/index.js";15import { buildConversation } from "../../src/session/index.js";16import {17  assertPairingSafe,18  durableTypes,19  makeHarness,20  textTurn,21  toolTurn,22} from "./fixtures.js";23import type { AgentHarness } from "./fixtures.js";2425let harness: AgentHarness | null = null;2627afterEach(async () => {28  if (harness !== null) {29    await harness.log.close();30    rmSync(harness.dir, { recursive: true, force: true });31    harness = null;32  }33});3435describe("AgentKernel.runTurn — happy path", () => {36  it("runs user → text+tool call → tool result → final text → completion evidence", async () => {37    harness = await makeHarness({38      turns: [39        toolTurn("req_1", "Let me read the file.", [40          { toolUseId: "toolu_1", toolName: "read", input: { file_path: "notes.txt" } },41        ]),42        textTurn("req_2", "The file says hello."),43      ],44    });45    writeFileSync(path.join(harness.dir, "notes.txt"), "hello agent\n", "utf8");4647    harness.user("What does notes.txt say?");48    const outcome = await harness.kernel.runTurn();4950    expect(outcome.kind).toBe("done");51    if (outcome.kind !== "done") return;52    expect(outcome.evidence.objective).toBe("What does notes.txt say?");53    expect(outcome.evidence.changedFiles).toEqual([]);54    expect(outcome.evidence.checks).toEqual([]);55    expect(outcome.evidence.unresolvedIssues).toEqual([]);5657    // Event order matches EVENT_MODEL §3 for one full turn.58    expect(durableTypes(harness.events())).toEqual([59      "user.message-created",60      "model.request-started",61      "model.text-block-completed",62      "tool.requested",63      "model.response-completed",64      "tool.approved",65      "tool.started",66      "file.read",67      "tool.completed",68      "model.request-started",69      "model.text-block-completed",70      "model.response-completed",71      "task.completed",72    ]);73    assertPairingSafe(harness.events());7475    // The second request carries the tool result back to the model.76    expect(harness.client.requests).toHaveLength(2);77    const second = harness.client.requests[1];78    const lastMessage = second?.messages[second.messages.length - 1];79    expect(lastMessage?.role).toBe("user");80    expect(81      lastMessage?.content.some(82        (block) => block.type === "tool_result" && block.tool_use_id === "toolu_1",83      ),84    ).toBe(true);85  });8687  it("returns idle when no user message exists", async () => {88    harness = await makeHarness({ turns: [] });89    const outcome = await harness.kernel.runTurn();90    expect(outcome).toEqual({ kind: "idle" });91    expect(harness.events()).toHaveLength(0);92  });93});9495describe("AgentKernel.runTurn — permission denied feeds the model, not the user", () => {96  it("records ToolFailed(permission-denied) and returns the feedback as an is_error tool_result", async () => {97    harness = await makeHarness({98      // Shipped defaults only: `process.execute` for a non-allowlisted command99      // evaluates to ask; no asker is wired → deny (silence is not consent).100      rules: [],101      turns: [102        toolTurn("req_1", "Running the script.", [103          { toolUseId: "toolu_1", toolName: "bash", input: { command: "npm install" } },104        ]),105        textTurn("req_2", "I could not run the command; here is what to do manually."),106      ],107    });108109    harness.user("Install the dependencies");110    const outcome = await harness.kernel.runTurn();111    expect(outcome.kind).toBe("done");112113    const events = harness.events();114    const types = durableTypes(events);115    expect(types).toContain("permission.requested");116    expect(types).toContain("permission.denied");117    expect(types).not.toContain("tool.approved");118    expect(types).not.toContain("tool.started");119120    const failed = events.find((event) => event.type === "tool.failed");121    expect(failed).toBeDefined();122    if (failed?.type !== "tool.failed") return;123    expect(failed.payload.errorKind).toBe("permission-denied");124    expect(failed.payload.modelText).toBe(NON_INTERACTIVE_FEEDBACK);125126    // The model saw the denial as a recoverable is_error observation.127    const second = harness.client.requests[1];128    const lastMessage = second?.messages[second.messages.length - 1];129    const errorResult = lastMessage?.content.find((block) => block.type === "tool_result");130    expect(errorResult?.type).toBe("tool_result");131    if (errorResult?.type !== "tool_result") return;132    expect(errorResult.is_error).toBe(true);133    expect(errorResult.content).toBe(NON_INTERACTIVE_FEEDBACK);134    assertPairingSafe(events);135  });136137  it("feeds invalid tool input back as repair prose", async () => {138    harness = await makeHarness({139      turns: [140        toolTurn("req_1", "Reading.", [141          { toolUseId: "toolu_1", toolName: "read", input: {} }, // missing file_path142        ]),143        textTurn("req_2", "Retrying differently."),144      ],145    });146    harness.user("Read something");147    const outcome = await harness.kernel.runTurn();148    expect(outcome.kind).toBe("done");149    const failed = harness.events().find((event) => event.type === "tool.failed");150    if (failed?.type !== "tool.failed") throw new Error("expected tool.failed");151    expect(failed.payload.errorKind).toBe("invalid-input");152    expect(failed.payload.modelText).toContain('parameter "file_path" is required');153    assertPairingSafe(harness.events());154  });155});156157describe("AgentKernel.runTurn — failure paths stay honest", () => {158  it("records TaskFailed(iteration-budget-exhausted) instead of a fabricated completion", async () => {159    // Model keeps producing tool calls forever; the budget must stop the turn.160    harness = await makeHarness({161      maxIterations: 3,162      turns: Array.from({ length: 5 }, (_, i) =>163        toolTurn(`req_${i}`, "Looking again.", [164          { toolUseId: `toolu_${i}`, toolName: "glob", input: { pattern: "**/*.zzz" } },165        ]),166      ),167    });168    harness.user("Loop forever");169    const outcome = await harness.kernel.runTurn();170    expect(outcome).toMatchObject({ kind: "failed", reason: "iteration-budget-exhausted" });171    const failed = harness.events().find((event) => event.type === "task.failed");172    if (failed?.type !== "task.failed") throw new Error("expected task.failed");173    expect(failed.payload.reason).toBe("iteration-budget-exhausted");174    expect(durableTypes(harness.events())).not.toContain("task.completed");175    assertPairingSafe(harness.events());176  });177178  it("records TaskFailed(model-fatal-error) on a non-recoverable model error", async () => {179    harness = await makeHarness({ turns: [] }); // scripted client throws invalid-request180    harness.user("Hello");181    const outcome = await harness.kernel.runTurn();182    expect(outcome).toMatchObject({ kind: "failed", reason: "model-fatal-error" });183    const types = durableTypes(harness.events());184    expect(types).toContain("model.request-failed");185    expect(types).toContain("task.failed");186    expect(types).not.toContain("task.completed");187  });188});189190describe("deriveNext — pure decisions over recorded state", () => {191  const gate = { required: false, attempts: 0, candidateSeq: 0 };192  const opts = { shouldCompact: false, iterationsLeft: 10 };193194  it("is idle with no user message", () => {195    const state = foldTurnState([]);196    expect(deriveNext(state, gate, opts)).toEqual({ kind: "idle" });197  });198199  it("prefers pending tools over steering, and steering over the model call", () => {200    const base = foldTurnState([]);201    expect(202      deriveNext(203        {204          ...base,205          hasUserMessage: true,206          pending: [{ toolUseId: "t1", toolName: "read", input: {}, blockIndex: 0 }],207          queuedSteering: 1,208        },209        gate,210        opts,211      ).kind,212    ).toBe("execute-tools");213    expect(214      deriveNext({ ...base, hasUserMessage: true, queuedSteering: 1 }, gate, opts).kind,215    ).toBe("inject-steering");216    expect(deriveNext({ ...base, hasUserMessage: true }, gate, opts).kind).toBe("call-model");217  });218219  it("routes unresolved overflow to compaction before anything else model-facing", () => {220    const base = foldTurnState([]);221    const state = { ...base, hasUserMessage: true, overflowUnresolved: true };222    expect(deriveNext(state, gate, opts).kind).toBe("compact");223  });224225  it("gates completion on verification and stops nudging after two attempts", () => {226    const base = foldTurnState([]);227    const answered = {228      ...base,229      hasUserMessage: true,230      lastResponse: { seq: 10, stopReason: "end_turn" as const },231      lastInputSeq: 5,232    };233    expect(234      deriveNext(answered, { required: true, attempts: 0, candidateSeq: 9 }, opts),235    ).toEqual({ kind: "verify", attempt: 1, candidateSeq: 9 });236    expect(237      deriveNext(answered, { required: true, attempts: 2, candidateSeq: 9 }, opts).kind,238    ).toBe("done");239    expect(deriveNext(answered, gate, opts).kind).toBe("done");240  });241242  it("stops at the iteration budget instead of calling the model again", () => {243    const base = foldTurnState([]);244    const state = { ...base, hasUserMessage: true };245    expect(deriveNext(state, gate, { shouldCompact: false, iterationsLeft: 0 }).kind).toBe(246      "budget-exhausted",247    );248  });249});250251describe("conversation projection over a recorded turn", () => {252  it("rebuilds a valid tool loop from the log alone", async () => {253    harness = await makeHarness({254      turns: [255        toolTurn("req_1", "Checking.", [256          { toolUseId: "toolu_1", toolName: "glob", input: { pattern: "*.txt" } },257        ]),258        textTurn("req_2", "No text files."),259      ],260    });261    harness.user("Any text files?");262    await harness.kernel.runTurn();263    const messages = buildConversation(harness.events());264    expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "user", "assistant"]);265  });266});267