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.6 KB · 319 lines typescript
Raw Blame History
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