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%
15.3 KB · 460 lines typescript
Raw Blame History
1/**2 * KHAELOR3 * File: tests/session/projections.test.ts4 * Description: Projection tests — conversation fold, compaction determinism, usage totals, file-change set, pairing.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910import { describe, expect, it } from "vitest";11import type { DurableEvent, DurableEventInput, ModelUsage } from "../../src/session/events.js";12import {13  COMPACTION_MESSAGE_PREFIX,14  buildConversation,15  buildFileChangeSet,16  buildUsageTotals,17  findDanglingToolUseIds,18  isPairingSafeCut,19} from "../../src/session/projections.js";20import { envelope } from "./fixtures.js";2122/** Build a seq-ordered durable stream from inputs. */23function stream(...inputs: DurableEventInput[]): DurableEvent[] {24  return inputs.map((input, i) => envelope(i + 1, input));25}2627const usage = (input: number, output: number): ModelUsage => ({28  inputTokens: input,29  outputTokens: output,30  cacheReadTokens: 0,31  cacheWriteTokens: 0,32});3334const user = (text: string): DurableEventInput => ({35  type: "user.message-created",36  payload: { text, mentions: [] },37});3839const requestStarted = (requestId: string, model = "claude-sonnet-4-5"): DurableEventInput => ({40  type: "model.request-started",41  payload: {42    requestId,43    model,44    purpose: "main",45    contextStats: { estimatedInputTokens: 0, sections: [] },46  },47});4849const textBlock = (requestId: string, blockIndex: number, text: string): DurableEventInput => ({50  type: "model.text-block-completed",51  payload: { requestId, blockIndex, text },52});5354const thinkingBlock = (requestId: string, blockIndex: number): DurableEventInput => ({55  type: "model.thinking-block-completed",56  payload: { requestId, blockIndex, thinking: "reasoning…", signature: "sig" },57});5859const toolUse = (requestId: string, blockIndex: number, toolUseId: string): DurableEventInput => ({60  type: "tool.requested",61  payload: { requestId, blockIndex, toolUseId, toolName: "read", input: { path: "a.ts" } },62});6364const responseCompleted = (65  requestId: string,66  stopReason: "end_turn" | "tool_use",67  u: ModelUsage = usage(10, 5),68): DurableEventInput => ({69  type: "model.response-completed",70  payload: { requestId, stopReason, usage: u, durationMs: 100 },71});7273const toolCompleted = (toolUseId: string, modelText: string): DurableEventInput => ({74  type: "tool.completed",75  payload: { toolUseId, modelText, durationMs: 5, ui: { kind: "read", summary: "Read a.ts" } },76});7778describe("buildConversation", () => {79  it("folds a full tool-loop turn into user/assistant/tool_result messages", () => {80    const events = stream(81      user("Fix the bug"),82      requestStarted("r1"),83      thinkingBlock("r1", 0),84      textBlock("r1", 1, "Let me look."),85      toolUse("r1", 2, "t1"),86      responseCompleted("r1", "tool_use"),87      toolCompleted("t1", "const x = 1;"),88      requestStarted("r2"),89      textBlock("r2", 0, "Fixed."),90      responseCompleted("r2", "end_turn"),91    );92    const messages = buildConversation(events);93    expect(messages).toEqual([94      { role: "user", content: [{ type: "text", text: "Fix the bug" }] },95      {96        role: "assistant",97        content: [98          { type: "thinking", thinking: "reasoning…", signature: "sig" },99          { type: "text", text: "Let me look." },100          { type: "tool_use", id: "t1", name: "read", input: { path: "a.ts" } },101        ],102      },103      {104        role: "user",105        content: [{ type: "tool_result", tool_use_id: "t1", content: "const x = 1;" }],106      },107      { role: "assistant", content: [{ type: "text", text: "Fixed." }] },108    ]);109  });110111  it("orders assistant blocks by blockIndex regardless of event order", () => {112    const events = stream(113      user("go"),114      textBlock("r1", 2, "second"),115      thinkingBlock("r1", 0),116      textBlock("r1", 1, "first"),117      responseCompleted("r1", "end_turn"),118    );119    const [, assistant] = buildConversation(events);120    expect(assistant!.content.map((b) => b.type)).toEqual(["thinking", "text", "text"]);121    expect(assistant!.content[1]).toEqual({ type: "text", text: "first" });122    expect(assistant!.content[2]).toEqual({ type: "text", text: "second" });123  });124125  it("groups multiple tool results into one user message and marks failures", () => {126    const events = stream(127      user("go"),128      toolUse("r1", 0, "t1"),129      toolUse("r1", 1, "t2"),130      toolUse("r1", 2, "t3"),131      responseCompleted("r1", "tool_use"),132      toolCompleted("t1", "ok"),133      {134        type: "tool.failed",135        payload: { toolUseId: "t2", modelText: "boom", errorKind: "exec-error", durationMs: 1 },136      },137      {138        type: "tool.cancelled",139        payload: { toolUseId: "t3", reason: "interrupted", modelText: "[cancelled]" },140      },141    );142    const messages = buildConversation(events);143    expect(messages).toHaveLength(3);144    expect(messages[2]!.content).toEqual([145      { type: "tool_result", tool_use_id: "t1", content: "ok" },146      { type: "tool_result", tool_use_id: "t2", content: "boom", is_error: true },147      { type: "tool_result", tool_use_id: "t3", content: "[cancelled]" },148    ]);149  });150151  it("applies ContextPruned with the recorded placeholder, deterministically", () => {152    const events = stream(153      user("go"),154      toolUse("r1", 0, "t1"),155      responseCompleted("r1", "tool_use"),156      toolCompleted("t1", "HUGE OUTPUT"),157      {158        type: "context.pruned",159        payload: { toolUseIds: ["t1"], placeholder: "[pruned]", tokensReclaimedEstimate: 100 },160      },161    );162    const messages = buildConversation(events);163    expect(messages[2]!.content[0]).toEqual({164      type: "tool_result",165      tool_use_id: "t1",166      content: "[pruned]",167    });168  });169170  it("appends injected steering into the tool-result user message at afterSeq", () => {171    const queued = envelope(7, { type: "user.steering-queued", payload: { text: "also add tests" } });172    const base = stream(173      user("go"), // 1174      toolUse("r1", 0, "t1"), // 2175      responseCompleted("r1", "tool_use"), // 3176      toolCompleted("t1", "ok"), // 4177      user("ignored placeholder"), // 5 — replaced below178      user("ignored placeholder"), // 6 — replaced below179    ).slice(0, 4);180    const events: DurableEvent[] = [181      ...base,182      queued, // 7 (id known)183      envelope(8, {184        type: "user.steering-injected",185        payload: { queuedEventId: queued.id, seam: "post-tool-batch", afterSeq: 4 },186      }),187    ];188    const messages = buildConversation(events);189    expect(messages[2]!.content).toEqual([190      { type: "tool_result", tool_use_id: "t1", content: "ok" },191      { type: "text", text: "also add tests" },192    ]);193  });194195  it("replaces the cut range with a single synthetic checkpoint message (ContextCompacted)", () => {196    const events = stream(197      user("Fix the bug"), // 1198      toolUse("r1", 0, "t1"), // 2199      responseCompleted("r1", "tool_use"), // 3200      toolCompleted("t1", "big output"), // 4201      textBlock("r2", 0, "Done with step 1."), // 5202      responseCompleted("r2", "end_turn"), // 6203      {204        type: "context.compacted",205        payload: {206          checkpointYaml: "objective: fix the bug\n",207          cut: { fromSeq: 1, toSeq: 6 },208          trigger: "proactive-token-budget",209          tokensBefore: 100000,210          summaryModel: "claude-haiku-4-5",211        },212      }, // 7213      user("continue"), // 8214    );215    const messages = buildConversation(events);216    expect(messages).toEqual([217      {218        role: "user",219        content: [{ type: "text", text: COMPACTION_MESSAGE_PREFIX + "objective: fix the bug\n" }],220      },221      { role: "user", content: [{ type: "text", text: "continue" }] },222    ]);223  });224225  it("a later compaction may consume an earlier checkpoint message", () => {226    const events = stream(227      user("one"), // 1228      textBlock("r1", 0, "a"), // 2229      responseCompleted("r1", "end_turn"), // 3230      {231        type: "context.compacted",232        payload: {233          checkpointYaml: "first: checkpoint\n",234          cut: { fromSeq: 1, toSeq: 3 },235          trigger: "user-command",236          tokensBefore: 10,237          summaryModel: "aux",238        },239      }, // 4240      user("two"), // 5241      textBlock("r2", 0, "b"), // 6242      responseCompleted("r2", "end_turn"), // 7243      {244        type: "context.compacted",245        payload: {246          checkpointYaml: "second: checkpoint\n",247          cut: { fromSeq: 1, toSeq: 7 },248          trigger: "user-command",249          tokensBefore: 10,250          summaryModel: "aux",251        },252      }, // 8253      user("three"), // 9254    );255    const messages = buildConversation(events);256    expect(messages).toEqual([257      { role: "user", content: [{ type: "text", text: COMPACTION_MESSAGE_PREFIX + "second: checkpoint\n" }] },258      { role: "user", content: [{ type: "text", text: "three" }] },259    ]);260  });261262  it("is replay-deterministic: rebuilding (incl. after JSONL round-trip) yields identical bytes", () => {263    const events = stream(264      user("Fix the bug"),265      toolUse("r1", 0, "t1"),266      responseCompleted("r1", "tool_use"),267      toolCompleted("t1", "big output"),268      {269        type: "context.pruned",270        payload: { toolUseIds: ["t1"], placeholder: "[pruned]", tokensReclaimedEstimate: 1 },271      },272      {273        type: "context.compacted",274        payload: {275          checkpointYaml: "objective: x\n",276          cut: { fromSeq: 1, toSeq: 5 },277          trigger: "reactive-overflow",278          tokensBefore: 10,279          summaryModel: "aux",280        },281      },282      user("continue"),283    );284    const first = JSON.stringify(buildConversation(events));285    const second = JSON.stringify(buildConversation(events));286    const replayed = events.map((e) => JSON.parse(JSON.stringify(e)) as DurableEvent);287    const third = JSON.stringify(buildConversation(replayed));288    expect(second).toBe(first);289    expect(third).toBe(first);290  });291292  it("keeps settled blocks of a failed request in history (pairing with cancelled tools)", () => {293    const events = stream(294      user("go"),295      toolUse("r1", 0, "t1"),296      {297        type: "model.request-failed",298        payload: { requestId: "r1", kind: "cancelled", message: "aborted", retriesExhausted: false },299      },300      {301        type: "tool.cancelled",302        payload: { toolUseId: "t1", reason: "interrupted", modelText: "[cancelled]" },303      },304    );305    const messages = buildConversation(events);306    expect(messages[1]!.content[0]!.type).toBe("tool_use");307    expect(messages[2]!.content[0]).toEqual({308      type: "tool_result",309      tool_use_id: "t1",310      content: "[cancelled]",311    });312  });313});314315describe("buildUsageTotals", () => {316  it("sums usage from ModelResponseCompleted only, keyed by the requesting model", () => {317    const events = stream(318      requestStarted("r1", "main-model"),319      responseCompleted("r1", "tool_use", {320        inputTokens: 100,321        outputTokens: 50,322        cacheReadTokens: 30,323        cacheWriteTokens: 10,324      }),325      requestStarted("r2", "main-model"),326      responseCompleted("r2", "end_turn", {327        inputTokens: 200,328        outputTokens: 25,329        cacheReadTokens: 0,330        cacheWriteTokens: 0,331      }),332      requestStarted("r3", "aux-model"),333      responseCompleted("r3", "end_turn", {334        inputTokens: 7,335        outputTokens: 3,336        cacheReadTokens: 0,337        cacheWriteTokens: 0,338      }),339    );340    const totals = buildUsageTotals(events);341    expect(totals.perModel["main-model"]).toEqual({342      inputTokens: 300,343      outputTokens: 75,344      cacheReadTokens: 30,345      cacheWriteTokens: 10,346      requests: 2,347    });348    expect(totals.perModel["aux-model"]!.requests).toBe(1);349    expect(totals.totals.inputTokens).toBe(307);350    expect(totals.totals.outputTokens).toBe(78);351    expect(totals.totals.requests).toBe(3);352  });353354  it("never invents usage: other events contribute nothing", () => {355    const events = stream(356      user("hello"),357      toolUse("r1", 0, "t1"),358      toolCompleted("t1", "x"),359      { type: "session.renamed", payload: { title: "t" } },360    );361    const totals = buildUsageTotals(events);362    expect(totals.totals).toEqual({363      inputTokens: 0,364      outputTokens: 0,365      cacheReadTokens: 0,366      cacheWriteTokens: 0,367      requests: 0,368    });369    expect(Object.keys(totals.perModel)).toHaveLength(0);370  });371});372373describe("buildFileChangeSet", () => {374  const modified = (375    path: string,376    added: number,377    removed: number,378    toolUseId: string,379    operation: "write" | "edit" = "edit",380  ): DurableEventInput => ({381    type: "file.modified",382    payload: { path, operation, diffStats: { added, removed }, toolUseId },383  });384385  it("accumulates cumulative diff stats and operations per path", () => {386    const events = stream(387      modified("src/a.ts", 10, 2, "t1", "write"),388      modified("src/a.ts", 3, 1, "t2"),389      modified("src/b.ts", 1, 0, "t3"),390    );391    const set = buildFileChangeSet(events);392    expect(set.changes.get("src/a.ts")).toEqual({393      path: "src/a.ts",394      operations: ["write", "edit"],395      diffStats: { added: 13, removed: 3 },396      toolUseIds: ["t1", "t2"],397    });398    expect(set.changes.get("src/b.ts")!.diffStats).toEqual({ added: 1, removed: 0 });399  });400401  it("pre-first-edit baseline refines session-start", () => {402    const baseline = (when: "session-start" | "pre-first-edit", diffHash: string): DurableEventInput => ({403      type: "git.baseline-recorded",404      payload: { when, baseline: { branch: "main", dirtyFiles: [], untrackedFiles: [], diffHash } },405    });406    const set = buildFileChangeSet(stream(baseline("session-start", "h1"), baseline("pre-first-edit", "h2")));407    expect(set.baselineWhen).toBe("pre-first-edit");408    expect(set.baseline!.diffHash).toBe("h2");409  });410411  it("records last read mtime per path and survives compaction events", () => {412    const events = stream(413      {414        type: "file.read",415        payload: { path: "src/a.ts", bytes: 100, mtimeMs: 111, toolUseId: "t1" },416      },417      modified("src/a.ts", 1, 0, "t2"),418      {419        type: "context.compacted",420        payload: {421          checkpointYaml: "x: y\n",422          cut: { fromSeq: 1, toSeq: 2 },423          trigger: "user-command",424          tokensBefore: 1,425          summaryModel: "aux",426        },427      },428    );429    const set = buildFileChangeSet(events);430    expect(set.reads.get("src/a.ts")!.mtimeMs).toBe(111);431    expect(set.changes.has("src/a.ts")).toBe(true); // compaction never erases file changes432  });433});434435describe("pairing safety helpers", () => {436  it("findDanglingToolUseIds reports requests without terminal events", () => {437    const events = stream(438      toolUse("r1", 0, "t1"),439      toolUse("r1", 1, "t2"),440      toolUse("r1", 2, "t3"),441      toolCompleted("t1", "ok"),442      { type: "tool.cancelled", payload: { toolUseId: "t3", reason: "shutdown", modelText: "[x]" } },443    );444    expect(findDanglingToolUseIds(events)).toEqual(["t2"]);445  });446447  it("isPairingSafeCut validates both cut boundaries", () => {448    const events = stream(449      user("go"), // 1450      toolUse("r1", 0, "t1"), // 2451      toolCompleted("t1", "ok"), // 3452      toolUse("r2", 0, "t2"), // 4 — open453    );454    expect(isPairingSafeCut(events, { fromSeq: 1, toSeq: 3 })).toBe(true);455    expect(isPairingSafeCut(events, { fromSeq: 1, toSeq: 4 })).toBe(false); // orphans t2456    expect(isPairingSafeCut(events, { fromSeq: 3, toSeq: 3 })).toBe(false); // consumes t1's result only457    expect(isPairingSafeCut(events, { fromSeq: 4, toSeq: 2 })).toBe(false); // inverted range458  });459});460