Skip to content

Commit 2c956b1

Browse files
committed
feat(session): record the compaction trigger on fold records
1 parent 99f67eb commit 2c956b1

2 files changed

Lines changed: 127 additions & 12 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { createPruningCompactor } from "./compactor.js";
3+
import type {
4+
ConversationTurn,
5+
ReactorState,
6+
StrategyContext,
7+
} from "@intx/types/runtime";
8+
9+
function ctx(trigger: string): StrategyContext {
10+
return { state: {} as ReactorState, trigger };
11+
}
12+
13+
function textTurn(
14+
role: ConversationTurn["role"],
15+
text: string,
16+
): ConversationTurn {
17+
return {
18+
role,
19+
content: [{ type: "text", text }],
20+
timestamp: 1,
21+
};
22+
}
23+
24+
// Bodies exceed the pinned tail budget so apply() takes the fold path.
25+
function foldableTurns(): ConversationTurn[] {
26+
const body = (label: string): string => `${label} ${"x".repeat(120)}`;
27+
return [
28+
textTurn("user", body("old message 1")),
29+
textTurn("assistant", body("old response 1")),
30+
textTurn("user", body("old message 2")),
31+
textTurn("assistant", body("old response 2")),
32+
textTurn("user", "recent message"),
33+
textTurn("assistant", "recent response"),
34+
];
35+
}
36+
37+
function compactor(
38+
summarize?: (turns: ConversationTurn[]) => Promise<string>,
39+
): ReturnType<typeof createPruningCompactor> {
40+
return createPruningCompactor({
41+
summaryMaxChars: 2000,
42+
compactionShape: { tailBudgetTokens: 10 },
43+
...(summarize !== undefined ? { summarize } : {}),
44+
});
45+
}
46+
47+
describe("pruning compactor persists invocation trigger (CL-10869)", () => {
48+
test.each(["director:operator-request", "director:context-threshold"])(
49+
"a real fold records %s without rewriting the human reason",
50+
async (trigger) => {
51+
const result = await compactor().apply(foldableTurns(), ctx(trigger));
52+
expect(result.record.reason).toMatch(
53+
/^compacted \d+ turns, keeping \d+ tail$/,
54+
);
55+
expect(result.record.reason).not.toContain("director:");
56+
expect(result.record.decisions.trigger).toBe(trigger);
57+
},
58+
);
59+
60+
test("a verify keep-prior record carries the trigger that was passed", async () => {
61+
const trigger = "director:context-overflow";
62+
const turns: ConversationTurn[] = [
63+
textTurn(
64+
"user",
65+
"Migrate the auth module to opaque tokens in src/auth.ts",
66+
),
67+
textTurn("assistant", "Reading the handler first."),
68+
{
69+
role: "assistant",
70+
content: [
71+
{
72+
type: "tool_call",
73+
id: "c1",
74+
name: "run_shell",
75+
arguments: { command: "bun run test auth" },
76+
},
77+
],
78+
timestamp: 3,
79+
},
80+
{
81+
role: "user",
82+
content: [
83+
{
84+
type: "tool_result",
85+
callId: "c1",
86+
isError: true,
87+
content: [{ type: "text", text: "token refresh assertion failed" }],
88+
},
89+
],
90+
timestamp: 4,
91+
},
92+
textTurn("assistant", "Fix the token refresh assertion next."),
93+
textTurn("user", "recent ask"),
94+
textTurn("assistant", "recent reply"),
95+
];
96+
const result = await compactor(
97+
async () => "Auth migration done. No errors remain.",
98+
).apply(turns, ctx(trigger));
99+
expect(result.output).toBe(turns);
100+
expect(result.record.reason).toBe(
101+
"verify failed — keeping prior context",
102+
);
103+
expect(result.record.decisions.verifyAbortReason).toBe("contradiction");
104+
expect(result.record.decisions.trigger).toBe(trigger);
105+
});
106+
});

