/** * Tests for the v1 context pipeline Compact stage and Templates module. */ import { describe, expect, it } from "bun:test"; import type { ContentBlock, Message } from "../../types.ts"; import { compact, compactOperation, truncateOperationOutputs, truncateToolOutput, } from "./compact.ts"; import { buildActionSummary, describeOutcome, extractPurpose, renderArchiveEntry, renderCompactSummary, } from "./templates.ts"; import type { Operation, Turn } from "./types.ts"; import { COMPACTION_SCORE_THRESHOLD, TOOL_OUTPUT_TRUNCATION } from "./types.ts"; // --------------------------------------------------------------------------- // Test helpers // --------------------------------------------------------------------------- function makeAssistantMsg( tools: Array<{ name: string; id?: string; path?: string }>, text = "", ): Message & { role: "assistant" } { return { role: "assistant", content: [ ...(text ? [{ type: "text" as const, text }] : []), ...tools.map((t) => ({ type: "tool_use" as const, id: t.id ?? `tu_${t.name}`, name: t.name, input: t.path ? { path: t.path } : {}, })), ], }; } function makeUserMsg( toolIds: Array<{ id: string; content?: string; isError?: boolean }>, ): Message & { role: "user" } { const blocks = toolIds.map((t) => ({ type: "tool_result" as const, tool_use_id: t.id, content: t.content ?? "ok", ...(t.isError ? { is_error: true } : {}), })) as unknown as ContentBlock[]; return { role: "user", content: blocks }; } function makeTurn( index: number, tools: Array<{ name: string; id?: string; path?: string; resultContent?: string }>, opts: { hasError?: boolean; hasDecision?: boolean; text?: string } = {}, ): Turn { const assistant = makeAssistantMsg(tools, opts.text ?? ""); const toolResults = makeUserMsg( tools.map((t) => ({ id: t.id ?? `tu_${t.name}`, content: t.resultContent ?? "ok", isError: opts.hasError, })), ); return { index, assistant, toolResults, meta: { tools: tools.map((t) => t.name), files: tools.flatMap((t) => (t.path ? [t.path] : [])), hasError: opts.hasError ?? false, hasDecision: opts.hasDecision ?? false, tokens: 100, timestamp: Date.now(), }, }; } function makeOp(overrides: Partial = {}): Operation { return { id: 1, status: "completed", type: "explore", turns: [], files: new Set(), tools: new Set(), outcome: "success", artifacts: [], dependsOn: [], score: 0.5, summary: null, startTurn: 0, endTurn: 0, ...overrides, }; } // --------------------------------------------------------------------------- // extractPurpose // --------------------------------------------------------------------------- describe("extractPurpose", () => { it("extracts purpose from 'I'll' pattern in first turn", () => { const op = makeOp({ turns: [ makeTurn(0, [{ name: "read", path: "src/foo.ts" }], { text: "I'll implement the authentication flow.", }), ], }); const purpose = extractPurpose(op); expect(purpose).toContain("implement the authentication flow"); }); it("extracts purpose from 'Let me' pattern", () => { const op = makeOp({ turns: [ makeTurn(0, [{ name: "bash" }], { text: "Let me run the test suite to check for failures.", }), ], }); const purpose = extractPurpose(op); expect(purpose).toContain("run the test suite"); }); it("falls back to type + files when no regex matches", () => { const op = makeOp({ type: "explore", files: new Set(["src/foo.ts", "src/bar.ts"]), turns: [makeTurn(0, [{ name: "read" }], { text: "Checking." })], }); const purpose = extractPurpose(op); expect(purpose).toMatch(/explore operation on/); expect(purpose).toContain("src/foo.ts"); }); it("falls back to type only when no files", () => { const op = makeOp({ type: "verify", files: new Set(), turns: [], }); const purpose = extractPurpose(op); expect(purpose).toBe("verify operation"); }); it("caps extracted purpose at 100 chars", () => { const longText = `I'll ${"x".repeat(200)}.`; const op = makeOp({ turns: [makeTurn(0, [{ name: "bash" }], { text: longText })], }); const purpose = extractPurpose(op); expect(purpose.length).toBeLessThanOrEqual(100); }); }); // --------------------------------------------------------------------------- // buildActionSummary // --------------------------------------------------------------------------- describe("buildActionSummary", () => { it("builds deduplicated tool(file) list", () => { const op = makeOp({ turns: [ makeTurn(0, [ { name: "read", path: "src/foo.ts" }, { name: "edit", path: "src/foo.ts" }, ]), ], }); const summary = buildActionSummary(op); expect(summary).toContain("read(src/foo.ts)"); expect(summary).toContain("edit(src/foo.ts)"); }); it("deduplicates identical tool(file) pairs across turns", () => { const op = makeOp({ turns: [ makeTurn(0, [{ name: "read", path: "src/foo.ts" }]), makeTurn(1, [{ name: "read", path: "src/foo.ts" }]), ], }); const summary = buildActionSummary(op); const count = (summary.match(/read\(src\/foo\.ts\)/g) ?? []).length; expect(count).toBe(1); }); it("uses bare tool name when no files", () => { const op = makeOp({ turns: [makeTurn(0, [{ name: "bash" }])], }); const summary = buildActionSummary(op); expect(summary).toContain("bash"); expect(summary).not.toContain("bash("); }); it("returns empty string for op with no turns", () => { const op = makeOp({ turns: [] }); expect(buildActionSummary(op)).toBe(""); }); }); // --------------------------------------------------------------------------- // describeOutcome // --------------------------------------------------------------------------- describe("describeOutcome", () => { it("describes success with artifacts", () => { const op = makeOp({ outcome: "success", artifacts: ["src/foo.ts"] }); expect(describeOutcome(op)).toContain("Artifacts: src/foo.ts"); }); it("describes success without artifacts", () => { const op = makeOp({ outcome: "success", artifacts: [] }); expect(describeOutcome(op)).toBe("Completed successfully"); }); it("describes failure", () => { const op = makeOp({ outcome: "failure" }); expect(describeOutcome(op)).toContain("Failed"); }); it("describes partial completion", () => { const op = makeOp({ outcome: "partial" }); expect(describeOutcome(op)).toContain("Partial"); }); it("describes in_progress", () => { const op = makeOp({ outcome: "in_progress" }); expect(describeOutcome(op)).toContain("In progress"); }); }); // --------------------------------------------------------------------------- // renderCompactSummary // --------------------------------------------------------------------------- describe("renderCompactSummary", () => { it("includes op id, purpose, actions, and outcome", () => { const op = makeOp({ id: 3, outcome: "success", artifacts: ["src/out.ts"], turns: [ makeTurn(0, [{ name: "write", path: "src/out.ts" }], { text: "I'll write the output module.", }), ], }); const summary = renderCompactSummary(op); expect(summary).toContain("[Op 3]"); expect(summary).toContain("write the output module"); expect(summary).toContain("Actions:"); expect(summary).toContain("Outcome:"); }); it("omits Actions line when there are no turns", () => { const op = makeOp({ id: 1, turns: [] }); const summary = renderCompactSummary(op); expect(summary).not.toContain("Actions:"); }); }); // --------------------------------------------------------------------------- // renderArchiveEntry // --------------------------------------------------------------------------- describe("renderArchiveEntry", () => { it("produces a one-liner with op id, purpose, and outcome", () => { const op = makeOp({ id: 5, outcome: "success", turns: [ makeTurn(0, [{ name: "bash" }], { text: "I'll run the linter to check for issues." }), ], }); const entry = renderArchiveEntry(op); expect(entry).toMatch(/^Op5:/); expect(entry).toContain("[success]"); expect(entry).not.toContain("\n"); }); }); // --------------------------------------------------------------------------- // truncateToolOutput // --------------------------------------------------------------------------- describe("truncateToolOutput", () => { it("returns content unchanged when under bash budget", () => { const content = "short output"; expect(truncateToolOutput("bash", content)).toBe(content); }); it("truncates bash output over budget using head+tail strategy", () => { const bashBudget = TOOL_OUTPUT_TRUNCATION.bashMaxTokens * 4; const manyLines = Array.from({ length: 200 }, (_, i) => `line ${i}`).join("\n"); // Force over budget by using content > bashBudget chars const longContent = `${manyLines}\n${"x".repeat(bashBudget)}`; const result = truncateToolOutput("bash", longContent); expect(result).toContain("lines omitted"); expect(result.length).toBeLessThan(longContent.length); }); it("truncates grep output with simple char truncation", () => { const grepBudget = TOOL_OUTPUT_TRUNCATION.grepMaxTokens * 4; const content = "x".repeat(grepBudget + 100); const result = truncateToolOutput("grep", content); expect(result).toContain("truncated"); expect(result.length).toBeLessThan(content.length); }); it("truncates read output using head+tail strategy", () => { const readBudget = TOOL_OUTPUT_TRUNCATION.readMaxTokens * 4; const manyLines = Array.from({ length: 300 }, (_, i) => `line ${i}`).join("\n"); const longContent = `${manyLines}\n${"x".repeat(readBudget)}`; const result = truncateToolOutput("read", longContent); expect(result).toContain("lines omitted"); }); it("truncates glob output to max results", () => { const max = TOOL_OUTPUT_TRUNCATION.globMaxResults; const lines = Array.from({ length: max + 10 }, (_, i) => `file${i}.ts`).join("\n"); const result = truncateToolOutput("glob", lines); expect(result).toContain("more results"); const keptLines = result.split("\n").filter((l) => !l.startsWith("[") && l.trim().length > 0); expect(keptLines.length).toBe(max); }); it("returns unchanged content for unknown tools", () => { const content = "some output"; expect(truncateToolOutput("write", content)).toBe(content); expect(truncateToolOutput("edit", content)).toBe(content); }); }); // --------------------------------------------------------------------------- // compactOperation // --------------------------------------------------------------------------- describe("compactOperation", () => { it("sets status to compacted and generates a summary", () => { const op = makeOp({ id: 2, status: "completed", score: 0.1, turns: [makeTurn(0, [{ name: "read", path: "src/foo.ts" }])], }); compactOperation(op); expect(op.status).toBe("compacted"); expect(op.summary).not.toBeNull(); expect(typeof op.summary).toBe("string"); expect(op.summary).toContain("[Op 2]"); }); }); // --------------------------------------------------------------------------- // truncateOperationOutputs // --------------------------------------------------------------------------- describe("truncateOperationOutputs", () => { it("truncates bash tool_result content when over budget", () => { const bashBudget = TOOL_OUTPUT_TRUNCATION.bashMaxTokens * 4; const largeOutput = Array.from({ length: 200 }, (_, i) => `line ${i}`).join("\n"); const longContent = `${largeOutput}\n${"x".repeat(bashBudget)}`; const turn = makeTurn(0, [{ name: "bash", id: "tu_bash", resultContent: longContent }]); const op = makeOp({ turns: [turn], score: 0.5 }); truncateOperationOutputs(op); const resultBlocks = turn.toolResults?.content as unknown as Array<{ type: string; content: string; }>; const bashResult = resultBlocks.find((b) => b.type === "tool_result"); expect(bashResult?.content).toContain("lines omitted"); expect(bashResult?.content.length).toBeLessThan(longContent.length); }); it("does not truncate short content", () => { const turn = makeTurn(0, [{ name: "bash", id: "tu_bash", resultContent: "short" }]); const op = makeOp({ turns: [turn] }); truncateOperationOutputs(op); const resultBlocks = turn.toolResults?.content as unknown as Array<{ type: string; content: string; }>; const bashResult = resultBlocks.find((b) => b.type === "tool_result"); expect(bashResult?.content).toBe("short"); }); it("handles turns with null toolResults", () => { const turn = makeTurn(0, [{ name: "bash" }]); const turnWithNoResults = { ...turn, toolResults: null }; const op = makeOp({ turns: [turnWithNoResults] }); // Should not throw expect(() => truncateOperationOutputs(op)).not.toThrow(); }); }); // --------------------------------------------------------------------------- // compact (stage entry point) // --------------------------------------------------------------------------- describe("compact", () => { it("compacts operations with score below threshold", () => { const op = makeOp({ id: 1, status: "completed", score: COMPACTION_SCORE_THRESHOLD - 0.01, turns: [makeTurn(0, [{ name: "read", path: "src/foo.ts" }])], }); compact([op], null); expect(op.status).toBe("compacted"); expect(op.summary).not.toBeNull(); }); it("keeps operations with score at or above threshold", () => { const op = makeOp({ id: 1, status: "completed", score: COMPACTION_SCORE_THRESHOLD, turns: [makeTurn(0, [{ name: "read", path: "src/foo.ts" }])], }); compact([op], null); expect(op.status).toBe("completed"); expect(op.summary).toBeNull(); }); it("never compacts the active operation", () => { const op = makeOp({ id: 1, status: "active", score: 0.0, // below threshold, but active }); compact([op], 1); expect(op.status).toBe("active"); expect(op.summary).toBeNull(); }); it("skips already-compacted operations", () => { const op = makeOp({ id: 1, status: "compacted", score: 0.0, summary: "existing summary", }); compact([op], null); expect(op.summary).toBe("existing summary"); }); it("skips archived operations", () => { const op = makeOp({ id: 1, status: "archived", score: 0.0, }); compact([op], null); expect(op.status).toBe("archived"); }); it("processes multiple operations correctly", () => { const lowOp = makeOp({ id: 1, status: "completed", score: 0.1 }); const highOp = makeOp({ id: 2, status: "completed", score: 0.8 }); const activeOp = makeOp({ id: 3, status: "active", score: 0.0 }); compact([lowOp, highOp, activeOp], 3); expect(lowOp.status).toBe("compacted"); expect(highOp.status).toBe("completed"); // kept, possibly truncated expect(activeOp.status).toBe("active"); // never touched }); it("threshold boundary: score exactly at threshold is kept", () => { const op = makeOp({ id: 1, status: "completed", score: COMPACTION_SCORE_THRESHOLD }); compact([op], null); expect(op.status).toBe("completed"); }); it("threshold boundary: score just below threshold is compacted", () => { const op = makeOp({ id: 1, status: "completed", score: COMPACTION_SCORE_THRESHOLD - Number.EPSILON, }); compact([op], null); expect(op.status).toBe("compacted"); }); it("truncates active operation tool outputs without compacting", () => { const bashBudget = TOOL_OUTPUT_TRUNCATION.bashMaxTokens * 4; const largeOutput = Array.from({ length: 200 }, (_, i) => `line ${i}`).join("\n"); const longContent = `${largeOutput}\n${"x".repeat(bashBudget)}`; const turn = makeTurn(0, [{ name: "bash", id: "tu_bash", resultContent: longContent }]); const op = makeOp({ id: 5, status: "active", score: 1.0, turns: [turn] }); compact([op], 5); // Status must remain active (not compacted) expect(op.status).toBe("active"); expect(op.summary).toBeNull(); // But tool outputs should have been truncated const resultBlocks = turn.toolResults?.content as unknown as Array<{ type: string; content: string; }>; const bashResult = resultBlocks.find((b) => b.type === "tool_result"); expect(bashResult?.content.length).toBeLessThan(longContent.length); }); it("uses failure-specific bash limit for failure-outcome operations", () => { const failureLimit = TOOL_OUTPUT_TRUNCATION.failureBashMaxTokens * 4; // Content exceeds the failure limit but stays within the normal bash limit const contentBetweenLimits = "x".repeat(failureLimit + 100); const failureTurn = makeTurn(0, [ { name: "bash", id: "tu_bash_f", resultContent: contentBetweenLimits }, ]); const failureOp = makeOp({ id: 1, status: "completed", outcome: "failure", score: 0.9, turns: [failureTurn], }); const successTurn = makeTurn(0, [ { name: "bash", id: "tu_bash_s", resultContent: contentBetweenLimits }, ]); const successOp = makeOp({ id: 2, status: "completed", outcome: "success", score: 0.9, turns: [successTurn], }); compact([failureOp, successOp], null); const getContent = (turn: Turn) => { const blocks = turn.toolResults?.content as unknown as Array<{ type: string; content: string; }>; return blocks.find((b) => b.type === "tool_result")?.content ?? ""; }; const failureContent = getContent(failureTurn); const successContent = getContent(successTurn); // Failure op: content exceeds failureBashMaxTokens → truncated expect(failureContent.length).toBeLessThan(contentBetweenLimits.length); // Success op: content under normalLimit → not truncated expect(successContent.length).toBe(contentBetweenLimits.length); }); }); // --------------------------------------------------------------------------- // truncateOperationOutputs — token count update // --------------------------------------------------------------------------- describe("truncateOperationOutputs — token count update", () => { it("re-estimates turn.meta.tokens to reflect truncated content size", () => { const bashBudget = TOOL_OUTPUT_TRUNCATION.bashMaxTokens * 4; const largeOutput = Array.from({ length: 200 }, (_, i) => `line ${i}`).join("\n"); const longContent = `${largeOutput}\n${"x".repeat(bashBudget)}`; // Estimate tokens for the un-truncated content (4 chars/token heuristic) const untruncatedEstimate = Math.ceil(longContent.length / 4); const turn = makeTurn(0, [{ name: "bash", id: "tu_bash", resultContent: longContent }]); const op = makeOp({ turns: [turn] }); truncateOperationOutputs(op); // After truncation, re-estimated tokens should be less than the un-truncated estimate expect(turn.meta.tokens).toBeLessThan(untruncatedEstimate); // And should reflect approximately the bash max tokens limit (some overhead for assistant // content and the truncation marker lines) expect(turn.meta.tokens).toBeLessThan(TOOL_OUTPUT_TRUNCATION.bashMaxTokens + 500); }); it("re-estimates turn.meta.tokens even when no truncation was applied", () => { const shortContent = "short output"; const turn = makeTurn(0, [{ name: "bash", id: "tu_bash", resultContent: shortContent }]); const op = makeOp({ turns: [turn] }); truncateOperationOutputs(op); // Token count should be re-estimated from actual content (small for short output) expect(typeof turn.meta.tokens).toBe("number"); expect(turn.meta.tokens).toBeGreaterThan(0); // For "short output" (12 chars = 3 tokens) + assistant overhead, should be much less than 100 expect(turn.meta.tokens).toBeLessThan(50); }); }); // --------------------------------------------------------------------------- // compact — event emission (PipelineEventSink) // --------------------------------------------------------------------------- describe("compact — event emission", () => { function makeSink(): { events: Array>; emit: (e: Record) => void; } { const events: Array> = []; return { events, emit(e) { events.push(e); }, }; } it("emits a `compact` event when an op is compacted by score", () => { const sink = makeSink(); const op = makeOp({ id: 7, status: "completed", score: COMPACTION_SCORE_THRESHOLD - 0.05, turns: [makeTurn(0, [{ name: "read", path: "src/foo.ts" }])], }); compact([op], null, undefined, sink, 4); expect(op.status).toBe("compacted"); expect(sink.events).toHaveLength(1); expect(sink.events[0]).toMatchObject({ type: "compact", turn: 4, operationId: 7, reason: "score_below_threshold", archivedAs: "compacted", }); expect(typeof sink.events[0]?.score).toBe("number"); expect(sink.events[0]?.score).toBeLessThan(COMPACTION_SCORE_THRESHOLD); }); it("does not emit when an op is kept (score above threshold)", () => { const sink = makeSink(); const op = makeOp({ id: 1, status: "completed", score: COMPACTION_SCORE_THRESHOLD + 0.1, turns: [makeTurn(0, [{ name: "read" }])], }); compact([op], null, undefined, sink, 1); expect(op.status).toBe("completed"); expect(sink.events).toHaveLength(0); }); it("does not emit for the active operation (never compacted)", () => { const sink = makeSink(); const active = makeOp({ id: 1, status: "active", score: 0 }); compact([active], 1, undefined, sink, 2); expect(sink.events).toHaveLength(0); }); it("does not emit for already-compacted or archived ops", () => { const sink = makeSink(); const compacted = makeOp({ id: 1, status: "compacted", score: 0, summary: "old" }); const archived = makeOp({ id: 2, status: "archived", score: 0 }); compact([compacted, archived], null, undefined, sink, 3); expect(sink.events).toHaveLength(0); }); it("emits one event per compacted op when multiple drop below threshold", () => { const sink = makeSink(); const lowA = makeOp({ id: 10, status: "completed", score: 0.05 }); const lowB = makeOp({ id: 11, status: "completed", score: 0.1 }); const high = makeOp({ id: 12, status: "completed", score: 0.9 }); compact([lowA, lowB, high], null, undefined, sink, 5); expect(sink.events).toHaveLength(2); const ids = sink.events.map((e) => e.operationId).sort(); expect(ids).toEqual([10, 11]); for (const event of sink.events) { expect(event.reason).toBe("score_below_threshold"); expect(event.archivedAs).toBe("compacted"); expect(event.turn).toBe(5); } }); it("is a no-op when no eventSink is provided", () => { const op = makeOp({ id: 1, status: "completed", score: 0.05, turns: [makeTurn(0, [{ name: "read" }])], }); // Should not throw and should still compact normally compact([op], null); expect(op.status).toBe("compacted"); }); it("does not emit when sink is provided but currentTurn is undefined", () => { const sink = makeSink(); const op = makeOp({ id: 1, status: "completed", score: 0.05 }); compact([op], null, undefined, sink); // no currentTurn expect(op.status).toBe("compacted"); expect(sink.events).toHaveLength(0); }); });