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