‎src/session/compactor.ts‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -903,10 +903,19 @@ export function createPruningCompactor(
903903
version: "1.9.0",
904904
async apply(
905905
turns: ConversationTurn[],
906-
_ctx: StrategyContext,
906+
ctx: StrategyContext,
907907
): Promise<StrategyResult<ConversationTurn[]>> {
908908
// Prior compacted summaries fold into the next handoff, not frozen.
909909
const shape = resolveCompactionShape(cfg.compactionShape);
910+
// Operator and automatic folds share one human reason string. Keep the
911+
// reactor's raw trigger (director: prefix included) on the manifest so
912+
// a later reader can tell them apart.
913+
const withTrigger = (
914+
fields: Record<string, unknown>,
915+
): Record<string, unknown> => ({
916+
...fields,
917+
trigger: ctx.trigger,
918+
});
910919

911920
if (turns.length <= COMPACT_NOOP_FLOOR) {
912921
return {
@@ -919,7 +928,7 @@ export function createPruningCompactor(
919928
...extraInstructionParameter(cfg),
920929
},
921930
reason: "no compaction needed",
922-
decisions: { agedImageCount: 0 },
931+
decisions: withTrigger({ agedImageCount: 0 }),
923932
},
924933
};
925934
}
@@ -988,14 +997,14 @@ export function createPruningCompactor(
988997
...extraInstructionParameter(cfg),
989998
},
990999
reason: "no compaction needed",
991-
decisions: {
1000+
decisions: withTrigger({
9921001
tailBudgetTokens: shape.tailBudgetTokens,
9931002
tailTokenEstimate: tail.tailTokenEstimate,
9941003
shortenedToolOutputs: tail.shortenedToolOutputs,
9951004
agedImageCount: aged.agedImageCount,
9961005
supersededReadCount: supersededReads.size,
9971006
liveTokenEstimate: estimateContextTokens(output),
998-
},
1007+
}),
9991008
},
10001009
...(aged.blobs.length > 0 ? { blobs: aged.blobs } : {}),
10011010
};
@@ -1036,10 +1045,10 @@ export function createPruningCompactor(
10361045
...extraInstructionParameter(cfg),
10371046
},
10381047
reason: "summarize failed",
1039-
decisions: {
1048+
decisions: withTrigger({
10401049
summarizeFailed: 1,
10411050
agedImageCount: aged.agedImageCount,
1042-
},
1051+
}),
10431052
},
10441053
};
10451054
}
@@ -1061,10 +1070,10 @@ export function createPruningCompactor(
10611070
...extraInstructionParameter(cfg),
10621071
},
10631072
reason: "summarize failed",
1064-
decisions: {
1073+
decisions: withTrigger({
10651074
summarizeFailed: 1,
10661075
agedImageCount: aged.agedImageCount,
1067-
},
1076+
}),
10681077
},
10691078
};
10701079
}
@@ -1090,12 +1099,12 @@ export function createPruningCompactor(
10901099
compactionShape: shape,
10911100
},
10921101
reason: "verify failed — keeping prior context",
1093-
decisions: {
1102+
decisions: withTrigger({
10941103
verifyAborted: 1,
10951104
verifyAbortReason: abortReason,
10961105
verifyMissing: misses.map((m) => m.kind),
10971106
agedImageCount: aged.agedImageCount,
1098-
},
1107+
}),
10991108
},
11001109
});
11011110
if (verified.aborted && verified.abortReason === "contradiction") {
@@ -1198,7 +1207,7 @@ export function createPruningCompactor(
11981207
? ` (statistics-only stub: ${summarizeFallback})`
11991208
: ""
12001209
}`,
1201-
decisions: {
1210+
decisions: withTrigger({
12021211
summarizedTurnCount: summarizedTurns.length,
12031212
anchorTurnCount: 0,
12041213
recentTurnCount: tailTurns.length,
@@ -1220,7 +1229,7 @@ export function createPruningCompactor(
12201229
}
12211230
: {}),
12221231
...verifyDecisions,
1223-
},
1232+
}),
12241233
},
12251234
blobs: [...aged.blobs, handoff.blob],
12261235
};

0 commit comments

Comments
 (0)