pi-condense 2.0.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 +73 -0
- package/LICENSE +22 -0
- package/PRUNING.md +1028 -0
- package/README.md +243 -0
- package/index.ts +858 -0
- package/package.json +56 -0
- package/src/batch-capture.ts +226 -0
- package/src/block-refs.test.ts +42 -0
- package/src/block-refs.ts +16 -0
- package/src/budget.test.ts +66 -0
- package/src/budget.ts +39 -0
- package/src/chain-compressor.test.ts +283 -0
- package/src/chain-compressor.ts +132 -0
- package/src/chain-detector.test.ts +302 -0
- package/src/chain-detector.ts +128 -0
- package/src/chain-range-prune.test.ts +522 -0
- package/src/chain-range-prune.ts +128 -0
- package/src/commands.test.ts +67 -0
- package/src/commands.ts +1207 -0
- package/src/config.ts +126 -0
- package/src/content-hash.ts +35 -0
- package/src/error-purge.test.ts +186 -0
- package/src/error-purge.ts +71 -0
- package/src/frontier.ts +62 -0
- package/src/indexer.ts +393 -0
- package/src/nested-placeholders.test.ts +82 -0
- package/src/nested-placeholders.ts +20 -0
- package/src/oversized-spill.integration.test.ts +73 -0
- package/src/protected.test.ts +62 -0
- package/src/protected.ts +51 -0
- package/src/pruner.test.ts +508 -0
- package/src/pruner.ts +156 -0
- package/src/query-tool.ts +78 -0
- package/src/range-compression.integration.test.ts +252 -0
- package/src/spill.test.ts +102 -0
- package/src/spill.ts +90 -0
- package/src/stats.test.ts +114 -0
- package/src/stats.ts +190 -0
- package/src/summarizer.test.ts +17 -0
- package/src/summarizer.ts +262 -0
- package/src/summary-refs.ts +61 -0
- package/src/thinking-strip.test.ts +175 -0
- package/src/thinking-strip.ts +42 -0
- package/src/tree-browser.ts +382 -0
- package/src/types.ts +764 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { detectChains, withClosingMessage } from "./chain-detector.js";
|
|
3
|
+
|
|
4
|
+
// ── Minimal message factories ──────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
function userMsg(timestamp: number, text = "do the thing"): any {
|
|
7
|
+
return { role: "user", content: [{ type: "text", text }], timestamp };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function syntheticChainMsg(timestamp: number, blockId = "b1"): any {
|
|
11
|
+
return {
|
|
12
|
+
role: "user",
|
|
13
|
+
content: [{ type: "text", text: `<compressed-chain id="${blockId}" tools="t1">summary</compressed-chain>` }],
|
|
14
|
+
timestamp,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assistantWithTools(timestamp: number, toolCallIds: string[]): any {
|
|
19
|
+
return {
|
|
20
|
+
role: "assistant",
|
|
21
|
+
content: [
|
|
22
|
+
{ type: "text", text: "working..." },
|
|
23
|
+
...toolCallIds.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })),
|
|
24
|
+
],
|
|
25
|
+
timestamp,
|
|
26
|
+
usage: {},
|
|
27
|
+
stopReason: "toolUse",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toolResult(timestamp: number, toolCallId: string): any {
|
|
32
|
+
return {
|
|
33
|
+
role: "toolResult",
|
|
34
|
+
toolCallId,
|
|
35
|
+
toolName: "bash",
|
|
36
|
+
content: [{ type: "text", text: "output" }],
|
|
37
|
+
isError: false,
|
|
38
|
+
timestamp,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function assistantText(timestamp: number, text = "done"): any {
|
|
43
|
+
return {
|
|
44
|
+
role: "assistant",
|
|
45
|
+
content: [{ type: "text", text }, { type: "thinking", thinking: "thoughts", thinkingSignature: "sig" }],
|
|
46
|
+
timestamp,
|
|
47
|
+
usage: {},
|
|
48
|
+
stopReason: "stop",
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── Tests ──────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe("detectChains", () => {
|
|
55
|
+
test("empty input returns empty array", () => {
|
|
56
|
+
expect(detectChains([])).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("single complete chain produces one range", () => {
|
|
60
|
+
const msgs = [
|
|
61
|
+
userMsg(100),
|
|
62
|
+
assistantWithTools(200, ["tc1", "tc2"]),
|
|
63
|
+
toolResult(300, "tc1"),
|
|
64
|
+
toolResult(310, "tc2"),
|
|
65
|
+
assistantText(400),
|
|
66
|
+
];
|
|
67
|
+
const ranges = detectChains(msgs);
|
|
68
|
+
expect(ranges).toHaveLength(1);
|
|
69
|
+
expect(ranges[0].startUserTimestamp).toBe(100);
|
|
70
|
+
expect(ranges[0].middleToolCallIds).toContain("tc1");
|
|
71
|
+
expect(ranges[0].middleToolCallIds).toContain("tc2");
|
|
72
|
+
expect(ranges[0].finalAssistantTimestamp).toBe(400);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("multi-chain sequence produces N ranges in order", () => {
|
|
76
|
+
const msgs = [
|
|
77
|
+
userMsg(100),
|
|
78
|
+
assistantWithTools(200, ["tc1"]),
|
|
79
|
+
toolResult(300, "tc1"),
|
|
80
|
+
assistantText(400),
|
|
81
|
+
userMsg(500),
|
|
82
|
+
assistantWithTools(600, ["tc2"]),
|
|
83
|
+
toolResult(700, "tc2"),
|
|
84
|
+
assistantText(800),
|
|
85
|
+
];
|
|
86
|
+
const ranges = detectChains(msgs);
|
|
87
|
+
expect(ranges).toHaveLength(2);
|
|
88
|
+
expect(ranges[0].startUserTimestamp).toBe(100);
|
|
89
|
+
expect(ranges[0].finalAssistantTimestamp).toBe(400);
|
|
90
|
+
expect(ranges[1].startUserTimestamp).toBe(500);
|
|
91
|
+
expect(ranges[1].finalAssistantTimestamp).toBe(800);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("open chain (no text-only close) is not emitted", () => {
|
|
95
|
+
const msgs = [
|
|
96
|
+
userMsg(100),
|
|
97
|
+
assistantWithTools(200, ["tc1"]),
|
|
98
|
+
toolResult(300, "tc1"),
|
|
99
|
+
// no text-only assistant close
|
|
100
|
+
];
|
|
101
|
+
expect(detectChains(msgs)).toHaveLength(0);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("synthetic chain message is not treated as a chain start", () => {
|
|
105
|
+
const msgs = [
|
|
106
|
+
syntheticChainMsg(50), // should be skipped
|
|
107
|
+
assistantText(200), // text-only but no prior real chain start
|
|
108
|
+
];
|
|
109
|
+
expect(detectChains(msgs)).toHaveLength(0);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("synthetic chain message in a real multi-chain sequence is a passthrough", () => {
|
|
113
|
+
// Represents a session after one chain was already compressed:
|
|
114
|
+
// synthetic summary, then the next real chain
|
|
115
|
+
const msgs = [
|
|
116
|
+
syntheticChainMsg(50),
|
|
117
|
+
userMsg(100),
|
|
118
|
+
assistantWithTools(200, ["tc1"]),
|
|
119
|
+
toolResult(300, "tc1"),
|
|
120
|
+
assistantText(400),
|
|
121
|
+
];
|
|
122
|
+
const ranges = detectChains(msgs);
|
|
123
|
+
expect(ranges).toHaveLength(1);
|
|
124
|
+
expect(ranges[0].startUserTimestamp).toBe(100);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("interrupted chain (user interrupts before text-only close) emits with null final", () => {
|
|
128
|
+
const msgs = [
|
|
129
|
+
userMsg(100),
|
|
130
|
+
assistantWithTools(200, ["tc1"]),
|
|
131
|
+
toolResult(300, "tc1"),
|
|
132
|
+
userMsg(400), // second user message interrupts before chain close
|
|
133
|
+
assistantText(500),
|
|
134
|
+
];
|
|
135
|
+
const ranges = detectChains(msgs);
|
|
136
|
+
// First chain is interrupted → emitted with null finalAssistantTimestamp
|
|
137
|
+
expect(ranges).toHaveLength(2);
|
|
138
|
+
expect(ranges[0].startUserTimestamp).toBe(100);
|
|
139
|
+
expect(ranges[0].finalAssistantTimestamp).toBeNull();
|
|
140
|
+
// Second "chain" opened at ts=400, but text-only at 500 closes it (no tool calls needed)
|
|
141
|
+
// Actually, the second user at 400 starts a chain, but its assistant turn has no toolCalls
|
|
142
|
+
// so middleToolCallIds is empty and it immediately closes with the text-only assistant.
|
|
143
|
+
expect(ranges[1].startUserTimestamp).toBe(400);
|
|
144
|
+
expect(ranges[1].middleToolCallIds).toEqual([]);
|
|
145
|
+
expect(ranges[1].finalAssistantTimestamp).toBe(500);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("agent-message flush: closing assistant absent from branch → newest chain reads as open", () => {
|
|
149
|
+
// Reproduces the runtime state at message_end: pi emits the event to extensions
|
|
150
|
+
// BEFORE appending event.message, so the just-closed assistant is missing here.
|
|
151
|
+
const branch = [
|
|
152
|
+
userMsg(100),
|
|
153
|
+
assistantWithTools(200, ["tc1"]),
|
|
154
|
+
toolResult(300, "tc1"),
|
|
155
|
+
assistantText(400),
|
|
156
|
+
userMsg(500),
|
|
157
|
+
assistantWithTools(600, ["tc2"]),
|
|
158
|
+
toolResult(700, "tc2"),
|
|
159
|
+
// closing assistant for chain 2 not yet in branch
|
|
160
|
+
];
|
|
161
|
+
const ranges = detectChains(branch);
|
|
162
|
+
expect(ranges).toHaveLength(1); // only chain 1 closed; chain 2 open → dropped
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("middleToolCallIds contains all toolCallIds from assistant and toolResult messages", () => {
|
|
166
|
+
const msgs = [
|
|
167
|
+
userMsg(100),
|
|
168
|
+
assistantWithTools(200, ["tc1", "tc2"]),
|
|
169
|
+
toolResult(300, "tc1"),
|
|
170
|
+
toolResult(310, "tc2"),
|
|
171
|
+
assistantWithTools(400, ["tc3"]),
|
|
172
|
+
toolResult(500, "tc3"),
|
|
173
|
+
assistantText(600),
|
|
174
|
+
];
|
|
175
|
+
const [range] = detectChains(msgs);
|
|
176
|
+
expect(range.middleToolCallIds.sort()).toEqual(["tc1", "tc2", "tc3"].sort());
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("detectChains protectedToolCallIds", () => {
|
|
181
|
+
const chainMsgs = () => [
|
|
182
|
+
{ role: "user", timestamp: 1 },
|
|
183
|
+
{
|
|
184
|
+
role: "assistant",
|
|
185
|
+
timestamp: 2,
|
|
186
|
+
content: [
|
|
187
|
+
{ type: "toolCall", id: "tc-read", name: "read" },
|
|
188
|
+
{ type: "toolCall", id: "tc-todo", name: "todowrite" },
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
{ role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "x" }] },
|
|
192
|
+
{ role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "plan" }] },
|
|
193
|
+
{ role: "assistant", timestamp: 5, content: [{ type: "text", text: "done" }] },
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
test("is empty when no protectedTools are given", () => {
|
|
197
|
+
const [chain] = detectChains(chainMsgs());
|
|
198
|
+
expect(chain.protectedToolCallIds).toEqual([]);
|
|
199
|
+
expect(chain.middleToolCallIds.sort()).toEqual(["tc-read", "tc-todo"]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("collects protected ids by tool name from both branches", () => {
|
|
203
|
+
const [chain] = detectChains(chainMsgs(), (name) => name === "todowrite");
|
|
204
|
+
expect(chain.protectedToolCallIds).toEqual(["tc-todo"]);
|
|
205
|
+
expect(chain.middleToolCallIds.sort()).toEqual(["tc-read", "tc-todo"]);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("does not leak protected ids across consecutive chains", () => {
|
|
209
|
+
const msgs = [
|
|
210
|
+
// chain 1: has a protected todowrite
|
|
211
|
+
{ role: "user", timestamp: 1 },
|
|
212
|
+
{ role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "tc-todo", name: "todowrite" }] },
|
|
213
|
+
{ role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "p" }] },
|
|
214
|
+
{ role: "assistant", timestamp: 4, content: [{ type: "text", text: "done 1" }] },
|
|
215
|
+
// chain 2: only an unprotected read
|
|
216
|
+
{ role: "user", timestamp: 5 },
|
|
217
|
+
{ role: "assistant", timestamp: 6, content: [{ type: "toolCall", id: "tc-read", name: "read" }] },
|
|
218
|
+
{ role: "toolResult", toolCallId: "tc-read", toolName: "read", content: [{ type: "text", text: "x" }] },
|
|
219
|
+
{ role: "assistant", timestamp: 8, content: [{ type: "text", text: "done 2" }] },
|
|
220
|
+
];
|
|
221
|
+
const chains = detectChains(msgs, (name) => name === "todowrite");
|
|
222
|
+
expect(chains.length).toBe(2);
|
|
223
|
+
expect(chains[0].protectedToolCallIds).toEqual(["tc-todo"]);
|
|
224
|
+
expect(chains[1].protectedToolCallIds).toEqual([]);
|
|
225
|
+
expect(chains[1].middleToolCallIds).toEqual(["tc-read"]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("collects protected ids by args.path via predicate", () => {
|
|
229
|
+
const msgs = [
|
|
230
|
+
{ role: "user", timestamp: 1 },
|
|
231
|
+
{
|
|
232
|
+
role: "assistant",
|
|
233
|
+
timestamp: 2,
|
|
234
|
+
content: [
|
|
235
|
+
{ type: "toolCall", id: "tc-skill", name: "read", input: { path: "/h/skills/x/SKILL.md" } },
|
|
236
|
+
{ type: "toolCall", id: "tc-src", name: "read", input: { path: "/h/src/app.ts" } },
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
{ role: "toolResult", toolCallId: "tc-skill", toolName: "read", timestamp: 3, content: [] },
|
|
240
|
+
{ role: "toolResult", toolCallId: "tc-src", toolName: "read", timestamp: 4, content: [] },
|
|
241
|
+
{ role: "assistant", timestamp: 5, content: [{ type: "text", text: "done" }] },
|
|
242
|
+
];
|
|
243
|
+
const pred = (name: string, args: unknown) =>
|
|
244
|
+
typeof (args as any)?.path === "string" && (args as any).path.includes("/skills/");
|
|
245
|
+
const [chain] = detectChains(msgs, pred);
|
|
246
|
+
expect(chain.protectedToolCallIds).toEqual(["tc-skill"]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("populates protectedToolCallIds on an interrupted (open→new user) chain", () => {
|
|
250
|
+
const msgs = [
|
|
251
|
+
{ role: "user", timestamp: 1 },
|
|
252
|
+
{ role: "assistant", timestamp: 2, content: [{ type: "toolCall", id: "tc-todo", name: "todowrite" }] },
|
|
253
|
+
{ role: "toolResult", toolCallId: "tc-todo", toolName: "todowrite", content: [{ type: "text", text: "p" }] },
|
|
254
|
+
{ role: "user", timestamp: 4 },
|
|
255
|
+
];
|
|
256
|
+
const [interrupted] = detectChains(msgs, (name) => name === "todowrite");
|
|
257
|
+
expect(interrupted.finalAssistantTimestamp).toBeNull();
|
|
258
|
+
expect(interrupted.protectedToolCallIds).toEqual(["tc-todo"]);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("withClosingMessage", () => {
|
|
263
|
+
test("undefined closing returns the same array reference", () => {
|
|
264
|
+
const msgs = [userMsg(100)];
|
|
265
|
+
expect(withClosingMessage(msgs, undefined)).toBe(msgs);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("appends closing when branch does not already end with it", () => {
|
|
269
|
+
const branch = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1")];
|
|
270
|
+
const closing = assistantText(400);
|
|
271
|
+
const merged = withClosingMessage(branch, closing);
|
|
272
|
+
expect(merged).toHaveLength(4);
|
|
273
|
+
expect(merged[3]).toBe(closing);
|
|
274
|
+
expect(branch).toHaveLength(3); // original not mutated
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("does not double-append when branch already ends with the closing message (role+timestamp)", () => {
|
|
278
|
+
const closing = assistantText(400);
|
|
279
|
+
const branch = [userMsg(100), assistantWithTools(200, ["tc1"]), toolResult(300, "tc1"), closing];
|
|
280
|
+
expect(withClosingMessage(branch, closing)).toBe(branch);
|
|
281
|
+
// also dedups a distinct object with the same role+timestamp
|
|
282
|
+
const branch2 = [userMsg(100), assistantText(400)];
|
|
283
|
+
expect(withClosingMessage(branch2, assistantText(400))).toBe(branch2);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("threading the closing message makes the newest chain close (effective window = K)", () => {
|
|
287
|
+
const branch = [
|
|
288
|
+
userMsg(100),
|
|
289
|
+
assistantWithTools(200, ["tc1"]),
|
|
290
|
+
toolResult(300, "tc1"),
|
|
291
|
+
assistantText(400),
|
|
292
|
+
userMsg(500),
|
|
293
|
+
assistantWithTools(600, ["tc2"]),
|
|
294
|
+
toolResult(700, "tc2"),
|
|
295
|
+
];
|
|
296
|
+
const closing = assistantText(800);
|
|
297
|
+
const ranges = detectChains(withClosingMessage(branch, closing));
|
|
298
|
+
expect(ranges).toHaveLength(2);
|
|
299
|
+
expect(ranges[1].startUserTimestamp).toBe(500);
|
|
300
|
+
expect(ranges[1].finalAssistantTimestamp).toBe(800);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { ChainRange } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/** Prefix that identifies a synthetic chain-compression user message. */
|
|
4
|
+
const COMPRESSED_CHAIN_PREFIX = "<compressed-chain";
|
|
5
|
+
|
|
6
|
+
function isSyntheticChainMessage(msg: any): boolean {
|
|
7
|
+
const content = msg.content;
|
|
8
|
+
if (typeof content === "string") return content.trimStart().startsWith(COMPRESSED_CHAIN_PREFIX);
|
|
9
|
+
if (!Array.isArray(content)) return false;
|
|
10
|
+
const first = content[0];
|
|
11
|
+
return first?.type === "text" && typeof first.text === "string" && first.text.trimStart().startsWith(COMPRESSED_CHAIN_PREFIX);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hasToolCalls(msg: any): boolean {
|
|
15
|
+
return Array.isArray(msg.content) && msg.content.some((b: any) => b.type === "toolCall");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function collectToolCalls(msg: any): { id: string; name: string; args: unknown }[] {
|
|
19
|
+
if (!Array.isArray(msg.content)) return [];
|
|
20
|
+
return msg.content
|
|
21
|
+
.filter((b: any) => b.type === "toolCall" && b.id && b.name)
|
|
22
|
+
.map((b: any) => ({ id: b.id as string, name: b.name as string, args: b.input ?? b.arguments }));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type State = "idle" | "inChain";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Walks an AgentMessage array and emits ChainRange records for each detectable chain.
|
|
29
|
+
*
|
|
30
|
+
* A chain is: [user message] → [assistant+toolResult turns...] → [text-only assistant].
|
|
31
|
+
* Synthetic chain messages (injected by chain-range-prune) are treated as passthroughs —
|
|
32
|
+
* not chain starts. This is defensive; the detector normally runs pre-compression.
|
|
33
|
+
*
|
|
34
|
+
* NOTE: Message identity uses `timestamp` (for user / final text-only assistant) and
|
|
35
|
+
* `toolCallId` sets (for middle tool-using turns). AgentMessage has no `.id` field.
|
|
36
|
+
*
|
|
37
|
+
* @param isProtected Predicate over (toolName, args); matching calls are never pruned
|
|
38
|
+
* and their outputs are relocated verbatim into compressed chains.
|
|
39
|
+
*/
|
|
40
|
+
export function detectChains(
|
|
41
|
+
messages: any[],
|
|
42
|
+
isProtected: (toolName: string, args: unknown) => boolean = () => false,
|
|
43
|
+
): ChainRange[] {
|
|
44
|
+
const ranges: ChainRange[] = [];
|
|
45
|
+
let state: State = "idle";
|
|
46
|
+
let chainStart: { timestamp: number } | null = null;
|
|
47
|
+
let middleIds = new Set<string>();
|
|
48
|
+
let protectedIds = new Set<string>();
|
|
49
|
+
|
|
50
|
+
const emitInterrupted = () => {
|
|
51
|
+
if (state === "inChain" && chainStart) {
|
|
52
|
+
ranges.push({
|
|
53
|
+
startUserTimestamp: chainStart.timestamp,
|
|
54
|
+
middleToolCallIds: [...middleIds],
|
|
55
|
+
protectedToolCallIds: [...protectedIds],
|
|
56
|
+
finalAssistantTimestamp: null,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (const msg of messages) {
|
|
62
|
+
if (msg.role === "user") {
|
|
63
|
+
if (isSyntheticChainMessage(msg)) continue; // passthrough — not a chain start
|
|
64
|
+
emitInterrupted();
|
|
65
|
+
chainStart = { timestamp: msg.timestamp };
|
|
66
|
+
middleIds = new Set();
|
|
67
|
+
protectedIds = new Set();
|
|
68
|
+
state = "inChain";
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (state !== "inChain") continue;
|
|
73
|
+
|
|
74
|
+
if (msg.role === "assistant" && hasToolCalls(msg)) {
|
|
75
|
+
for (const { id, name, args } of collectToolCalls(msg)) {
|
|
76
|
+
middleIds.add(id);
|
|
77
|
+
if (isProtected(name, args)) protectedIds.add(id);
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (msg.role === "toolResult") {
|
|
83
|
+
if (msg.toolCallId) {
|
|
84
|
+
middleIds.add(msg.toolCallId);
|
|
85
|
+
// toolResult fallback — results carry no args; name-only by design,
|
|
86
|
+
// the assistant block always precedes its result so no protection is lost
|
|
87
|
+
if (isProtected(msg.toolName, undefined)) protectedIds.add(msg.toolCallId);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (msg.role === "assistant" && !hasToolCalls(msg)) {
|
|
93
|
+
ranges.push({
|
|
94
|
+
startUserTimestamp: chainStart!.timestamp,
|
|
95
|
+
middleToolCallIds: [...middleIds],
|
|
96
|
+
protectedToolCallIds: [...protectedIds],
|
|
97
|
+
finalAssistantTimestamp: msg.timestamp,
|
|
98
|
+
});
|
|
99
|
+
chainStart = null;
|
|
100
|
+
middleIds = new Set();
|
|
101
|
+
protectedIds = new Set();
|
|
102
|
+
state = "idle";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Open chain at end of input is intentionally dropped (in-flight).
|
|
107
|
+
|
|
108
|
+
return ranges;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Appends `closing` to a copy of `branchMessages` unless the array already ends with it.
|
|
113
|
+
*
|
|
114
|
+
* pi emits `message_end` to extensions BEFORE persisting the message to the session
|
|
115
|
+
* (agent-session.js `_processAgentEvent` runs `_emitExtensionEvent` ahead of
|
|
116
|
+
* `sessionManager.appendMessage`). At the agent-message flush boundary the just-closed
|
|
117
|
+
* final assistant is therefore still missing from `getBranch()`; without threading it
|
|
118
|
+
* in, the newest chain reads as open and the rolling window over-retains by one
|
|
119
|
+
* (effective K+1 instead of K). Identity is role+timestamp (AgentMessage has no id,
|
|
120
|
+
* matching the detector's own identity model), so a future pi that persists before
|
|
121
|
+
* emitting keeps this a no-op.
|
|
122
|
+
*/
|
|
123
|
+
export function withClosingMessage(branchMessages: any[], closing: any): any[] {
|
|
124
|
+
if (!closing) return branchMessages;
|
|
125
|
+
const last = branchMessages[branchMessages.length - 1];
|
|
126
|
+
if (last && last.role === closing.role && last.timestamp === closing.timestamp) return branchMessages;
|
|
127
|
+
return [...branchMessages, closing];
|
|
128
|
+
}
|