/** * KHAELOR * File: tests/session/projections.test.ts * Description: Projection tests — conversation fold, compaction determinism, usage totals, file-change set, pairing. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { describe, expect, it } from "vitest"; import type { DurableEvent, DurableEventInput, ModelUsage } from "../../src/session/events.js"; import { COMPACTION_MESSAGE_PREFIX, buildConversation, buildFileChangeSet, buildUsageTotals, findDanglingToolUseIds, isPairingSafeCut, } from "../../src/session/projections.js"; import { envelope } from "./fixtures.js"; /** Build a seq-ordered durable stream from inputs. */ function stream(...inputs: DurableEventInput[]): DurableEvent[] { return inputs.map((input, i) => envelope(i + 1, input)); } const usage = (input: number, output: number): ModelUsage => ({ inputTokens: input, outputTokens: output, cacheReadTokens: 0, cacheWriteTokens: 0, }); const user = (text: string): DurableEventInput => ({ type: "user.message-created", payload: { text, mentions: [] }, }); const requestStarted = (requestId: string, model = "claude-sonnet-4-5"): DurableEventInput => ({ type: "model.request-started", payload: { requestId, model, purpose: "main", contextStats: { estimatedInputTokens: 0, sections: [] }, }, }); const textBlock = (requestId: string, blockIndex: number, text: string): DurableEventInput => ({ type: "model.text-block-completed", payload: { requestId, blockIndex, text }, }); const thinkingBlock = (requestId: string, blockIndex: number): DurableEventInput => ({ type: "model.thinking-block-completed", payload: { requestId, blockIndex, thinking: "reasoning…", signature: "sig" }, }); const toolUse = (requestId: string, blockIndex: number, toolUseId: string): DurableEventInput => ({ type: "tool.requested", payload: { requestId, blockIndex, toolUseId, toolName: "read", input: { path: "a.ts" } }, }); const responseCompleted = ( requestId: string, stopReason: "end_turn" | "tool_use", u: ModelUsage = usage(10, 5), ): DurableEventInput => ({ type: "model.response-completed", payload: { requestId, stopReason, usage: u, durationMs: 100 }, }); const toolCompleted = (toolUseId: string, modelText: string): DurableEventInput => ({ type: "tool.completed", payload: { toolUseId, modelText, durationMs: 5, ui: { kind: "read", summary: "Read a.ts" } }, }); describe("buildConversation", () => { it("folds a full tool-loop turn into user/assistant/tool_result messages", () => { const events = stream( user("Fix the bug"), requestStarted("r1"), thinkingBlock("r1", 0), textBlock("r1", 1, "Let me look."), toolUse("r1", 2, "t1"), responseCompleted("r1", "tool_use"), toolCompleted("t1", "const x = 1;"), requestStarted("r2"), textBlock("r2", 0, "Fixed."), responseCompleted("r2", "end_turn"), ); const messages = buildConversation(events); expect(messages).toEqual([ { role: "user", content: [{ type: "text", text: "Fix the bug" }] }, { role: "assistant", content: [ { type: "thinking", thinking: "reasoning…", signature: "sig" }, { type: "text", text: "Let me look." }, { type: "tool_use", id: "t1", name: "read", input: { path: "a.ts" } }, ], }, { role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "const x = 1;" }], }, { role: "assistant", content: [{ type: "text", text: "Fixed." }] }, ]); }); it("orders assistant blocks by blockIndex regardless of event order", () => { const events = stream( user("go"), textBlock("r1", 2, "second"), thinkingBlock("r1", 0), textBlock("r1", 1, "first"), responseCompleted("r1", "end_turn"), ); const [, assistant] = buildConversation(events); expect(assistant!.content.map((b) => b.type)).toEqual(["thinking", "text", "text"]); expect(assistant!.content[1]).toEqual({ type: "text", text: "first" }); expect(assistant!.content[2]).toEqual({ type: "text", text: "second" }); }); it("groups multiple tool results into one user message and marks failures", () => { const events = stream( user("go"), toolUse("r1", 0, "t1"), toolUse("r1", 1, "t2"), toolUse("r1", 2, "t3"), responseCompleted("r1", "tool_use"), toolCompleted("t1", "ok"), { type: "tool.failed", payload: { toolUseId: "t2", modelText: "boom", errorKind: "exec-error", durationMs: 1 }, }, { type: "tool.cancelled", payload: { toolUseId: "t3", reason: "interrupted", modelText: "[cancelled]" }, }, ); const messages = buildConversation(events); expect(messages).toHaveLength(3); expect(messages[2]!.content).toEqual([ { type: "tool_result", tool_use_id: "t1", content: "ok" }, { type: "tool_result", tool_use_id: "t2", content: "boom", is_error: true }, { type: "tool_result", tool_use_id: "t3", content: "[cancelled]" }, ]); }); it("applies ContextPruned with the recorded placeholder, deterministically", () => { const events = stream( user("go"), toolUse("r1", 0, "t1"), responseCompleted("r1", "tool_use"), toolCompleted("t1", "HUGE OUTPUT"), { type: "context.pruned", payload: { toolUseIds: ["t1"], placeholder: "[pruned]", tokensReclaimedEstimate: 100 }, }, ); const messages = buildConversation(events); expect(messages[2]!.content[0]).toEqual({ type: "tool_result", tool_use_id: "t1", content: "[pruned]", }); }); it("appends injected steering into the tool-result user message at afterSeq", () => { const queued = envelope(7, { type: "user.steering-queued", payload: { text: "also add tests" } }); const base = stream( user("go"), // 1 toolUse("r1", 0, "t1"), // 2 responseCompleted("r1", "tool_use"), // 3 toolCompleted("t1", "ok"), // 4 user("ignored placeholder"), // 5 — replaced below user("ignored placeholder"), // 6 — replaced below ).slice(0, 4); const events: DurableEvent[] = [ ...base, queued, // 7 (id known) envelope(8, { type: "user.steering-injected", payload: { queuedEventId: queued.id, seam: "post-tool-batch", afterSeq: 4 }, }), ]; const messages = buildConversation(events); expect(messages[2]!.content).toEqual([ { type: "tool_result", tool_use_id: "t1", content: "ok" }, { type: "text", text: "also add tests" }, ]); }); it("replaces the cut range with a single synthetic checkpoint message (ContextCompacted)", () => { const events = stream( user("Fix the bug"), // 1 toolUse("r1", 0, "t1"), // 2 responseCompleted("r1", "tool_use"), // 3 toolCompleted("t1", "big output"), // 4 textBlock("r2", 0, "Done with step 1."), // 5 responseCompleted("r2", "end_turn"), // 6 { type: "context.compacted", payload: { checkpointYaml: "objective: fix the bug\n", cut: { fromSeq: 1, toSeq: 6 }, trigger: "proactive-token-budget", tokensBefore: 100000, summaryModel: "claude-haiku-4-5", }, }, // 7 user("continue"), // 8 ); const messages = buildConversation(events); expect(messages).toEqual([ { role: "user", content: [{ type: "text", text: COMPACTION_MESSAGE_PREFIX + "objective: fix the bug\n" }], }, { role: "user", content: [{ type: "text", text: "continue" }] }, ]); }); it("a later compaction may consume an earlier checkpoint message", () => { const events = stream( user("one"), // 1 textBlock("r1", 0, "a"), // 2 responseCompleted("r1", "end_turn"), // 3 { type: "context.compacted", payload: { checkpointYaml: "first: checkpoint\n", cut: { fromSeq: 1, toSeq: 3 }, trigger: "user-command", tokensBefore: 10, summaryModel: "aux", }, }, // 4 user("two"), // 5 textBlock("r2", 0, "b"), // 6 responseCompleted("r2", "end_turn"), // 7 { type: "context.compacted", payload: { checkpointYaml: "second: checkpoint\n", cut: { fromSeq: 1, toSeq: 7 }, trigger: "user-command", tokensBefore: 10, summaryModel: "aux", }, }, // 8 user("three"), // 9 ); const messages = buildConversation(events); expect(messages).toEqual([ { role: "user", content: [{ type: "text", text: COMPACTION_MESSAGE_PREFIX + "second: checkpoint\n" }] }, { role: "user", content: [{ type: "text", text: "three" }] }, ]); }); it("is replay-deterministic: rebuilding (incl. after JSONL round-trip) yields identical bytes", () => { const events = stream( user("Fix the bug"), toolUse("r1", 0, "t1"), responseCompleted("r1", "tool_use"), toolCompleted("t1", "big output"), { type: "context.pruned", payload: { toolUseIds: ["t1"], placeholder: "[pruned]", tokensReclaimedEstimate: 1 }, }, { type: "context.compacted", payload: { checkpointYaml: "objective: x\n", cut: { fromSeq: 1, toSeq: 5 }, trigger: "reactive-overflow", tokensBefore: 10, summaryModel: "aux", }, }, user("continue"), ); const first = JSON.stringify(buildConversation(events)); const second = JSON.stringify(buildConversation(events)); const replayed = events.map((e) => JSON.parse(JSON.stringify(e)) as DurableEvent); const third = JSON.stringify(buildConversation(replayed)); expect(second).toBe(first); expect(third).toBe(first); }); it("keeps settled blocks of a failed request in history (pairing with cancelled tools)", () => { const events = stream( user("go"), toolUse("r1", 0, "t1"), { type: "model.request-failed", payload: { requestId: "r1", kind: "cancelled", message: "aborted", retriesExhausted: false }, }, { type: "tool.cancelled", payload: { toolUseId: "t1", reason: "interrupted", modelText: "[cancelled]" }, }, ); const messages = buildConversation(events); expect(messages[1]!.content[0]!.type).toBe("tool_use"); expect(messages[2]!.content[0]).toEqual({ type: "tool_result", tool_use_id: "t1", content: "[cancelled]", }); }); }); describe("buildUsageTotals", () => { it("sums usage from ModelResponseCompleted only, keyed by the requesting model", () => { const events = stream( requestStarted("r1", "main-model"), responseCompleted("r1", "tool_use", { inputTokens: 100, outputTokens: 50, cacheReadTokens: 30, cacheWriteTokens: 10, }), requestStarted("r2", "main-model"), responseCompleted("r2", "end_turn", { inputTokens: 200, outputTokens: 25, cacheReadTokens: 0, cacheWriteTokens: 0, }), requestStarted("r3", "aux-model"), responseCompleted("r3", "end_turn", { inputTokens: 7, outputTokens: 3, cacheReadTokens: 0, cacheWriteTokens: 0, }), ); const totals = buildUsageTotals(events); expect(totals.perModel["main-model"]).toEqual({ inputTokens: 300, outputTokens: 75, cacheReadTokens: 30, cacheWriteTokens: 10, requests: 2, }); expect(totals.perModel["aux-model"]!.requests).toBe(1); expect(totals.totals.inputTokens).toBe(307); expect(totals.totals.outputTokens).toBe(78); expect(totals.totals.requests).toBe(3); }); it("never invents usage: other events contribute nothing", () => { const events = stream( user("hello"), toolUse("r1", 0, "t1"), toolCompleted("t1", "x"), { type: "session.renamed", payload: { title: "t" } }, ); const totals = buildUsageTotals(events); expect(totals.totals).toEqual({ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, requests: 0, }); expect(Object.keys(totals.perModel)).toHaveLength(0); }); }); describe("buildFileChangeSet", () => { const modified = ( path: string, added: number, removed: number, toolUseId: string, operation: "write" | "edit" = "edit", ): DurableEventInput => ({ type: "file.modified", payload: { path, operation, diffStats: { added, removed }, toolUseId }, }); it("accumulates cumulative diff stats and operations per path", () => { const events = stream( modified("src/a.ts", 10, 2, "t1", "write"), modified("src/a.ts", 3, 1, "t2"), modified("src/b.ts", 1, 0, "t3"), ); const set = buildFileChangeSet(events); expect(set.changes.get("src/a.ts")).toEqual({ path: "src/a.ts", operations: ["write", "edit"], diffStats: { added: 13, removed: 3 }, toolUseIds: ["t1", "t2"], }); expect(set.changes.get("src/b.ts")!.diffStats).toEqual({ added: 1, removed: 0 }); }); it("pre-first-edit baseline refines session-start", () => { const baseline = (when: "session-start" | "pre-first-edit", diffHash: string): DurableEventInput => ({ type: "git.baseline-recorded", payload: { when, baseline: { branch: "main", dirtyFiles: [], untrackedFiles: [], diffHash } }, }); const set = buildFileChangeSet(stream(baseline("session-start", "h1"), baseline("pre-first-edit", "h2"))); expect(set.baselineWhen).toBe("pre-first-edit"); expect(set.baseline!.diffHash).toBe("h2"); }); it("records last read mtime per path and survives compaction events", () => { const events = stream( { type: "file.read", payload: { path: "src/a.ts", bytes: 100, mtimeMs: 111, toolUseId: "t1" }, }, modified("src/a.ts", 1, 0, "t2"), { type: "context.compacted", payload: { checkpointYaml: "x: y\n", cut: { fromSeq: 1, toSeq: 2 }, trigger: "user-command", tokensBefore: 1, summaryModel: "aux", }, }, ); const set = buildFileChangeSet(events); expect(set.reads.get("src/a.ts")!.mtimeMs).toBe(111); expect(set.changes.has("src/a.ts")).toBe(true); // compaction never erases file changes }); }); describe("pairing safety helpers", () => { it("findDanglingToolUseIds reports requests without terminal events", () => { const events = stream( toolUse("r1", 0, "t1"), toolUse("r1", 1, "t2"), toolUse("r1", 2, "t3"), toolCompleted("t1", "ok"), { type: "tool.cancelled", payload: { toolUseId: "t3", reason: "shutdown", modelText: "[x]" } }, ); expect(findDanglingToolUseIds(events)).toEqual(["t2"]); }); it("isPairingSafeCut validates both cut boundaries", () => { const events = stream( user("go"), // 1 toolUse("r1", 0, "t1"), // 2 toolCompleted("t1", "ok"), // 3 toolUse("r2", 0, "t2"), // 4 — open ); expect(isPairingSafeCut(events, { fromSeq: 1, toSeq: 3 })).toBe(true); expect(isPairingSafeCut(events, { fromSeq: 1, toSeq: 4 })).toBe(false); // orphans t2 expect(isPairingSafeCut(events, { fromSeq: 3, toSeq: 3 })).toBe(false); // consumes t1's result only expect(isPairingSafeCut(events, { fromSeq: 4, toSeq: 2 })).toBe(false); // inverted range }); });