/** * KHAELOR * File: tests/agent/kernel.test.ts * Description: Kernel loop tests — happy-path turn, event order per EVENT_MODEL, permission-denied recovery, deriveNext purity. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { rmSync, writeFileSync } from "node:fs"; import * as path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { deriveNext, foldTurnState } from "../../src/agent/index.js"; import { NON_INTERACTIVE_FEEDBACK } from "../../src/permissions/index.js"; import { buildConversation } from "../../src/session/index.js"; import { assertPairingSafe, durableTypes, makeHarness, textTurn, toolTurn, } from "./fixtures.js"; import type { AgentHarness } from "./fixtures.js"; let harness: AgentHarness | null = null; afterEach(async () => { if (harness !== null) { await harness.log.close(); rmSync(harness.dir, { recursive: true, force: true }); harness = null; } }); describe("AgentKernel.runTurn — happy path", () => { it("runs user → text+tool call → tool result → final text → completion evidence", async () => { harness = await makeHarness({ turns: [ toolTurn("req_1", "Let me read the file.", [ { toolUseId: "toolu_1", toolName: "read", input: { file_path: "notes.txt" } }, ]), textTurn("req_2", "The file says hello."), ], }); writeFileSync(path.join(harness.dir, "notes.txt"), "hello agent\n", "utf8"); harness.user("What does notes.txt say?"); const outcome = await harness.kernel.runTurn(); expect(outcome.kind).toBe("done"); if (outcome.kind !== "done") return; expect(outcome.evidence.objective).toBe("What does notes.txt say?"); expect(outcome.evidence.changedFiles).toEqual([]); expect(outcome.evidence.checks).toEqual([]); expect(outcome.evidence.unresolvedIssues).toEqual([]); // Event order matches EVENT_MODEL §3 for one full turn. expect(durableTypes(harness.events())).toEqual([ "user.message-created", "model.request-started", "model.text-block-completed", "tool.requested", "model.response-completed", "tool.approved", "tool.started", "file.read", "tool.completed", "model.request-started", "model.text-block-completed", "model.response-completed", "task.completed", ]); assertPairingSafe(harness.events()); // The second request carries the tool result back to the model. expect(harness.client.requests).toHaveLength(2); const second = harness.client.requests[1]; const lastMessage = second?.messages[second.messages.length - 1]; expect(lastMessage?.role).toBe("user"); expect( lastMessage?.content.some( (block) => block.type === "tool_result" && block.tool_use_id === "toolu_1", ), ).toBe(true); }); it("returns idle when no user message exists", async () => { harness = await makeHarness({ turns: [] }); const outcome = await harness.kernel.runTurn(); expect(outcome).toEqual({ kind: "idle" }); expect(harness.events()).toHaveLength(0); }); }); describe("AgentKernel.runTurn — permission denied feeds the model, not the user", () => { it("records ToolFailed(permission-denied) and returns the feedback as an is_error tool_result", async () => { harness = await makeHarness({ // Shipped defaults only: `process.execute` for a non-allowlisted command // evaluates to ask; no asker is wired → deny (silence is not consent). rules: [], turns: [ toolTurn("req_1", "Running the script.", [ { toolUseId: "toolu_1", toolName: "bash", input: { command: "npm install" } }, ]), textTurn("req_2", "I could not run the command; here is what to do manually."), ], }); harness.user("Install the dependencies"); const outcome = await harness.kernel.runTurn(); expect(outcome.kind).toBe("done"); const events = harness.events(); const types = durableTypes(events); expect(types).toContain("permission.requested"); expect(types).toContain("permission.denied"); expect(types).not.toContain("tool.approved"); expect(types).not.toContain("tool.started"); const failed = events.find((event) => event.type === "tool.failed"); expect(failed).toBeDefined(); if (failed?.type !== "tool.failed") return; expect(failed.payload.errorKind).toBe("permission-denied"); expect(failed.payload.modelText).toBe(NON_INTERACTIVE_FEEDBACK); // The model saw the denial as a recoverable is_error observation. const second = harness.client.requests[1]; const lastMessage = second?.messages[second.messages.length - 1]; const errorResult = lastMessage?.content.find((block) => block.type === "tool_result"); expect(errorResult?.type).toBe("tool_result"); if (errorResult?.type !== "tool_result") return; expect(errorResult.is_error).toBe(true); expect(errorResult.content).toBe(NON_INTERACTIVE_FEEDBACK); assertPairingSafe(events); }); it("feeds invalid tool input back as repair prose", async () => { harness = await makeHarness({ turns: [ toolTurn("req_1", "Reading.", [ { toolUseId: "toolu_1", toolName: "read", input: {} }, // missing file_path ]), textTurn("req_2", "Retrying differently."), ], }); harness.user("Read something"); const outcome = await harness.kernel.runTurn(); expect(outcome.kind).toBe("done"); const failed = harness.events().find((event) => event.type === "tool.failed"); if (failed?.type !== "tool.failed") throw new Error("expected tool.failed"); expect(failed.payload.errorKind).toBe("invalid-input"); expect(failed.payload.modelText).toContain('parameter "file_path" is required'); assertPairingSafe(harness.events()); }); }); describe("AgentKernel.runTurn — failure paths stay honest", () => { it("records TaskFailed(iteration-budget-exhausted) instead of a fabricated completion", async () => { // Model keeps producing tool calls forever; the budget must stop the turn. harness = await makeHarness({ maxIterations: 3, turns: Array.from({ length: 5 }, (_, i) => toolTurn(`req_${i}`, "Looking again.", [ { toolUseId: `toolu_${i}`, toolName: "glob", input: { pattern: "**/*.zzz" } }, ]), ), }); harness.user("Loop forever"); const outcome = await harness.kernel.runTurn(); expect(outcome).toMatchObject({ kind: "failed", reason: "iteration-budget-exhausted" }); const failed = harness.events().find((event) => event.type === "task.failed"); if (failed?.type !== "task.failed") throw new Error("expected task.failed"); expect(failed.payload.reason).toBe("iteration-budget-exhausted"); expect(durableTypes(harness.events())).not.toContain("task.completed"); assertPairingSafe(harness.events()); }); it("records TaskFailed(model-fatal-error) on a non-recoverable model error", async () => { harness = await makeHarness({ turns: [] }); // scripted client throws invalid-request harness.user("Hello"); const outcome = await harness.kernel.runTurn(); expect(outcome).toMatchObject({ kind: "failed", reason: "model-fatal-error" }); const types = durableTypes(harness.events()); expect(types).toContain("model.request-failed"); expect(types).toContain("task.failed"); expect(types).not.toContain("task.completed"); }); }); describe("deriveNext — pure decisions over recorded state", () => { const gate = { required: false, attempts: 0, candidateSeq: 0 }; const opts = { shouldCompact: false, iterationsLeft: 10 }; it("is idle with no user message", () => { const state = foldTurnState([]); expect(deriveNext(state, gate, opts)).toEqual({ kind: "idle" }); }); it("prefers pending tools over steering, and steering over the model call", () => { const base = foldTurnState([]); expect( deriveNext( { ...base, hasUserMessage: true, pending: [{ toolUseId: "t1", toolName: "read", input: {}, blockIndex: 0 }], queuedSteering: 1, }, gate, opts, ).kind, ).toBe("execute-tools"); expect( deriveNext({ ...base, hasUserMessage: true, queuedSteering: 1 }, gate, opts).kind, ).toBe("inject-steering"); expect(deriveNext({ ...base, hasUserMessage: true }, gate, opts).kind).toBe("call-model"); }); it("routes unresolved overflow to compaction before anything else model-facing", () => { const base = foldTurnState([]); const state = { ...base, hasUserMessage: true, overflowUnresolved: true }; expect(deriveNext(state, gate, opts).kind).toBe("compact"); }); it("gates completion on verification and stops nudging after two attempts", () => { const base = foldTurnState([]); const answered = { ...base, hasUserMessage: true, lastResponse: { seq: 10, stopReason: "end_turn" as const }, lastInputSeq: 5, }; expect( deriveNext(answered, { required: true, attempts: 0, candidateSeq: 9 }, opts), ).toEqual({ kind: "verify", attempt: 1, candidateSeq: 9 }); expect( deriveNext(answered, { required: true, attempts: 2, candidateSeq: 9 }, opts).kind, ).toBe("done"); expect(deriveNext(answered, gate, opts).kind).toBe("done"); }); it("stops at the iteration budget instead of calling the model again", () => { const base = foldTurnState([]); const state = { ...base, hasUserMessage: true }; expect(deriveNext(state, gate, { shouldCompact: false, iterationsLeft: 0 }).kind).toBe( "budget-exhausted", ); }); }); describe("conversation projection over a recorded turn", () => { it("rebuilds a valid tool loop from the log alone", async () => { harness = await makeHarness({ turns: [ toolTurn("req_1", "Checking.", [ { toolUseId: "toolu_1", toolName: "glob", input: { pattern: "*.txt" } }, ]), textTurn("req_2", "No text files."), ], }); harness.user("Any text files?"); await harness.kernel.runTurn(); const messages = buildConversation(harness.events()); expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "user", "assistant"]); }); });