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/fixtures.ts4 * Description: Agent-kernel test fixtures — scripted ModelClient (no network), full real-service harness on a temp dir.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { mkdtempSync } from "node:fs";11import { tmpdir } from "node:os";12import * as path from "node:path";13import { expect } from "vitest";14import {15 AgentKernel,16 EventLogSession,17 InterruptionController,18 SteeringQueue,19 ToolExecutor,20 VerificationGate,21} from "../../src/agent/index.js";22import { ModelError } from "../../src/anthropic/index.js";23import type { ModelClient, ModelEvent, ModelRequest } from "../../src/anthropic/index.js";24import { ContextBudget, KhaelorContextEngine } from "../../src/context/index.js";25import { PermissionService } from "../../src/permissions/index.js";26import type { PermissionRule } from "../../src/permissions/index.js";27import { SessionEventBus, SessionLog, buildConversation, findDanglingToolUseIds } from "../../src/session/index.js";28import type { DurableEvent } from "../../src/session/index.js";29import { createDefaultToolRegistry } from "../../src/tools/index.js";30import {31 InMemoryFileTimeRegistry,32 LocalProcessManager,33 LocalWorkspace,34} from "../../src/workspace/index.js";3536// ───────────────────────── scripted model client ─────────────────────────3738export interface ScriptedTurn {39 /** Called after the request is captured, before any event is yielded. */40 onStart?: () => void;41 events: ModelEvent[];42 /** After yielding events, wait for the abort signal, then throw cancelled. */43 hangUntilAbort?: boolean;44 /** Throw this after yielding events. */45 error?: Error;46}4748/** A ModelClient that replays scripted ModelEvent sequences — no network, honest signals. */49export class ScriptedModelClient implements ModelClient {50 readonly requests: ModelRequest[] = [];51 readonly #turns: ScriptedTurn[];52 #index = 0;5354 constructor(turns: ScriptedTurn[]) {55 this.#turns = turns;56 }5758 async *stream(request: ModelRequest, signal?: AbortSignal): AsyncIterable<ModelEvent> {59 const turn = this.#turns[this.#index];60 this.#index += 1;61 if (turn === undefined) {62 throw new ModelError("invalid-request", "ScriptedModelClient: no more scripted turns.");63 }64 this.requests.push(request);65 turn.onStart?.();66 for (const event of turn.events) {67 if (signal?.aborted === true) throw new ModelError("cancelled", "Request cancelled.");68 await Promise.resolve();69 yield event;70 }71 if (turn.hangUntilAbort === true) {72 await new Promise<void>((resolve) => {73 if (signal?.aborted === true) {74 resolve();75 return;76 }77 signal?.addEventListener("abort", () => resolve(), { once: true });78 });79 throw new ModelError("cancelled", "Request cancelled.");80 }81 if (turn.error !== undefined) throw turn.error;82 }83}8485/** Standard scripted blocks. */86export const USAGE = { inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0 };8788export function textTurn(requestId: string, text: string): ScriptedTurn {89 return {90 events: [91 { type: "started", requestId },92 { type: "text-block-completed", blockIndex: 0, text },93 { type: "completed", stopReason: "end_turn", usage: { ...USAGE }, durationMs: 5 },94 ],95 };96}9798export function toolTurn(99 requestId: string,100 text: string,101 calls: { toolUseId: string; toolName: string; input: unknown }[],102): ScriptedTurn {103 const events: ModelEvent[] = [104 { type: "started", requestId },105 { type: "text-block-completed", blockIndex: 0, text },106 ];107 calls.forEach((call, i) => {108 events.push({109 type: "tool-call-completed",110 blockIndex: i + 1,111 toolUseId: call.toolUseId,112 toolName: call.toolName,113 input: call.input,114 });115 });116 events.push({ type: "completed", stopReason: "tool_use", usage: { ...USAGE }, durationMs: 5 });117 return { events };118}119120// ───────────────────────── harness ─────────────────────────121122export const ALLOW_ALL_RULES: PermissionRule[] = [123 { capability: "*", pattern: "*", action: "allow", source: "user" },124];125126export interface AgentHarness {127 dir: string;128 sessionsDir: string;129 workspace: LocalWorkspace;130 log: SessionLog;131 session: EventLogSession;132 client: ScriptedModelClient;133 kernel: AgentKernel;134 steering: SteeringQueue;135 interruption: InterruptionController;136 verifier: VerificationGate;137 events(): readonly DurableEvent[];138 user(text: string): void;139}140141export interface HarnessOptions {142 turns: ScriptedTurn[];143 /** User-layer permission rules. Defaults to allow-everything. Pass [] for shipped defaults only. */144 rules?: PermissionRule[];145 /** Reuse an existing workspace dir (resume tests). */146 dir?: string;147 /** Resume an existing session log instead of creating one. */148 resume?: { sessionsDir: string; sessionId: string };149 /** Kernel model-call budget override. */150 maxIterations?: number;151}152153/** Build a kernel wired to REAL services (workspace, tools, permissions, context) in a temp dir. */154export async function makeHarness(options: HarnessOptions): Promise<AgentHarness> {155 const dir = options.dir ?? mkdtempSync(path.join(tmpdir(), "khaelor-agent-"));156 const sessionsDir = options.resume?.sessionsDir ?? path.join(dir, ".khaelor-sessions");157158 const log =159 options.resume !== undefined160 ? await SessionLog.open({161 projectHash: "agenttest",162 sessionsDir,163 sessionId: options.resume.sessionId,164 })165 : await SessionLog.create({ projectHash: "agenttest", sessionsDir });166167 const bus = new SessionEventBus({ sessionId: log.sessionId, appender: log });168 const session = new EventLogSession({169 sessionId: log.sessionId,170 bus,171 replayed: log.replayedEvents,172 });173174 const workspace = new LocalWorkspace(dir);175 const fileTimes = new InMemoryFileTimeRegistry();176 const processes = new LocalProcessManager({177 logDir: path.join(dir, ".khaelor-process-logs"),178 shell: "/bin/sh",179 });180 const registry = createDefaultToolRegistry();181182 const permissions = new PermissionService({183 rules: { user: options.rules ?? ALLOW_ALL_RULES },184 publish: (event) => {185 session.publishDurable(event);186 },187 });188189 const executor = new ToolExecutor({190 session,191 registry,192 permissions,193 workspace,194 fileTimes,195 processes,196 spillDir: path.join(dir, ".khaelor-spill"),197 });198199 const client = new ScriptedModelClient(options.turns);200 const context = new KhaelorContextEngine({201 model: "test-model",202 auxModel: "test-aux",203 maxOutputTokens: 4096,204 systemTiers: [{ name: "identity", text: "You are KHAELOR, a terminal engineering agent." }],205 tools: registry.list().map((tool) => ({206 name: tool.name,207 description: tool.description,208 inputSchema: tool.inputSchema,209 })),210 modelClient: client,211 budget: new ContextBudget({ model: "test-model", reservedOutputTokens: 4096 }),212 });213214 const verifier = new VerificationGate({ workspace });215 const steering = new SteeringQueue(session);216 const interruption = new InterruptionController(session);217 const kernel = new AgentKernel({218 session,219 context,220 model: client,221 executor,222 verifier,223 steering,224 interruption,225 ...(options.maxIterations !== undefined ? { maxIterations: options.maxIterations } : {}),226 });227228 return {229 dir,230 sessionsDir,231 workspace,232 log,233 session,234 client,235 kernel,236 steering,237 interruption,238 verifier,239 events: () => session.events(),240 user: (text: string) => {241 session.publishDurable({ type: "user.message-created", payload: { text, mentions: [] } });242 },243 };244}245246// ───────────────────────── assertions and helpers ─────────────────────────247248/** Durable event type strings, in seq order. */249export function durableTypes(events: readonly DurableEvent[]): string[] {250 return events.map((event) => event.type);251}252253/**254 * EVENT_MODEL invariants over a recorded log:255 * - every tool_use has exactly one terminal tool_result (§6.5);256 * - per toolUseId, ToolApproved/Started precede the terminal event;257 * - the rebuilt conversation pairs every tool_use with a tool_result,258 * tool_use in assistant messages, tool_result in user messages.259 */260export function assertPairingSafe(events: readonly DurableEvent[]): void {261 expect(findDanglingToolUseIds(events)).toEqual([]);262263 const requestedAt = new Map<string, number>();264 const startedAt = new Map<string, number>();265 const terminalAt = new Map<string, number>();266 for (const event of events) {267 switch (event.type) {268 case "tool.requested":269 requestedAt.set(event.payload.toolUseId, event.seq);270 break;271 case "tool.started":272 startedAt.set(event.payload.toolUseId, event.seq);273 break;274 case "tool.completed":275 case "tool.failed":276 case "tool.cancelled":277 expect(terminalAt.has(event.payload.toolUseId)).toBe(false); // exactly one terminal278 terminalAt.set(event.payload.toolUseId, event.seq);279 break;280 default:281 break;282 }283 }284 for (const [toolUseId, seq] of terminalAt) {285 const requested = requestedAt.get(toolUseId);286 expect(requested).toBeDefined();287 expect(requested as number).toBeLessThan(seq);288 const started = startedAt.get(toolUseId);289 if (started !== undefined) expect(started).toBeLessThan(seq);290 }291292 const messages = buildConversation(events);293 if (messages.length > 0) expect(messages[0]?.role).toBe("user");294 const useIds = new Set<string>();295 const resultIds = new Set<string>();296 for (const message of messages) {297 for (const block of message.content) {298 if (block.type === "tool_use") {299 expect(message.role).toBe("assistant");300 useIds.add(block.id);301 } else if (block.type === "tool_result") {302 expect(message.role).toBe("user");303 expect(useIds.has(block.tool_use_id)).toBe(true); // result after its use304 resultIds.add(block.tool_use_id);305 }306 }307 }308 expect([...useIds].sort()).toEqual([...resultIds].sort());309}310311/** Poll until `predicate` holds (macro-task cadence) — for interrupt timing. */312export async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise<void> {313 const deadline = Date.now() + timeoutMs;314 while (!predicate()) {315 if (Date.now() > deadline) throw new Error("waitFor: condition not met in time");316 await new Promise((resolve) => setTimeout(resolve, 5));317 }318}319