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/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