diff --git a/src/session/compactor-trigger.test.ts b/src/session/compactor-trigger.test.ts new file mode 100644 index 000000000..938c45ad2 --- /dev/null +++ b/src/session/compactor-trigger.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, test } from "bun:test"; +import { createPruningCompactor } from "./compactor.js"; +import type { + ConversationTurn, + ReactorState, + StrategyContext, +} from "@intx/types/runtime"; + +function ctx(trigger: string): StrategyContext { + return { state: {} as ReactorState, trigger }; +} + +function textTurn( + role: ConversationTurn["role"], + text: string, +): ConversationTurn { + return { + role, + content: [{ type: "text", text }], + timestamp: 1, + }; +} + +// Bodies exceed the pinned tail budget so apply() takes the fold path. +function foldableTurns(): ConversationTurn[] { + const body = (label: string): string => `${label} ${"x".repeat(120)}`; + return [ + textTurn("user", body("old message 1")), + textTurn("assistant", body("old response 1")), + textTurn("user", body("old message 2")), + textTurn("assistant", body("old response 2")), + textTurn("user", "recent message"), + textTurn("assistant", "recent response"), + ]; +} + +function compactor( + summarize?: (turns: ConversationTurn[]) => Promise, +): ReturnType { + return createPruningCompactor({ + summaryMaxChars: 2000, + compactionShape: { tailBudgetTokens: 10 }, + ...(summarize !== undefined ? { summarize } : {}), + }); +} + +describe("pruning compactor persists invocation trigger (CL-10869)", () => { + test.each(["director:operator-request", "director:context-threshold"])( + "a real fold records %s without rewriting the human reason", + async (trigger) => { + const result = await compactor().apply(foldableTurns(), ctx(trigger)); + expect(result.record.reason).toMatch( + /^compacted \d+ turns, keeping \d+ tail$/, + ); + expect(result.record.reason).not.toContain("director:"); + expect(result.record.decisions.trigger).toBe(trigger); + }, + ); + + test("a verify keep-prior record carries the trigger that was passed", async () => { + const trigger = "director:context-overflow"; + const turns: ConversationTurn[] = [ + textTurn( + "user", + "Migrate the auth module to opaque tokens in src/auth.ts", + ), + textTurn("assistant", "Reading the handler first."), + { + role: "assistant", + content: [ + { + type: "tool_call", + id: "c1", + name: "run_shell", + arguments: { command: "bun run test auth" }, + }, + ], + timestamp: 3, + }, + { + role: "user", + content: [ + { + type: "tool_result", + callId: "c1", + isError: true, + content: [{ type: "text", text: "token refresh assertion failed" }], + }, + ], + timestamp: 4, + }, + textTurn("assistant", "Fix the token refresh assertion next."), + textTurn("user", "recent ask"), + textTurn("assistant", "recent reply"), + ]; + const result = await compactor( + async () => "Auth migration done. No errors remain.", + ).apply(turns, ctx(trigger)); + expect(result.output).toBe(turns); + expect(result.record.reason).toBe("verify failed — keeping prior context"); + expect(result.record.decisions.verifyAbortReason).toBe("contradiction"); + expect(result.record.decisions.trigger).toBe(trigger); + }); +}); diff --git a/src/session/compactor.ts b/src/session/compactor.ts index 3ce3f5d61..c466830e3 100644 --- a/src/session/compactor.ts +++ b/src/session/compactor.ts @@ -903,10 +903,19 @@ export function createPruningCompactor( version: "1.9.0", async apply( turns: ConversationTurn[], - _ctx: StrategyContext, + ctx: StrategyContext, ): Promise> { // Prior compacted summaries fold into the next handoff, not frozen. const shape = resolveCompactionShape(cfg.compactionShape); + // Operator and automatic folds share one human reason string. Keep the + // reactor's raw trigger (director: prefix included) on the manifest so + // a later reader can tell them apart. + const withTrigger = ( + fields: Record, + ): Record => ({ + ...fields, + trigger: ctx.trigger, + }); if (turns.length <= COMPACT_NOOP_FLOOR) { return { @@ -919,7 +928,7 @@ export function createPruningCompactor( ...extraInstructionParameter(cfg), }, reason: "no compaction needed", - decisions: { agedImageCount: 0 }, + decisions: withTrigger({ agedImageCount: 0 }), }, }; } @@ -988,14 +997,14 @@ export function createPruningCompactor( ...extraInstructionParameter(cfg), }, reason: "no compaction needed", - decisions: { + decisions: withTrigger({ tailBudgetTokens: shape.tailBudgetTokens, tailTokenEstimate: tail.tailTokenEstimate, shortenedToolOutputs: tail.shortenedToolOutputs, agedImageCount: aged.agedImageCount, supersededReadCount: supersededReads.size, liveTokenEstimate: estimateContextTokens(output), - }, + }), }, ...(aged.blobs.length > 0 ? { blobs: aged.blobs } : {}), }; @@ -1036,10 +1045,10 @@ export function createPruningCompactor( ...extraInstructionParameter(cfg), }, reason: "summarize failed", - decisions: { + decisions: withTrigger({ summarizeFailed: 1, agedImageCount: aged.agedImageCount, - }, + }), }, }; } @@ -1061,10 +1070,10 @@ export function createPruningCompactor( ...extraInstructionParameter(cfg), }, reason: "summarize failed", - decisions: { + decisions: withTrigger({ summarizeFailed: 1, agedImageCount: aged.agedImageCount, - }, + }), }, }; } @@ -1090,12 +1099,12 @@ export function createPruningCompactor( compactionShape: shape, }, reason: "verify failed — keeping prior context", - decisions: { + decisions: withTrigger({ verifyAborted: 1, verifyAbortReason: abortReason, verifyMissing: misses.map((m) => m.kind), agedImageCount: aged.agedImageCount, - }, + }), }, }); if (verified.aborted && verified.abortReason === "contradiction") { @@ -1198,7 +1207,7 @@ export function createPruningCompactor( ? ` (statistics-only stub: ${summarizeFallback})` : "" }`, - decisions: { + decisions: withTrigger({ summarizedTurnCount: summarizedTurns.length, anchorTurnCount: 0, recentTurnCount: tailTurns.length, @@ -1220,7 +1229,7 @@ export function createPruningCompactor( } : {}), ...verifyDecisions, - }, + }), }, blobs: [...aged.blobs, handoff.blob], };