pi-condense 2.9.2 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/PRUNING.md +10 -2
- package/README.md +1 -0
- package/index.ts +24 -28
- package/package.json +1 -1
- package/src/batch-capture.test.ts +116 -1
- package/src/batch-capture.ts +47 -16
- package/src/budget.test.ts +21 -1
- package/src/budget.ts +10 -0
- package/src/chain-compressor.test.ts +17 -0
- package/src/chain-detector.test.ts +77 -1
- package/src/chain-detector.ts +26 -7
- package/src/chain-range-prune.test.ts +83 -0
- package/src/chain-range-prune.ts +7 -5
- package/src/config.test.ts +22 -0
- package/src/config.ts +6 -0
- package/src/context-metrics.test.ts +35 -5
- package/src/context-metrics.ts +5 -2
- package/src/reload-rearm.integration.test.ts +356 -18
- package/src/types.ts +17 -3
|
@@ -559,6 +559,35 @@ describe("applyChainCompressions", () => {
|
|
|
559
559
|
expect(synthetic.content[0].text).not.toContain("<protected-output");
|
|
560
560
|
});
|
|
561
561
|
|
|
562
|
+
test("custom-anchored range: drops interior and relocates protected output, same as the user-anchored equivalent", () => {
|
|
563
|
+
const e = {
|
|
564
|
+
blockId: "b1",
|
|
565
|
+
startUserTimestamp: 1,
|
|
566
|
+
droppedToolCallIds: ["tc-read", "tc-todo"],
|
|
567
|
+
protectedToolCallIds: ["tc-todo"],
|
|
568
|
+
finalAssistantTimestamp: 9,
|
|
569
|
+
toolRefs: ["t1", "t2"],
|
|
570
|
+
compressedAt: 100,
|
|
571
|
+
};
|
|
572
|
+
const messages = [
|
|
573
|
+
{ role: "custom", customType: "pi-gauntlet-transition-recovery", timestamp: 1 },
|
|
574
|
+
{ role: "assistant", timestamp: 2, content: [
|
|
575
|
+
{ type: "toolCall", id: "tc-read", name: "read" },
|
|
576
|
+
{ type: "toolCall", id: "tc-todo", name: "todowrite" },
|
|
577
|
+
] },
|
|
578
|
+
{ role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "FILE" }] },
|
|
579
|
+
{ role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "PLAN-STATE" }] },
|
|
580
|
+
{ role: "assistant", timestamp: 9, content: [{ type: "text", text: "done" }] },
|
|
581
|
+
];
|
|
582
|
+
const out = applyChainCompressions(messages, [e] as any, () => "SUMMARY", false);
|
|
583
|
+
expect(out.find((m: any) => m.role === "toolResult" && m.toolCallId === "tc-todo")).toBeUndefined();
|
|
584
|
+
expect(out.find((m: any) => m.role === "assistant" && m.timestamp === 2)).toBeUndefined();
|
|
585
|
+
const synthetic = out.find((m: any) => typeof m.content?.[0]?.text === "string" && m.content[0].text.startsWith("<compressed-chain"));
|
|
586
|
+
expect(synthetic.content[0].text).toContain('<protected-output tool="todowrite">');
|
|
587
|
+
expect(synthetic.content[0].text).toContain("PLAN-STATE");
|
|
588
|
+
expect(synthetic.content[0].text).not.toContain("FILE");
|
|
589
|
+
});
|
|
590
|
+
|
|
562
591
|
test("blockSummaryLookup: missing lookup leaves placeholder literal", () => {
|
|
563
592
|
const msgs = [
|
|
564
593
|
userMsg(100),
|
|
@@ -636,6 +665,60 @@ describe("resolveRange", () => {
|
|
|
636
665
|
const range = resolveRange({ startUserTimestamp: 100, finalAssistantTimestamp: 200 }, messages);
|
|
637
666
|
expect(range).toEqual({ startIndex: 0, endIndex: 1 });
|
|
638
667
|
});
|
|
668
|
+
|
|
669
|
+
function customAnchor(timestamp: number, customType = "pi-gauntlet-transition-recovery"): any {
|
|
670
|
+
return { role: "custom", customType, timestamp };
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
test("resolves when the start anchor is an eligible non-pruner custom message", () => {
|
|
674
|
+
const messages = [
|
|
675
|
+
customAnchor(100),
|
|
676
|
+
assistantWithTools(200, ["tc1"]),
|
|
677
|
+
toolResult(300, "tc1"),
|
|
678
|
+
assistantText(400),
|
|
679
|
+
];
|
|
680
|
+
const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
|
|
681
|
+
expect(range).toEqual({ startIndex: 0, endIndex: 3 });
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("returns null when a user message and an eligible custom message collide at the same start timestamp", () => {
|
|
685
|
+
const messages = [
|
|
686
|
+
{ role: "user", content: [{ type: "text", text: "go" }], timestamp: 100 },
|
|
687
|
+
customAnchor(100),
|
|
688
|
+
assistantWithTools(200, ["tc1"]),
|
|
689
|
+
toolResult(300, "tc1"),
|
|
690
|
+
assistantText(400),
|
|
691
|
+
];
|
|
692
|
+
const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
|
|
693
|
+
expect(range).toBeNull();
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("returns null when two eligible custom messages share the start timestamp", () => {
|
|
697
|
+
const messages = [
|
|
698
|
+
customAnchor(100),
|
|
699
|
+
customAnchor(100, "other-extension"),
|
|
700
|
+
assistantWithTools(200, ["tc1"]),
|
|
701
|
+
toolResult(300, "tc1"),
|
|
702
|
+
assistantText(400),
|
|
703
|
+
];
|
|
704
|
+
const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
|
|
705
|
+
expect(range).toBeNull();
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
test("user-anchored resolution is unaffected by the custom-anchor widening (regression pin)", () => {
|
|
709
|
+
expect(resolveRange(entry(), base())).toEqual({ startIndex: 0, endIndex: 3 });
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("a context-prune-summary custom at the start timestamp does not count as a start match", () => {
|
|
713
|
+
const messages = [
|
|
714
|
+
summaryMsg(100, ["tc0"]),
|
|
715
|
+
assistantWithTools(200, ["tc1"]),
|
|
716
|
+
toolResult(300, "tc1"),
|
|
717
|
+
assistantText(400),
|
|
718
|
+
];
|
|
719
|
+
const range = resolveRange(entry({ startUserTimestamp: 100, finalAssistantTimestamp: 400 }), messages);
|
|
720
|
+
expect(range).toBeNull();
|
|
721
|
+
});
|
|
639
722
|
});
|
|
640
723
|
|
|
641
724
|
describe("applyChainCompressions - positional", () => {
|
package/src/chain-range-prune.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { CUSTOM_TYPE_SUMMARY } from "./types.js";
|
|
|
3
3
|
import type { ChainCompressionEntry } from "./types.js";
|
|
4
4
|
import { substituteBlockRefs } from "./nested-placeholders.js";
|
|
5
5
|
import { extractToolResultText } from "./batch-capture.js";
|
|
6
|
+
import { isChainAnchorCustom } from "./chain-detector.js";
|
|
6
7
|
import { bareToolCallId, occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
7
8
|
import type { DiagnosticSink } from "./diagnostics.js";
|
|
8
9
|
|
|
@@ -68,11 +69,12 @@ export function buildSyntheticChainMessage(
|
|
|
68
69
|
/**
|
|
69
70
|
* Resolves a persisted chain entry to a positional index range.
|
|
70
71
|
*
|
|
71
|
-
* Role-gated and unique-match-or-nothing: exactly one
|
|
72
|
+
* Role-gated and unique-match-or-nothing: exactly one start anchor (user
|
|
73
|
+
* message or eligible non-pruner custom, isChainAnchorCustom) at
|
|
72
74
|
* startUserTimestamp, exactly one assistant at finalAssistantTimestamp, and
|
|
73
|
-
*
|
|
74
|
-
* whole point: an id-set or timestamp-window fallback is what deleted
|
|
75
|
-
* turns (doc/specs/2026-08-12-toolcall-id-collisions.md).
|
|
75
|
+
* startIndex < endIndex. Otherwise null - the entry drops nothing. Fail-closed
|
|
76
|
+
* is the whole point: an id-set or timestamp-window fallback is what deleted
|
|
77
|
+
* live turns (doc/specs/2026-08-12-toolcall-id-collisions.md).
|
|
76
78
|
*/
|
|
77
79
|
export function resolveRange(
|
|
78
80
|
entry: Pick<ChainCompressionEntry, "startUserTimestamp" | "finalAssistantTimestamp">,
|
|
@@ -85,7 +87,7 @@ export function resolveRange(
|
|
|
85
87
|
let endMatches = 0;
|
|
86
88
|
for (let i = 0; i < messages.length; i++) {
|
|
87
89
|
const msg = messages[i];
|
|
88
|
-
if (msg.role === "user" && msg.timestamp === entry.startUserTimestamp) {
|
|
90
|
+
if ((msg.role === "user" || isChainAnchorCustom(msg)) && msg.timestamp === entry.startUserTimestamp) {
|
|
89
91
|
startMatches++;
|
|
90
92
|
if (startIndex < 0) startIndex = i;
|
|
91
93
|
} else if (msg.role === "assistant" && msg.timestamp === entry.finalAssistantTimestamp) {
|
package/src/config.test.ts
CHANGED
|
@@ -125,3 +125,25 @@ describe("loadConfig backward compatibility with removed thinkingStrip key", ()
|
|
|
125
125
|
expect(written.contextPrune.thinkingStrip).toEqual(stale);
|
|
126
126
|
});
|
|
127
127
|
});
|
|
128
|
+
|
|
129
|
+
describe("loadConfig frontierGapThresholdTokens normalization", () => {
|
|
130
|
+
it("defaults to null when unset", async () => {
|
|
131
|
+
await writeContextPrune({});
|
|
132
|
+
const config = await loadConfig();
|
|
133
|
+
expect(config.frontierGapThresholdTokens).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("floors a fractional value", async () => {
|
|
137
|
+
await writeContextPrune({ frontierGapThresholdTokens: 80000.7 });
|
|
138
|
+
const config = await loadConfig();
|
|
139
|
+
expect(config.frontierGapThresholdTokens).toBe(80000);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("falls back to null for 0, negative, Infinity, or a string", async () => {
|
|
143
|
+
for (const value of [0, -5, Infinity, "80000"]) {
|
|
144
|
+
await writeContextPrune({ frontierGapThresholdTokens: value });
|
|
145
|
+
const config = await loadConfig();
|
|
146
|
+
expect(config.frontierGapThresholdTokens).toBeNull();
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
package/src/config.ts
CHANGED
|
@@ -106,6 +106,12 @@ function normalize(existing: Partial<ContextPruneConfig>): ContextPruneConfig {
|
|
|
106
106
|
merged.budgetTurnDelta <= 1
|
|
107
107
|
? merged.budgetTurnDelta
|
|
108
108
|
: DEFAULT_CONFIG.budgetTurnDelta,
|
|
109
|
+
frontierGapThresholdTokens:
|
|
110
|
+
typeof merged.frontierGapThresholdTokens === "number" &&
|
|
111
|
+
Number.isFinite(merged.frontierGapThresholdTokens) &&
|
|
112
|
+
merged.frontierGapThresholdTokens > 0
|
|
113
|
+
? Math.floor(merged.frontierGapThresholdTokens)
|
|
114
|
+
: DEFAULT_CONFIG.frontierGapThresholdTokens,
|
|
109
115
|
};
|
|
110
116
|
}
|
|
111
117
|
|
|
@@ -170,11 +170,11 @@ describe("computeContextMetrics", () => {
|
|
|
170
170
|
expect(openSegmentChars).toBeGreaterThan(chainChars);
|
|
171
171
|
});
|
|
172
172
|
|
|
173
|
-
test("largestChainSharePct: a
|
|
173
|
+
test("largestChainSharePct: a pruner custom message (customType starting with context-prune-) counts toward the denominator only, never the chain numerator", () => {
|
|
174
174
|
// Mirrors index.ts's branch projection for persisted summary custom_message
|
|
175
|
-
// entries:
|
|
176
|
-
//
|
|
177
|
-
//
|
|
175
|
+
// entries: a pruner customType (context-prune-summary) is excluded from
|
|
176
|
+
// chain anchoring by isChainAnchorCustom, so it cannot join a chain or the
|
|
177
|
+
// open segment -- it only inflates totalChars (the denominator).
|
|
178
178
|
// The customEntry sits between two final text-only assistant messages, so
|
|
179
179
|
// it lands outside both the chain range and the open-cycle segment --
|
|
180
180
|
// isolating the denominator effect from any open-segment interaction.
|
|
@@ -184,7 +184,7 @@ describe("computeContextMetrics", () => {
|
|
|
184
184
|
toolResult(300, "tc1", "bash", "x".repeat(2000)),
|
|
185
185
|
assistantText(400),
|
|
186
186
|
];
|
|
187
|
-
const customEntry = { role: "custom", customType: "
|
|
187
|
+
const customEntry = { role: "custom", customType: "context-prune-summary", content: "s".repeat(3000), display: true, timestamp: 450 };
|
|
188
188
|
const closer = assistantText(500, "ok");
|
|
189
189
|
|
|
190
190
|
const withoutCustom = computeContextMetrics(chainMsgs, null, noSummarized, noProtected);
|
|
@@ -199,6 +199,36 @@ describe("computeContextMetrics", () => {
|
|
|
199
199
|
expect(withCustom.largestChainSharePct).toBeLessThan(withoutCustom.largestChainSharePct);
|
|
200
200
|
});
|
|
201
201
|
|
|
202
|
+
test("largestChainSharePct: a custom-anchored chain (eligible non-pruner custom message opens a chain) is counted in the numerator", () => {
|
|
203
|
+
// Mirrors the user-anchored share test's arithmetic, but the chain start
|
|
204
|
+
// is an eligible custom message (isChainAnchorCustom) instead of a user
|
|
205
|
+
// message -- Wave 1's chain-detector already opens chains on these; this
|
|
206
|
+
// pins that computeContextMetrics' chain-start lookup finds them too.
|
|
207
|
+
const customAnchor = {
|
|
208
|
+
role: "custom",
|
|
209
|
+
customType: "pi-gauntlet-transition-recovery",
|
|
210
|
+
content: "resuming",
|
|
211
|
+
timestamp: 100,
|
|
212
|
+
};
|
|
213
|
+
const msgs = [
|
|
214
|
+
customAnchor,
|
|
215
|
+
assistantWithTools(200, ["tc1"]),
|
|
216
|
+
toolResult(300, "tc1", "bash", "x".repeat(2000)), // big closed chain
|
|
217
|
+
assistantText(400),
|
|
218
|
+
userMsg(500),
|
|
219
|
+
assistantText(600), // tiny open segment (empty, since it's the last text-only assistant)
|
|
220
|
+
];
|
|
221
|
+
const chars = msgs.map((m) => JSON.stringify(m).length);
|
|
222
|
+
const totalChars = chars.reduce((a, b) => a + b, 0);
|
|
223
|
+
const chainChars = chars[0] + chars[1] + chars[2] + chars[3]; // customAnchor..assistantText(400)
|
|
224
|
+
const openSegmentChars = 0; // last text-only assistant is msgs[5] itself; open segment is empty
|
|
225
|
+
const expectedPct = Math.round((100 * Math.max(chainChars, openSegmentChars)) / totalChars);
|
|
226
|
+
|
|
227
|
+
const result = computeContextMetrics(msgs, null, noSummarized, noProtected);
|
|
228
|
+
expect(result.largestChainSharePct).toBe(expectedPct);
|
|
229
|
+
expect(chainChars).toBeGreaterThan(openSegmentChars);
|
|
230
|
+
});
|
|
231
|
+
|
|
202
232
|
test("largestChainSharePct: interrupted chain (null finalAssistantTimestamp) is counted", () => {
|
|
203
233
|
const msgs = [
|
|
204
234
|
userMsg(100),
|
package/src/context-metrics.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { detectChains } from "./chain-detector.js";
|
|
1
|
+
import { detectChains, isChainAnchorCustom } from "./chain-detector.js";
|
|
2
2
|
import { occKey, resultTimestampOf } from "./occurrence-key.js";
|
|
3
3
|
import type { ContextMetricsSnapshot, PruneFrontier } from "./types.js";
|
|
4
4
|
|
|
@@ -75,7 +75,9 @@ export function computeContextMetrics(
|
|
|
75
75
|
const chains = detectChains(branch, isProtected);
|
|
76
76
|
let largestClosedChainChars = 0;
|
|
77
77
|
for (const range of chains) {
|
|
78
|
-
const startIdx = branch.findIndex(
|
|
78
|
+
const startIdx = branch.findIndex(
|
|
79
|
+
(m) => (m.role === "user" || isChainAnchorCustom(m)) && m.timestamp === range.startUserTimestamp,
|
|
80
|
+
);
|
|
79
81
|
if (startIdx === -1) continue;
|
|
80
82
|
let endIdx: number;
|
|
81
83
|
if (range.finalAssistantTimestamp !== null) {
|
|
@@ -85,6 +87,7 @@ export function computeContextMetrics(
|
|
|
85
87
|
if (endIdx === -1) continue;
|
|
86
88
|
} else {
|
|
87
89
|
let nextUserIdx = -1;
|
|
90
|
+
// User-only: under idle-only semantics only a user message interrupts an open chain.
|
|
88
91
|
for (let i = startIdx + 1; i < branch.length; i++) {
|
|
89
92
|
if (branch[i].role === "user") {
|
|
90
93
|
nextUserIdx = i;
|
|
@@ -49,6 +49,18 @@ function okStream() {
|
|
|
49
49
|
};
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// Classified "transient" by runOnce (src/summarizer.ts) — with
|
|
53
|
+
// summarizerModel: "default" (no distinct fallback model) this yields a
|
|
54
|
+
// null SummarizeResult after exactly one stream() call, no retries.
|
|
55
|
+
function errStream(message: string) {
|
|
56
|
+
return {
|
|
57
|
+
async *[Symbol.asyncIterator]() {},
|
|
58
|
+
async result() {
|
|
59
|
+
return { stopReason: "error", errorMessage: message, content: [], usage: USAGE };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
52
64
|
let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
|
|
53
65
|
summarizerCalls++;
|
|
54
66
|
return okStream();
|
|
@@ -117,6 +129,26 @@ function closedChainBranch(count: number): any[] {
|
|
|
117
129
|
return msgs;
|
|
118
130
|
}
|
|
119
131
|
|
|
132
|
+
// Builds one independent pending batch: user -> assistant toolCall -> toolResult,
|
|
133
|
+
// with no closing text-only assistant (chain stays open, matching defaultBranch's
|
|
134
|
+
// shape). Used by the frontier-gap tests below to grow/shrink the branch's
|
|
135
|
+
// un-pruned tail across turns by direct array mutation (bootExtension returns
|
|
136
|
+
// the live `branch` array reference, so pushing onto it after boot is visible
|
|
137
|
+
// to every later getBranch() call).
|
|
138
|
+
function pendingBatchEntries(toolCallId: string, text: string, timestamp: number): any[] {
|
|
139
|
+
return [
|
|
140
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: `do ${toolCallId}` }], timestamp } },
|
|
141
|
+
{
|
|
142
|
+
type: "message",
|
|
143
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: toolCallId, name: "read", arguments: {} }], timestamp: timestamp + 500 },
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
type: "message",
|
|
147
|
+
message: { role: "toolResult", toolCallId, toolName: "read", content: [{ type: "text", text }], timestamp: timestamp + 1000 },
|
|
148
|
+
},
|
|
149
|
+
];
|
|
150
|
+
}
|
|
151
|
+
|
|
120
152
|
// Boots a fresh index.ts extension instance against an isolated agent dir +
|
|
121
153
|
// session, mirroring the fixtures shared across the three scenarios below.
|
|
122
154
|
//
|
|
@@ -137,35 +169,45 @@ function closedChainBranch(count: number): any[] {
|
|
|
137
169
|
function bootExtension(
|
|
138
170
|
options: {
|
|
139
171
|
chainCompressionEnabled?: boolean;
|
|
172
|
+
rollingWindow?: number;
|
|
140
173
|
separatePiAppended?: boolean;
|
|
141
174
|
piAppendEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => void;
|
|
142
175
|
sessionAppendCustomEntry?: (push: (type: string, data?: unknown) => void) => (type: string, data?: unknown) => string;
|
|
143
176
|
branch?: any[];
|
|
144
177
|
protectedTools?: string[];
|
|
178
|
+
autoBudgetThreshold?: number | null;
|
|
179
|
+
budgetTurnDelta?: number | null;
|
|
180
|
+
frontierGapThresholdTokens?: number | null;
|
|
145
181
|
} = {},
|
|
146
182
|
) {
|
|
147
183
|
const agentDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-"));
|
|
148
184
|
process.env.PI_CODING_AGENT_DIR = agentDir;
|
|
185
|
+
const contextPruneSettings: any = {
|
|
186
|
+
enabled: true,
|
|
187
|
+
pruneOn: "agent-message",
|
|
188
|
+
batchingMode: "agent-message",
|
|
189
|
+
autoBudgetThreshold: options.autoBudgetThreshold === undefined ? 0.5 : options.autoBudgetThreshold,
|
|
190
|
+
summarizerModel: "default",
|
|
191
|
+
minBatchChars: 1,
|
|
192
|
+
showPruneStatusLine: true,
|
|
193
|
+
protectedTools: options.protectedTools ?? [],
|
|
194
|
+
chainCompression: {
|
|
195
|
+
enabled: options.chainCompressionEnabled ?? false,
|
|
196
|
+
rollingWindow: options.rollingWindow ?? 3,
|
|
197
|
+
stripFinalAssistantThinking: true,
|
|
198
|
+
fuseRangeSummary: true,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
// Omitted unless the test explicitly passes them, so the "default-null
|
|
202
|
+
// inert" scenario can assert behavior with no key present at all (not an
|
|
203
|
+
// explicit null), matching config.ts's own default.
|
|
204
|
+
if (options.budgetTurnDelta !== undefined) contextPruneSettings.budgetTurnDelta = options.budgetTurnDelta;
|
|
205
|
+
if (options.frontierGapThresholdTokens !== undefined) {
|
|
206
|
+
contextPruneSettings.frontierGapThresholdTokens = options.frontierGapThresholdTokens;
|
|
207
|
+
}
|
|
149
208
|
writeFileSync(
|
|
150
209
|
join(agentDir, "settings.json"),
|
|
151
|
-
JSON.stringify({
|
|
152
|
-
contextPrune: {
|
|
153
|
-
enabled: true,
|
|
154
|
-
pruneOn: "agent-message",
|
|
155
|
-
batchingMode: "agent-message",
|
|
156
|
-
autoBudgetThreshold: 0.5,
|
|
157
|
-
summarizerModel: "default",
|
|
158
|
-
minBatchChars: 1,
|
|
159
|
-
showPruneStatusLine: true,
|
|
160
|
-
protectedTools: options.protectedTools ?? [],
|
|
161
|
-
chainCompression: {
|
|
162
|
-
enabled: options.chainCompressionEnabled ?? false,
|
|
163
|
-
rollingWindow: 3,
|
|
164
|
-
stripFinalAssistantThinking: true,
|
|
165
|
-
fuseRangeSummary: true,
|
|
166
|
-
},
|
|
167
|
-
},
|
|
168
|
-
}),
|
|
210
|
+
JSON.stringify({ contextPrune: contextPruneSettings }),
|
|
169
211
|
);
|
|
170
212
|
|
|
171
213
|
const sessionDir = mkdtempSync(join(tmpdir(), "pi-condense-rearm-session-"));
|
|
@@ -577,4 +619,300 @@ describe("reload rearm (issue #6)", () => {
|
|
|
577
619
|
expect(text).toContain(`chain share: ${expectedPct}%`);
|
|
578
620
|
expect(expectedPct).toBeLessThan(inflatedPct);
|
|
579
621
|
});
|
|
622
|
+
|
|
623
|
+
it("feeds persisted custom_message steers into chain detection via the shared projection (#13)", async () => {
|
|
624
|
+
// A production-feed regression: chain detection/compaction/metrics must
|
|
625
|
+
// all see custom_message entries projected as role "custom" (src/batch-
|
|
626
|
+
// capture.ts projectBranchMessages), not just plain "message" entries.
|
|
627
|
+
// A non-pruner customType (isChainAnchorCustom) opens a chain while idle
|
|
628
|
+
// and anchors resolveRange the same way a user message does; a pruner-
|
|
629
|
+
// namespaced customType (context-prune-*) must NOT anchor one.
|
|
630
|
+
const t0 = new Date().toISOString();
|
|
631
|
+
let t = new Date(t0).getTime();
|
|
632
|
+
|
|
633
|
+
const customAnchoredChain: any[] = [
|
|
634
|
+
{
|
|
635
|
+
type: "custom_message",
|
|
636
|
+
customType: "pi-gauntlet-transition-recovery",
|
|
637
|
+
content: [{ type: "text", text: "continue" }],
|
|
638
|
+
timestamp: t0,
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
type: "message",
|
|
642
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-custom", name: "read", arguments: {} }] },
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
type: "message",
|
|
646
|
+
message: {
|
|
647
|
+
role: "toolResult",
|
|
648
|
+
toolCallId: "tc-custom",
|
|
649
|
+
toolName: "read",
|
|
650
|
+
content: [{ type: "text", text: "x".repeat(400) }],
|
|
651
|
+
timestamp: (t += 1000),
|
|
652
|
+
},
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
type: "message",
|
|
656
|
+
message: { role: "assistant", content: [{ type: "text", text: "done custom" }], timestamp: (t += 1000) },
|
|
657
|
+
},
|
|
658
|
+
];
|
|
659
|
+
|
|
660
|
+
// A second closed, user-anchored chain so the custom-anchored chain above
|
|
661
|
+
// is not the newest/frontier chain (rollingWindow: 0 makes every closed
|
|
662
|
+
// chain not already compressed eligible regardless, but this mirrors a
|
|
663
|
+
// realistic multi-turn session and rules out any "only chain" special case).
|
|
664
|
+
const trailingChain: any[] = [
|
|
665
|
+
{ type: "message", message: { role: "user", content: [{ type: "text", text: "do more" }], timestamp: (t += 1000) } },
|
|
666
|
+
{
|
|
667
|
+
type: "message",
|
|
668
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-trail", name: "read", arguments: {} }] },
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
type: "message",
|
|
672
|
+
message: {
|
|
673
|
+
role: "toolResult",
|
|
674
|
+
toolCallId: "tc-trail",
|
|
675
|
+
toolName: "read",
|
|
676
|
+
content: [{ type: "text", text: "y".repeat(400) }],
|
|
677
|
+
timestamp: (t += 1000),
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
{
|
|
681
|
+
type: "message",
|
|
682
|
+
message: { role: "assistant", content: [{ type: "text", text: "done trailing" }], timestamp: (t += 1000) },
|
|
683
|
+
},
|
|
684
|
+
];
|
|
685
|
+
|
|
686
|
+
const branch = [...customAnchoredChain, ...trailingChain];
|
|
687
|
+
|
|
688
|
+
const { handlers, ctx, appended } = await boot({ chainCompressionEnabled: true, rollingWindow: 0, branch });
|
|
689
|
+
|
|
690
|
+
await handlers.get("session_start")!({}, ctx);
|
|
691
|
+
await handlers.get("turn_end")!(
|
|
692
|
+
{ toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 },
|
|
693
|
+
ctx,
|
|
694
|
+
);
|
|
695
|
+
await handlers.get("message_end")!(
|
|
696
|
+
{ message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
|
|
697
|
+
ctx,
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
const chainEntries = appended.filter((e) => e.type === "context-prune-chain");
|
|
701
|
+
const anchoredAtCustom = chainEntries.find(
|
|
702
|
+
(e) => (e.data as any).startUserTimestamp === new Date(t0).getTime(),
|
|
703
|
+
);
|
|
704
|
+
expect(anchoredAtCustom).toBeDefined();
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
it("does not let a pruner-namespaced custom_message (context-prune-*) anchor a chain (#13)", async () => {
|
|
708
|
+
const t0 = new Date().toISOString();
|
|
709
|
+
let t = new Date(t0).getTime();
|
|
710
|
+
|
|
711
|
+
const pruneSummaryEntry: any[] = [
|
|
712
|
+
{
|
|
713
|
+
type: "custom_message",
|
|
714
|
+
customType: "context-prune-summary",
|
|
715
|
+
content: [{ type: "text", text: "prior summary" }],
|
|
716
|
+
timestamp: t0,
|
|
717
|
+
},
|
|
718
|
+
{
|
|
719
|
+
type: "message",
|
|
720
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-p", name: "read", arguments: {} }] },
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
type: "message",
|
|
724
|
+
message: {
|
|
725
|
+
role: "toolResult",
|
|
726
|
+
toolCallId: "tc-p",
|
|
727
|
+
toolName: "read",
|
|
728
|
+
content: [{ type: "text", text: "x".repeat(400) }],
|
|
729
|
+
timestamp: (t += 1000),
|
|
730
|
+
},
|
|
731
|
+
},
|
|
732
|
+
{
|
|
733
|
+
type: "message",
|
|
734
|
+
message: { role: "assistant", content: [{ type: "text", text: "done p" }], timestamp: (t += 1000) },
|
|
735
|
+
},
|
|
736
|
+
];
|
|
737
|
+
|
|
738
|
+
const { handlers, ctx, appended } = await boot({ chainCompressionEnabled: true, rollingWindow: 0, branch: pruneSummaryEntry });
|
|
739
|
+
|
|
740
|
+
await handlers.get("session_start")!({}, ctx);
|
|
741
|
+
await handlers.get("turn_end")!(
|
|
742
|
+
{ toolResults: [], message: { role: "assistant", content: [{ type: "text", text: "hi" }] }, turnIndex: 2 },
|
|
743
|
+
ctx,
|
|
744
|
+
);
|
|
745
|
+
await handlers.get("message_end")!(
|
|
746
|
+
{ message: { role: "assistant", content: [{ type: "text", text: "done" }] } },
|
|
747
|
+
ctx,
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
const chainEntries = appended.filter((e) => e.type === "context-prune-chain");
|
|
751
|
+
const anchoredAtSummary = chainEntries.find(
|
|
752
|
+
(e) => (e.data as any).startUserTimestamp === new Date(t0).getTime(),
|
|
753
|
+
);
|
|
754
|
+
expect(anchoredAtSummary).toBeUndefined();
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it("frontier-gap trigger fires at turn_end when the un-pruned tail exceeds the threshold (#13)", async () => {
|
|
758
|
+
const { handlers, ctx, notifications, appended } = await boot({
|
|
759
|
+
autoBudgetThreshold: null,
|
|
760
|
+
frontierGapThresholdTokens: 10,
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
764
|
+
|
|
765
|
+
await handlers.get("session_start")!({}, ctx);
|
|
766
|
+
|
|
767
|
+
// defaultBranch() already carries an unsummarized ~400-char toolResult
|
|
768
|
+
// (~100 tokens), well past the threshold of 10.
|
|
769
|
+
await handlers.get("turn_end")!(
|
|
770
|
+
{
|
|
771
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }] },
|
|
772
|
+
toolResults: [
|
|
773
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
|
|
774
|
+
],
|
|
775
|
+
turnIndex: 2,
|
|
776
|
+
},
|
|
777
|
+
ctx,
|
|
778
|
+
);
|
|
779
|
+
|
|
780
|
+
const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
781
|
+
expect(flushMetricsEntries.length).toBe(1);
|
|
782
|
+
const fm = flushMetricsEntries[0].data as any;
|
|
783
|
+
expect(fm.trigger).toBe("frontier-gap");
|
|
784
|
+
expect(fm.metrics.frontierGapTokens).toBeGreaterThanOrEqual(10);
|
|
785
|
+
|
|
786
|
+
expect(notifications.some((n) => n.includes("un-pruned tail exceeded frontier gap threshold"))).toBe(true);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it("budget trigger takes precedence over frontier-gap when both conditions are met at turn_end (#13)", async () => {
|
|
790
|
+
const { handlers, ctx, appended } = await boot({
|
|
791
|
+
autoBudgetThreshold: 0.5,
|
|
792
|
+
frontierGapThresholdTokens: 10,
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
await handlers.get("session_start")!({}, ctx);
|
|
796
|
+
|
|
797
|
+
// Usage fraction 0.9 crosses the 0.5 budget threshold; defaultBranch()'s
|
|
798
|
+
// un-pruned tail also crosses the 10-token gap threshold. Budget must win.
|
|
799
|
+
ctx.getContextUsage = () => ({ tokens: 900000, contextWindow: 1000000 });
|
|
800
|
+
|
|
801
|
+
await handlers.get("turn_end")!(
|
|
802
|
+
{
|
|
803
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }] },
|
|
804
|
+
toolResults: [
|
|
805
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
|
|
806
|
+
],
|
|
807
|
+
turnIndex: 2,
|
|
808
|
+
},
|
|
809
|
+
ctx,
|
|
810
|
+
);
|
|
811
|
+
|
|
812
|
+
const flushMetricsEntries = appended.filter((e) => e.type === "context-prune-flush-metrics");
|
|
813
|
+
expect(flushMetricsEntries.length).toBe(1);
|
|
814
|
+
expect((flushMetricsEntries[0].data as any).trigger).toBe("budget");
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
it("frontier-gap trigger stays inert when frontierGapThresholdTokens is unset (default null), even with a huge un-pruned tail (#13)", async () => {
|
|
818
|
+
const { handlers, ctx, appended } = await boot({
|
|
819
|
+
autoBudgetThreshold: null,
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
await handlers.get("session_start")!({}, ctx);
|
|
823
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
824
|
+
|
|
825
|
+
await handlers.get("turn_end")!(
|
|
826
|
+
{
|
|
827
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "read", arguments: {} }] },
|
|
828
|
+
toolResults: [
|
|
829
|
+
{ role: "toolResult", toolCallId: "tc1", toolName: "read", content: [{ type: "text", text: "x".repeat(400) }], timestamp: Date.now() },
|
|
830
|
+
],
|
|
831
|
+
turnIndex: 2,
|
|
832
|
+
},
|
|
833
|
+
ctx,
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("frontier-gap cadence: a partial-failure flush persists the surviving prefix and advances the frontier; the next gap-triggered flush advances it further (#13)", async () => {
|
|
840
|
+
const { handlers, ctx, appended } = await boot({
|
|
841
|
+
autoBudgetThreshold: null,
|
|
842
|
+
frontierGapThresholdTokens: 10,
|
|
843
|
+
branch: [],
|
|
844
|
+
});
|
|
845
|
+
|
|
846
|
+
await handlers.get("session_start")!({}, ctx);
|
|
847
|
+
ctx.getContextUsage = () => ({ tokens: 10, contextWindow: 1000000 });
|
|
848
|
+
|
|
849
|
+
// Turn 1: branch still empty -> frontierGapTokens is 0 -> no flush, even
|
|
850
|
+
// though this turn's own toolResults are pushed into pendingBatches.
|
|
851
|
+
await handlers.get("turn_end")!(
|
|
852
|
+
{
|
|
853
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-warmup", name: "read", arguments: {} }] },
|
|
854
|
+
toolResults: [
|
|
855
|
+
{ role: "toolResult", toolCallId: "tc-warmup", toolName: "read", content: [{ type: "text", text: "w".repeat(400) }], timestamp: Date.now() },
|
|
856
|
+
],
|
|
857
|
+
turnIndex: 1,
|
|
858
|
+
},
|
|
859
|
+
ctx,
|
|
860
|
+
);
|
|
861
|
+
expect(appended.some((e) => e.type === "context-prune-flush-metrics")).toBe(false);
|
|
862
|
+
|
|
863
|
+
// Grow the branch with two independent unindexed batches (tc-a, tc-b) —
|
|
864
|
+
// capturePendingBatches rescans the branch, not the in-memory queue, so
|
|
865
|
+
// this is what actually makes the upcoming flush see two batches.
|
|
866
|
+
let t = Date.now();
|
|
867
|
+
ctx.sessionManager.getBranch().push(...pendingBatchEntries("tc-a", "a".repeat(400), (t += 1000)));
|
|
868
|
+
ctx.sessionManager.getBranch().push(...pendingBatchEntries("tc-b", "b".repeat(400), (t += 1000)));
|
|
869
|
+
|
|
870
|
+
let callCount = 0;
|
|
871
|
+
streamImpl = () => {
|
|
872
|
+
callCount++;
|
|
873
|
+
summarizerCalls++;
|
|
874
|
+
if (callCount === 2) return errStream("simulated summarizer failure on second batch");
|
|
875
|
+
return okStream();
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
// Turn 2: gap now over threshold (tc-a + tc-b unsummarized) -> flush fires,
|
|
879
|
+
// processes tc-a successfully, tc-b's summarization call fails.
|
|
880
|
+
await handlers.get("turn_end")!(
|
|
881
|
+
{
|
|
882
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-a", name: "read", arguments: {} }] },
|
|
883
|
+
toolResults: [
|
|
884
|
+
{ role: "toolResult", toolCallId: "tc-a", toolName: "read", content: [{ type: "text", text: "a".repeat(400) }], timestamp: Date.now() },
|
|
885
|
+
],
|
|
886
|
+
turnIndex: 2,
|
|
887
|
+
},
|
|
888
|
+
ctx,
|
|
889
|
+
);
|
|
890
|
+
|
|
891
|
+
let frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
|
|
892
|
+
expect(frontierEntries.length).toBe(1);
|
|
893
|
+
const firstFrontier = frontierEntries[0].data as any;
|
|
894
|
+
expect(firstFrontier.lastAttemptedToolCallId).toBe("tc-a");
|
|
895
|
+
|
|
896
|
+
// Grow the branch again (tc-b is still unsummarized/pending after the
|
|
897
|
+
// restore; add tc-c as this turn's new work) — gap stays over threshold.
|
|
898
|
+
ctx.sessionManager.getBranch().push(...pendingBatchEntries("tc-c", "c".repeat(400), (t += 1000)));
|
|
899
|
+
|
|
900
|
+
// Turn 3: gap still over threshold -> flush fires again, this time both
|
|
901
|
+
// tc-b (restored) and tc-c succeed (streamImpl only fails on call #2).
|
|
902
|
+
await handlers.get("turn_end")!(
|
|
903
|
+
{
|
|
904
|
+
message: { role: "assistant", content: [{ type: "toolCall", id: "tc-c", name: "read", arguments: {} }] },
|
|
905
|
+
toolResults: [
|
|
906
|
+
{ role: "toolResult", toolCallId: "tc-c", toolName: "read", content: [{ type: "text", text: "c".repeat(400) }], timestamp: Date.now() },
|
|
907
|
+
],
|
|
908
|
+
turnIndex: 3,
|
|
909
|
+
},
|
|
910
|
+
ctx,
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
frontierEntries = appended.filter((e) => e.type === "context-prune-frontier");
|
|
914
|
+
expect(frontierEntries.length).toBe(2);
|
|
915
|
+
const secondFrontier = frontierEntries[1].data as any;
|
|
916
|
+
expect(secondFrontier.lastAttemptedTimestamp).toBeGreaterThan(firstFrontier.lastAttemptedTimestamp);
|
|
917
|
+
});
|
|
580
918
|
});
|