From 2c956b1898c87a4cfcb6bae2f7cef7a73a15b098 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 18 Oct 2026 20:41:03 -0700 Subject: [PATCH 1/2] feat(session): record the compaction trigger on fold records --- src/session/compactor-trigger.test.ts | 106 ++++++++++++++++++++++++++ src/session/compactor.ts | 33 +++++--- 2 files changed, 127 insertions(+), 12 deletions(-) create mode 100644 src/session/compactor-trigger.test.ts diff --git a/src/session/compactor-trigger.test.ts b/src/session/compactor-trigger.test.ts new file mode 100644 index 000000000..0e5ac4bd6 --- /dev/null +++ b/src/session/compactor-trigger.test.ts @@ -0,0 +1,106 @@ +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], }; From 001a23b73cdf60b69cbd1e002febe6d56bdb2690 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Sun, 18 Oct 2026 21:20:33 -0700 Subject: [PATCH 2/2] style(session): fit the trigger assertion on one line --- src/session/compactor-trigger.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/session/compactor-trigger.test.ts b/src/session/compactor-trigger.test.ts index 0e5ac4bd6..938c45ad2 100644 --- a/src/session/compactor-trigger.test.ts +++ b/src/session/compactor-trigger.test.ts @@ -97,9 +97,7 @@ describe("pruning compactor persists invocation trigger (CL-10869)", () => { 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.reason).toBe("verify failed — keeping prior context"); expect(result.record.decisions.verifyAbortReason).toBe("contradiction"); expect(result.record.decisions.trigger).toBe(trigger); });