@monotykamary/pi-vcc 0.4.0 → 0.5.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/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/core/brief.ts +245 -1
- package/src/core/causal-keys.ts +56 -0
- package/src/core/summarize.ts +53 -3
- package/__tests__/helpers.ts +0 -135
- package/__tests__/root-causes.test.ts +0 -858
- package/demo.gif +0 -0
- package/knip.json +0 -4
- package/scripts/audit-sessions.ts +0 -88
- package/scripts/benchmark-real-sessions.ts +0 -25
- package/scripts/compare-before-after.ts +0 -36
- package/scripts/dump-branch-output.ts +0 -20
- package/stress-debug.ts +0 -202
- package/stress-degradation-curve.ts +0 -251
- package/tests/australian-government.test.ts +0 -479
- package/tests/before-compact-hook.test.ts +0 -185
- package/tests/before-compact.test.ts +0 -227
- package/tests/breadcrumbs.test.ts +0 -312
- package/tests/brief.test.ts +0 -215
- package/tests/build-sections.test.ts +0 -59
- package/tests/compile.test.ts +0 -89
- package/tests/content.test.ts +0 -66
- package/tests/deep-extraction.test.ts +0 -322
- package/tests/extract-goals.test.ts +0 -86
- package/tests/extract-preferences.test.ts +0 -30
- package/tests/filter-noise.test.ts +0 -61
- package/tests/fixtures.ts +0 -61
- package/tests/format-recall.test.ts +0 -87
- package/tests/format.test.ts +0 -73
- package/tests/government-domain.test.ts +0 -542
- package/tests/lineage.test.ts +0 -33
- package/tests/load-messages.test.ts +0 -49
- package/tests/model-threshold-hook.test.ts +0 -311
- package/tests/model-threshold.test.ts +0 -223
- package/tests/normalize.test.ts +0 -104
- package/tests/proactive-threshold.test.ts +0 -490
- package/tests/real-sessions.test.ts +0 -38
- package/tests/recall-expand.test.ts +0 -15
- package/tests/recall-scope.test.ts +0 -32
- package/tests/recall-tool-scope.test.ts +0 -67
- package/tests/render-entries.test.ts +0 -92
- package/tests/report.test.ts +0 -58
- package/tests/sanitize.test.ts +0 -24
- package/tests/search-entries.test.ts +0 -233
- package/tests/stress-100-compactions.test.ts +0 -373
- package/tests/support/load-session.ts +0 -23
- package/tests/support/real-sessions.ts +0 -51
- package/tests/threshold-integration.test.ts +0 -335
|
@@ -1,858 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Litmus tests for "Cannot continue from message role: assistant" root causes.
|
|
3
|
-
*
|
|
4
|
-
* These tests verify the EXACT conditions under which the agent's
|
|
5
|
-
* continue() would throw, and confirm our fixes prevent it.
|
|
6
|
-
*
|
|
7
|
-
* Root cause taxonomy:
|
|
8
|
-
*
|
|
9
|
-
* RC1: After compaction, agent.state.messages has an assistant message
|
|
10
|
-
* as the last entry. The session's continue() call in the _runAgentPrompt
|
|
11
|
-
* while loop (or pre-prompt check) would throw.
|
|
12
|
-
*
|
|
13
|
-
* RC2: After overflow compaction with willRetry=true, the error message
|
|
14
|
-
* removal in _runAutoCompaction checks lastMsg.stopReason === "error",
|
|
15
|
-
* but pi-vcc's buildOwnCut may produce a kept tail whose last message
|
|
16
|
-
* is NOT an error — it's a different assistant message (e.g., stopReason:
|
|
17
|
-
* "stop" or "toolUse"). The removal doesn't trigger, continue() sees
|
|
18
|
-
* an assistant last message, throws.
|
|
19
|
-
*
|
|
20
|
-
* RC3: After threshold compaction, the session's _handlePostAgentRun while
|
|
21
|
-
* loop exits (returns false). But triggerInvisibleContinue() fires
|
|
22
|
-
* prompt([]) which starts a new agent run. If the session then tries
|
|
23
|
-
* to call continue() or prompt() (e.g., pre-prompt check or retry),
|
|
24
|
-
* it gets "Agent is already processing".
|
|
25
|
-
*
|
|
26
|
-
* RC4: Both pi-retry and pi-vcc fire triggerInvisibleContinue() for the
|
|
27
|
-
* same compaction event. Their separate _continueInProgress mutexes
|
|
28
|
-
* don't cross-gate, so both attempt prompt([]). The second one
|
|
29
|
-
* gets "Agent is already processing" (caught, but wasteful).
|
|
30
|
-
* More critically: if the session's continue() wrapper is waiting on
|
|
31
|
-
* pi-vcc's mutex while pi-vcc's prompt([]) is running, the continue()
|
|
32
|
-
* eventually resumes and calls the original continue() which throws
|
|
33
|
-
* because the last message after prompt([]) is an assistant message.
|
|
34
|
-
*
|
|
35
|
-
* RC5: The stopReason values in pi-ai are "stop" | "toolUse" | "length" |
|
|
36
|
-
* "error" | "aborted" — NOT "end_turn" / "tool_use" (Claude API names).
|
|
37
|
-
* Using the wrong string in guards means the gate never matches and
|
|
38
|
-
* triggerInvisibleContinue fires on every compaction (including clean
|
|
39
|
-
* ends).
|
|
40
|
-
*
|
|
41
|
-
* RC6: Manual /compact should NOT auto-continue. After compaction, the user
|
|
42
|
-
* chose to compact. The agent may be idle. triggerInvisibleContinue
|
|
43
|
-
* would restart the agent loop against the user's intent.
|
|
44
|
-
*/
|
|
45
|
-
|
|
46
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
47
|
-
import {
|
|
48
|
-
buildOwnCut,
|
|
49
|
-
} from "../src/hooks/before-compact";
|
|
50
|
-
import {
|
|
51
|
-
type MockEntry,
|
|
52
|
-
makeUserEntry,
|
|
53
|
-
makeAssistantEntry,
|
|
54
|
-
makeToolResultEntry,
|
|
55
|
-
makeCompactionEntry,
|
|
56
|
-
resetIds,
|
|
57
|
-
type ContextMessage,
|
|
58
|
-
userMsg,
|
|
59
|
-
assistantMsg,
|
|
60
|
-
toolResultMsg,
|
|
61
|
-
compactionSummaryMsg,
|
|
62
|
-
makeContextMessages,
|
|
63
|
-
} from "./helpers";
|
|
64
|
-
|
|
65
|
-
// ---------------------------------------------------------------------------
|
|
66
|
-
// buildOwnCut — the compaction boundary logic
|
|
67
|
-
// ---------------------------------------------------------------------------
|
|
68
|
-
|
|
69
|
-
describe("buildOwnCut", () => {
|
|
70
|
-
beforeEach(resetIds);
|
|
71
|
-
|
|
72
|
-
it("keeps a tail from the last user message", () => {
|
|
73
|
-
const entries = [
|
|
74
|
-
makeUserEntry("u1"),
|
|
75
|
-
makeAssistantEntry("a1", "stop"),
|
|
76
|
-
makeUserEntry("u2"),
|
|
77
|
-
makeAssistantEntry("a2", "stop"),
|
|
78
|
-
];
|
|
79
|
-
const result = buildOwnCut(entries);
|
|
80
|
-
expect(result.ok).toBe(true);
|
|
81
|
-
if (!result.ok) return;
|
|
82
|
-
// Summarizes u1 + a1, keeps u2 + a2
|
|
83
|
-
expect(result.messages).toHaveLength(2); // u1 + a1
|
|
84
|
-
expect(result.firstKeptEntryId).toBe(entries[2].id);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("mid-cycle cut when only one user message (3+ live messages)", () => {
|
|
88
|
-
const entries = [
|
|
89
|
-
makeUserEntry("u1"),
|
|
90
|
-
makeAssistantEntry("a1", "stop"),
|
|
91
|
-
makeAssistantEntry("a1b", "stop"),
|
|
92
|
-
];
|
|
93
|
-
const result = buildOwnCut(entries);
|
|
94
|
-
expect(result.ok).toBe(true);
|
|
95
|
-
if (!result.ok) return;
|
|
96
|
-
// Single user at idx 0 → mid-cycle boundary instead of compact-all
|
|
97
|
-
// But there are no toolResult messages, so no completed cycles → still compact-all
|
|
98
|
-
expect(result.compactAll).toBe(true);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("cancels when no live messages", () => {
|
|
102
|
-
const result = buildOwnCut([]);
|
|
103
|
-
expect(result.ok).toBe(false);
|
|
104
|
-
if (result.ok) return;
|
|
105
|
-
expect(result.reason).toBe("no_live_messages");
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("cancels when too few live messages", () => {
|
|
109
|
-
const entries = [makeUserEntry("u1")];
|
|
110
|
-
const result = buildOwnCut(entries);
|
|
111
|
-
expect(result.ok).toBe(false);
|
|
112
|
-
if (result.ok) return;
|
|
113
|
-
expect(result.reason).toBe("too_few_live_messages");
|
|
114
|
-
});
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// ---------------------------------------------------------------------------
|
|
118
|
-
// RC1: Compaction result produces assistant last message in context
|
|
119
|
-
// ---------------------------------------------------------------------------
|
|
120
|
-
|
|
121
|
-
describe("RC1: post-compaction context last message", () => {
|
|
122
|
-
/**
|
|
123
|
-
* After buildOwnCut compacts [u1, a1, u2, a2(stop)], the kept tail is
|
|
124
|
-
* [u2, a2]. After rebuild, the context is [compactionSummary, u2, a2].
|
|
125
|
-
* The LAST message is an assistant message.
|
|
126
|
-
*
|
|
127
|
-
* If continue() is called at this point, it throws.
|
|
128
|
-
*/
|
|
129
|
-
it("kept tail ending with assistant message makes continue() impossible", () => {
|
|
130
|
-
const context = makeContextMessages(
|
|
131
|
-
compactionSummaryMsg("summary"),
|
|
132
|
-
userMsg("u2"),
|
|
133
|
-
assistantMsg("a2", "stop"),
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
const lastMsg = context[context.length - 1];
|
|
137
|
-
expect(lastMsg.role).toBe("assistant");
|
|
138
|
-
// This IS the condition that triggers "Cannot continue from message role: assistant"
|
|
139
|
-
// Fix: the session should strip or not call continue() in this case.
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* After buildOwnCut compacts [u1, a1(toolUse), toolResult, a2(stop)],
|
|
144
|
-
* the kept tail starts from u1 (or wherever the cut boundary is).
|
|
145
|
-
* If tail ends with assistant, same problem.
|
|
146
|
-
*/
|
|
147
|
-
it("kept tail with toolUse assistant as last message is also blocked", () => {
|
|
148
|
-
const context = makeContextMessages(
|
|
149
|
-
compactionSummaryMsg("summary"),
|
|
150
|
-
userMsg("u2"),
|
|
151
|
-
assistantMsg("a2", "toolUse"),
|
|
152
|
-
);
|
|
153
|
-
|
|
154
|
-
const lastMsg = context[context.length - 1];
|
|
155
|
-
expect(lastMsg.role).toBe("assistant");
|
|
156
|
-
expect(lastMsg.stopReason).toBe("toolUse");
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* The ONLY case where continue() works after compaction is when the
|
|
161
|
-
* kept tail ends with a user or toolResult message.
|
|
162
|
-
*/
|
|
163
|
-
it("kept tail ending with user message allows continue()", () => {
|
|
164
|
-
const context = makeContextMessages(
|
|
165
|
-
compactionSummaryMsg("summary"),
|
|
166
|
-
userMsg("u2"),
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
const lastMsg = context[context.length - 1];
|
|
170
|
-
expect(lastMsg.role).toBe("user");
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it("kept tail ending with toolResult allows continue()", () => {
|
|
174
|
-
const context = makeContextMessages(
|
|
175
|
-
compactionSummaryMsg("summary"),
|
|
176
|
-
userMsg("u2"),
|
|
177
|
-
assistantMsg("a2", "toolUse"),
|
|
178
|
-
toolResultMsg("tc1", "result"),
|
|
179
|
-
);
|
|
180
|
-
|
|
181
|
-
const lastMsg = context[context.length - 1];
|
|
182
|
-
expect(lastMsg.role).toBe("toolResult");
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Compact-all (firstKeptEntryId="") produces context with just
|
|
187
|
-
* compactionSummary — which is role "user". continue() works.
|
|
188
|
-
*/
|
|
189
|
-
it("compact-all produces user-only context", () => {
|
|
190
|
-
const context = makeContextMessages(
|
|
191
|
-
compactionSummaryMsg("summary"),
|
|
192
|
-
);
|
|
193
|
-
|
|
194
|
-
const lastMsg = context[context.length - 1];
|
|
195
|
-
expect(lastMsg.role).toBe("user");
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
// ---------------------------------------------------------------------------
|
|
200
|
-
// RC2: Overflow compaction error removal mismatch
|
|
201
|
-
// ---------------------------------------------------------------------------
|
|
202
|
-
|
|
203
|
-
describe("RC2: overflow compaction error removal", () => {
|
|
204
|
-
/**
|
|
205
|
-
* After overflow compaction, _runAutoCompaction checks:
|
|
206
|
-
* if (lastMsg?.role === "assistant" && lastMsg.stopReason === "error")
|
|
207
|
-
* this.agent.state.messages = messages.slice(0, -1);
|
|
208
|
-
*
|
|
209
|
-
* This ONLY removes the last message if stopReason is "error".
|
|
210
|
-
* If the last message is a different assistant (e.g., stopReason "stop"
|
|
211
|
-
* or "toolUse"), it stays. continue() would throw.
|
|
212
|
-
*
|
|
213
|
-
* Scenario: Session has [u1, a1(stop), u2, a2(error)].
|
|
214
|
-
* pi-vcc cuts at u2 → kept tail: [u2, a2(error)].
|
|
215
|
-
* The error message IS the last → removal works → continue() OK.
|
|
216
|
-
*
|
|
217
|
-
* But what if: Session has [u1, a1(error), u2, a2(stop)].
|
|
218
|
-
* pi-vcc cuts at u2 → kept tail: [u2, a2(stop)].
|
|
219
|
-
* The last message is a2(stop), NOT an error → NO removal → continue() THROWS.
|
|
220
|
-
*/
|
|
221
|
-
it("error removal only triggers for stopReason=error at tail", () => {
|
|
222
|
-
// Simulated context after compaction with assistant(stop) as last
|
|
223
|
-
const context = makeContextMessages(
|
|
224
|
-
compactionSummaryMsg("summary"),
|
|
225
|
-
userMsg("u2"),
|
|
226
|
-
assistantMsg("a2", "stop"), // NOT "error"
|
|
227
|
-
);
|
|
228
|
-
|
|
229
|
-
const lastMsg = context[context.length - 1];
|
|
230
|
-
const wouldRemove =
|
|
231
|
-
lastMsg.role === "assistant" && lastMsg.stopReason === "error";
|
|
232
|
-
expect(wouldRemove).toBe(false); // Removal doesn't fire!
|
|
233
|
-
// continue() would throw "Cannot continue from message role: assistant"
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
it("error removal works when error IS the last message", () => {
|
|
237
|
-
const context = makeContextMessages(
|
|
238
|
-
compactionSummaryMsg("summary"),
|
|
239
|
-
userMsg("u2"),
|
|
240
|
-
assistantMsg("a2", "error"),
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
const lastMsg = context[context.length - 1];
|
|
244
|
-
const wouldRemove =
|
|
245
|
-
lastMsg.role === "assistant" && lastMsg.stopReason === "error";
|
|
246
|
-
expect(wouldRemove).toBe(true); // Removal fires → after removal, last is user
|
|
247
|
-
});
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
// ---------------------------------------------------------------------------
|
|
251
|
-
// RC5: stopReason value mapping
|
|
252
|
-
// ---------------------------------------------------------------------------
|
|
253
|
-
|
|
254
|
-
describe("RC5: stopReason string values", () => {
|
|
255
|
-
/**
|
|
256
|
-
* pi-ai StopReason type: "stop" | "toolUse" | "length" | "error" | "aborted"
|
|
257
|
-
*
|
|
258
|
-
* NOT the Claude API values: "end_turn" / "tool_use" / "max_tokens"
|
|
259
|
-
*
|
|
260
|
-
* Our invisible-continue gate must use the pi-ai values:
|
|
261
|
-
*/
|
|
262
|
-
const PI_AI_STOP_REASONS = ["stop", "toolUse", "length", "error", "aborted"];
|
|
263
|
-
const WRONG_VALUES = ["end_turn", "tool_use", "max_tokens"];
|
|
264
|
-
|
|
265
|
-
it("pi-ai stopReason values are lowercase-first camelCase", () => {
|
|
266
|
-
expect(PI_AI_STOP_REASONS).toContain("stop");
|
|
267
|
-
expect(PI_AI_STOP_REASONS).toContain("toolUse");
|
|
268
|
-
expect(PI_AI_STOP_REASONS).toContain("length");
|
|
269
|
-
expect(PI_AI_STOP_REASONS).toContain("error");
|
|
270
|
-
expect(PI_AI_STOP_REASONS).toContain("aborted");
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
it("Claude API values are NOT valid pi-ai stopReasons", () => {
|
|
274
|
-
for (const wrong of WRONG_VALUES) {
|
|
275
|
-
expect(PI_AI_STOP_REASONS).not.toContain(wrong);
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* The invisible continue decision table with CORRECT values:
|
|
281
|
-
*
|
|
282
|
-
* stopReason | Continuation needed?
|
|
283
|
-
* -----------|---------------------
|
|
284
|
-
* stop | NO (agent finished cleanly)
|
|
285
|
-
* toolUse | YES (mid-tool cycle, compacted mid-task)
|
|
286
|
-
* length | YES (hit max tokens, output truncated)
|
|
287
|
-
* error | YES (API error, but pi-retry may handle)
|
|
288
|
-
* aborted | NO (user cancelled)
|
|
289
|
-
*/
|
|
290
|
-
it("correct invisible-continue decision table", () => {
|
|
291
|
-
const decisions: Record<string, boolean> = {
|
|
292
|
-
stop: false,
|
|
293
|
-
toolUse: true,
|
|
294
|
-
length: true,
|
|
295
|
-
error: true,
|
|
296
|
-
aborted: false,
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
// Verify the decisions match our intent
|
|
300
|
-
expect(decisions["stop"]).toBe(false);
|
|
301
|
-
expect(decisions["toolUse"]).toBe(true);
|
|
302
|
-
expect(decisions["length"]).toBe(true);
|
|
303
|
-
expect(decisions["error"]).toBe(true);
|
|
304
|
-
expect(decisions["aborted"]).toBe(false);
|
|
305
|
-
});
|
|
306
|
-
|
|
307
|
-
it("using 'end_turn' would NEVER match — always continues", () => {
|
|
308
|
-
const stopReasons = ["stop", "toolUse", "length", "error", "aborted"];
|
|
309
|
-
const matchesEndTurn = stopReasons.some((sr) => sr === "end_turn");
|
|
310
|
-
expect(matchesEndTurn).toBe(false);
|
|
311
|
-
// This was the original bug: checking === "end_turn" always fails,
|
|
312
|
-
// so triggerInvisibleContinue always fires.
|
|
313
|
-
});
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
// ---------------------------------------------------------------------------
|
|
317
|
-
// RC6: Manual /compact should NOT auto-continue
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
|
|
320
|
-
describe("RC6: manual compact should not auto-continue", () => {
|
|
321
|
-
/**
|
|
322
|
-
* When the user runs /compact explicitly, they chose to compact.
|
|
323
|
-
* After compaction, the agent should NOT auto-continue.
|
|
324
|
-
*
|
|
325
|
-
* The session_compact event doesn't distinguish manual vs auto.
|
|
326
|
-
* We need to track whether the compaction was user-initiated.
|
|
327
|
-
*
|
|
328
|
-
* Currently, lastCompactWasPiVcc tracks /pi-vcc command, but
|
|
329
|
-
* /compact is NOT tracked separately.
|
|
330
|
-
*/
|
|
331
|
-
it("session_compact event does not carry reason (manual/overflow/threshold)", () => {
|
|
332
|
-
// The event payload is: { compactionEntry, fromExtension }
|
|
333
|
-
// No "reason" field — we can't distinguish manual from auto
|
|
334
|
-
const eventShape = {
|
|
335
|
-
type: "session_compact" as const,
|
|
336
|
-
compactionEntry: {} as any,
|
|
337
|
-
fromExtension: false,
|
|
338
|
-
};
|
|
339
|
-
expect(
|
|
340
|
-
Object.keys(eventShape).includes("reason"),
|
|
341
|
-
).toBe(false);
|
|
342
|
-
});
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* For manual /compact, the agent is always idle before compaction.
|
|
346
|
-
* The last assistant message always has stopReason !== "toolUse"
|
|
347
|
-
* (because if the agent were mid-task, it would be running).
|
|
348
|
-
*
|
|
349
|
-
* With our fix that checks stopReason === "stop" → no continue,
|
|
350
|
-
* manual /compact AFTER a clean finish correctly does NOT continue.
|
|
351
|
-
*/
|
|
352
|
-
it("after user-initiated /compact, last stopReason is typically 'stop'", () => {
|
|
353
|
-
const lastAssistant = assistantMsg("done", "stop");
|
|
354
|
-
const shouldContinue = lastAssistant.stopReason !== "stop"
|
|
355
|
-
&& lastAssistant.stopReason !== "aborted";
|
|
356
|
-
expect(shouldContinue).toBe(false);
|
|
357
|
-
});
|
|
358
|
-
});
|
|
359
|
-
|
|
360
|
-
// ---------------------------------------------------------------------------
|
|
361
|
-
// The comprehensive fix: strip trailing assistant from rebuilt context
|
|
362
|
-
// ---------------------------------------------------------------------------
|
|
363
|
-
|
|
364
|
-
describe("proposed fix: strip trailing assistant messages after compaction", () => {
|
|
365
|
-
/**
|
|
366
|
-
* The root fix is to ensure that after compaction, agent.state.messages
|
|
367
|
-
* NEVER ends with an assistant message. This means:
|
|
368
|
-
*
|
|
369
|
-
* 1. After rebuildSessionContext, check the tail.
|
|
370
|
-
* 2. If the last message is an assistant that was NOT in-progress
|
|
371
|
-
* (stopReason: "stop", "error", "aborted"), remove it from context.
|
|
372
|
-
* The assistant message is still in the session log — it's not lost.
|
|
373
|
-
* But the rebuilt context used for the next LLM call should end
|
|
374
|
-
* with a user/toolResult message so continue() works.
|
|
375
|
-
*
|
|
376
|
-
* 3. If the last assistant has stopReason "toolUse" or "length",
|
|
377
|
-
* the agent was mid-task. DON'T strip — triggerInvisibleContinue
|
|
378
|
-
* will handle resumption via prompt([]).
|
|
379
|
-
*
|
|
380
|
-
* Actually, even for toolUse/length, stripping and relying on
|
|
381
|
-
* triggerInvisibleContinue is cleaner. prompt([]) doesn't care
|
|
382
|
-
* about the last message role — it just sends the current context.
|
|
383
|
-
* And after stripping, continue() would also work if needed.
|
|
384
|
-
*
|
|
385
|
-
* But stripping removes context! The LLM wouldn't see the last
|
|
386
|
-
* assistant response. This could lose information about what the
|
|
387
|
-
* agent was doing.
|
|
388
|
-
*
|
|
389
|
-
* Alternative: instead of stripping, ensure that whenever we would
|
|
390
|
-
* call continue() after compaction, we call prompt([]) instead.
|
|
391
|
-
* This is essentially what our triggerInvisibleContinue does.
|
|
392
|
-
*
|
|
393
|
-
* The REAL fix is to not catch-and-swallow, but to PREVENT the
|
|
394
|
-
* continue() call entirely when the context is in an invalid state.
|
|
395
|
-
* This means: our monkey-patched continue() should detect the
|
|
396
|
-
* assistant-last-message case and call prompt([]) INSTEAD of
|
|
397
|
-
* throwing (or just returning void).
|
|
398
|
-
*/
|
|
399
|
-
|
|
400
|
-
it("monkey-patched continue() should convert to prompt([]) for assistant last message", () => {
|
|
401
|
-
const context = makeContextMessages(
|
|
402
|
-
compactionSummaryMsg("summary"),
|
|
403
|
-
userMsg("u2"),
|
|
404
|
-
assistantMsg("a2", "stop"),
|
|
405
|
-
);
|
|
406
|
-
|
|
407
|
-
const lastMsg = context[context.length - 1];
|
|
408
|
-
|
|
409
|
-
// Instead of throwing "Cannot continue from message role: assistant",
|
|
410
|
-
// the patched continue() should detect this and call prompt([]).
|
|
411
|
-
if (lastMsg.role === "assistant") {
|
|
412
|
-
// The fix: instead of throwing, we call prompt([]) which works
|
|
413
|
-
// with any last message role.
|
|
414
|
-
const shouldConvertToPrompt = lastMsg.role === "assistant";
|
|
415
|
-
expect(shouldConvertToPrompt).toBe(true);
|
|
416
|
-
}
|
|
417
|
-
});
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
// ---------------------------------------------------------------------------
|
|
421
|
-
// buildOwnCut — context ending positions
|
|
422
|
-
// ---------------------------------------------------------------------------
|
|
423
|
-
|
|
424
|
-
describe("buildOwnCut tail ending analysis", () => {
|
|
425
|
-
beforeEach(resetIds);
|
|
426
|
-
|
|
427
|
-
/**
|
|
428
|
-
* CRITICAL INSIGHT: buildOwnCut always produces a kept tail that
|
|
429
|
-
* starts from a user message. The tail includes everything from
|
|
430
|
-
* that user message to the end. If the session had a normal
|
|
431
|
-
* user→assistant→user→assistant pattern, the tail ends with
|
|
432
|
-
* an assistant message.
|
|
433
|
-
*
|
|
434
|
-
* This means: for any non-trivial compaction, the rebuilt context
|
|
435
|
-
* will ALWAYS end with an assistant message (unless the tail ends
|
|
436
|
-
* with toolResult).
|
|
437
|
-
*/
|
|
438
|
-
|
|
439
|
-
it("typical conversation tail ends with assistant (stop)", () => {
|
|
440
|
-
const entries = [
|
|
441
|
-
makeUserEntry("task"),
|
|
442
|
-
makeAssistantEntry("working...", "stop"),
|
|
443
|
-
makeUserEntry("continue"),
|
|
444
|
-
makeAssistantEntry("done", "stop"),
|
|
445
|
-
];
|
|
446
|
-
const result = buildOwnCut(entries);
|
|
447
|
-
expect(result.ok).toBe(true);
|
|
448
|
-
if (!result.ok) return;
|
|
449
|
-
|
|
450
|
-
// Kept tail starts from entries[2] (user "continue")
|
|
451
|
-
const firstKeptIdx = entries.findIndex(e => e.id === result.firstKeptEntryId);
|
|
452
|
-
expect(firstKeptIdx).toBe(2);
|
|
453
|
-
|
|
454
|
-
// The tail is entries[2:] = [user, assistant(stop)]
|
|
455
|
-
// So rebuilt context = [compactionSummary, user, assistant]
|
|
456
|
-
// Last message: assistant → continue() THROWS
|
|
457
|
-
const tail = entries.slice(firstKeptIdx);
|
|
458
|
-
const lastEntry = tail[tail.length - 1];
|
|
459
|
-
expect(lastEntry.message?.role).toBe("assistant");
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
it("tool-result ending is safe", () => {
|
|
463
|
-
const entries = [
|
|
464
|
-
makeUserEntry("task"),
|
|
465
|
-
makeAssistantEntry("working...", "toolUse"),
|
|
466
|
-
makeToolResultEntry("tc1", "result"),
|
|
467
|
-
];
|
|
468
|
-
const result = buildOwnCut(entries);
|
|
469
|
-
expect(result.ok).toBe(true);
|
|
470
|
-
if (!result.ok) return;
|
|
471
|
-
|
|
472
|
-
// Single user + tool chain → compact-all (firstKeptEntryId = "")
|
|
473
|
-
if (result.compactAll) {
|
|
474
|
-
expect(result.firstKeptEntryId).toBe("");
|
|
475
|
-
// compact-all context = [compactionSummary] → continue() OK
|
|
476
|
-
}
|
|
477
|
-
});
|
|
478
|
-
|
|
479
|
-
it("conversation with tool calls: tail often ends with assistant", () => {
|
|
480
|
-
const entries = [
|
|
481
|
-
makeUserEntry("task"),
|
|
482
|
-
makeAssistantEntry("let me check", "toolUse"),
|
|
483
|
-
makeToolResultEntry("tc1", "found it"),
|
|
484
|
-
makeAssistantEntry("here's the answer", "stop"),
|
|
485
|
-
makeUserEntry("thanks"),
|
|
486
|
-
makeAssistantEntry("you're welcome", "stop"),
|
|
487
|
-
];
|
|
488
|
-
const result = buildOwnCut(entries);
|
|
489
|
-
expect(result.ok).toBe(true);
|
|
490
|
-
if (!result.ok) return;
|
|
491
|
-
|
|
492
|
-
// Cut at last user message: entries[4] (user "thanks")
|
|
493
|
-
const firstKeptIdx = entries.findIndex(e => e.id === result.firstKeptEntryId);
|
|
494
|
-
const tail = entries.slice(firstKeptIdx);
|
|
495
|
-
const lastEntry = tail[tail.length - 1];
|
|
496
|
-
expect(lastEntry.message?.role).toBe("assistant");
|
|
497
|
-
// This tail ends with assistant → continue() throws
|
|
498
|
-
});
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* THE FUNDAMENTAL ISSUE: buildOwnCut's task-boundary logic keeps
|
|
502
|
-
* the tail from last user message onwards. This tail almost always
|
|
503
|
-
* ends with an assistant message (the response to that user message).
|
|
504
|
-
*
|
|
505
|
-
* After compaction, the rebuilt context includes this tail,
|
|
506
|
-
* so agent.state.messages ends with an assistant message.
|
|
507
|
-
*
|
|
508
|
-
* This means ANY call to continue() after compaction WILL throw
|
|
509
|
-
* "Cannot continue from message role: assistant" — unless we
|
|
510
|
-
* either:
|
|
511
|
-
* (a) Strip the trailing assistant message(s) from the context
|
|
512
|
-
* (b) Replace continue() calls with prompt([]) calls
|
|
513
|
-
* (c) Have the monkey-patched continue() fall back to prompt([])
|
|
514
|
-
* when it detects an assistant last message
|
|
515
|
-
*/
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
// ---------------------------------------------------------------------------
|
|
519
|
-
// Monkey-patch behavior: current vs proposed
|
|
520
|
-
// ---------------------------------------------------------------------------
|
|
521
|
-
|
|
522
|
-
describe("continue() monkey-patch: catch vs convert", () => {
|
|
523
|
-
/**
|
|
524
|
-
* PREVIOUS BEHAVIOR (all three extensions):
|
|
525
|
-
* continue() catches "Cannot continue from message role: assistant"
|
|
526
|
-
* and swallows the error (returns void).
|
|
527
|
-
* PROBLEM: the agent doesn't actually continue. The while loop
|
|
528
|
-
* exits. The task is left incomplete.
|
|
529
|
-
*
|
|
530
|
-
* NEW BEHAVIOR (pi-vcc):
|
|
531
|
-
* continue() catches the error and FALLS BACK to prompt([])
|
|
532
|
-
* when the last assistant stopReason is NOT "stop" or "aborted".
|
|
533
|
-
* This actually starts the agent loop, so the agent continues
|
|
534
|
-
* processing. The while loop in _runAgentPrompt gets another
|
|
535
|
-
* iteration because prompt([]) runs the agent (which emits
|
|
536
|
-
* agent_end, which sets _lastAssistantMessage).
|
|
537
|
-
*/
|
|
538
|
-
|
|
539
|
-
it("swallowing the error results in dead agent loop (old behavior)", () => {
|
|
540
|
-
const context = makeContextMessages(
|
|
541
|
-
compactionSummaryMsg("summary"),
|
|
542
|
-
userMsg("u2"),
|
|
543
|
-
assistantMsg("a2", "stop"),
|
|
544
|
-
);
|
|
545
|
-
|
|
546
|
-
// After compaction, _handlePostAgentRun returns false.
|
|
547
|
-
// The while loop exits. No continue() is called.
|
|
548
|
-
// BUT: if it WERE called, the OLD monkey-patch would swallow the error.
|
|
549
|
-
// The agent loop exits without continuing.
|
|
550
|
-
const lastMsg = context[context.length - 1];
|
|
551
|
-
const wouldSwallow = lastMsg.role === "assistant";
|
|
552
|
-
expect(wouldSwallow).toBe(true);
|
|
553
|
-
// Result: agent stops. Task incomplete if mid-tool-cycle.
|
|
554
|
-
});
|
|
555
|
-
|
|
556
|
-
/**
|
|
557
|
-
* The fix: instead of swallowing, convert continue() → prompt([])
|
|
558
|
-
* when the last assistant was mid-task.
|
|
559
|
-
*/
|
|
560
|
-
it("new: continue() falls back to prompt([]) for toolUse tail", () => {
|
|
561
|
-
const context = makeContextMessages(
|
|
562
|
-
compactionSummaryMsg("summary"),
|
|
563
|
-
userMsg("u2"),
|
|
564
|
-
assistantMsg("a2", "toolUse"),
|
|
565
|
-
);
|
|
566
|
-
|
|
567
|
-
const lastMsg = context[context.length - 1];
|
|
568
|
-
const shouldConvertToPrompt = lastMsg.role === "assistant"
|
|
569
|
-
&& lastMsg.stopReason !== "stop"
|
|
570
|
-
&& lastMsg.stopReason !== "aborted";
|
|
571
|
-
expect(shouldConvertToPrompt).toBe(true);
|
|
572
|
-
// prompt([]) works regardless of last message role.
|
|
573
|
-
// The agent picks up where it left off.
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
it("new: continue() falls back to prompt([]) for length tail", () => {
|
|
577
|
-
const context = makeContextMessages(
|
|
578
|
-
compactionSummaryMsg("summary"),
|
|
579
|
-
userMsg("u2"),
|
|
580
|
-
assistantMsg("a2", "length"),
|
|
581
|
-
);
|
|
582
|
-
|
|
583
|
-
const lastMsg = context[context.length - 1];
|
|
584
|
-
const shouldConvertToPrompt = lastMsg.role === "assistant"
|
|
585
|
-
&& lastMsg.stopReason !== "stop"
|
|
586
|
-
&& lastMsg.stopReason !== "aborted";
|
|
587
|
-
expect(shouldConvertToPrompt).toBe(true);
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
/**
|
|
591
|
-
* For stopReason "stop", we should NOT convert to prompt([]).
|
|
592
|
-
* The agent finished cleanly. prompt([]) would restart it unnecessarily.
|
|
593
|
-
* The continue() return gives void, the while loop exits naturally.
|
|
594
|
-
*/
|
|
595
|
-
it("new: NO fallback for stopReason=stop (agent finished cleanly)", () => {
|
|
596
|
-
const context = makeContextMessages(
|
|
597
|
-
compactionSummaryMsg("summary"),
|
|
598
|
-
userMsg("u2"),
|
|
599
|
-
assistantMsg("a2", "stop"),
|
|
600
|
-
);
|
|
601
|
-
|
|
602
|
-
const lastMsg = context[context.length - 1];
|
|
603
|
-
const shouldConvertToPrompt = lastMsg.role === "assistant"
|
|
604
|
-
&& lastMsg.stopReason !== "stop"
|
|
605
|
-
&& lastMsg.stopReason !== "aborted";
|
|
606
|
-
expect(shouldConvertToPrompt).toBe(false);
|
|
607
|
-
// Don't auto-continue after clean stop.
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
/**
|
|
611
|
-
* For stopReason "aborted" (user cancel), also don't auto-continue.
|
|
612
|
-
*/
|
|
613
|
-
it("new: NO fallback for stopReason=aborted (user cancelled)", () => {
|
|
614
|
-
const context = makeContextMessages(
|
|
615
|
-
compactionSummaryMsg("summary"),
|
|
616
|
-
userMsg("u2"),
|
|
617
|
-
assistantMsg("a2", "aborted"),
|
|
618
|
-
);
|
|
619
|
-
|
|
620
|
-
const lastMsg = context[context.length - 1];
|
|
621
|
-
const shouldConvertToPrompt = lastMsg.role === "assistant"
|
|
622
|
-
&& lastMsg.stopReason !== "stop"
|
|
623
|
-
&& lastMsg.stopReason !== "aborted";
|
|
624
|
-
expect(shouldConvertToPrompt).toBe(false);
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
/**
|
|
628
|
-
* For stopReason "error", don't convert — pi-retry handles error retries.
|
|
629
|
-
* Falling back to prompt([]) here would race pi-retry's own triggerInvisibleContinue.
|
|
630
|
-
*/
|
|
631
|
-
it("new: NO fallback for stopReason=error (pi-retry handles it)", () => {
|
|
632
|
-
const context = makeContextMessages(
|
|
633
|
-
compactionSummaryMsg("summary"),
|
|
634
|
-
userMsg("u2"),
|
|
635
|
-
assistantMsg("a2", "error"),
|
|
636
|
-
);
|
|
637
|
-
|
|
638
|
-
const lastMsg = context[context.length - 1];
|
|
639
|
-
// pi-vcc should NOT fire invisible continue for errors.
|
|
640
|
-
// pi-retry already fires triggerInvisibleContinue from its agent_end handler.
|
|
641
|
-
// Both firing would race prompt([]) → wasted or duplicate runs.
|
|
642
|
-
const piVccShouldFire = lastMsg.stopReason !== "stop"
|
|
643
|
-
&& lastMsg.stopReason !== "aborted"
|
|
644
|
-
&& lastMsg.stopReason !== "error"; // KEY: skip error
|
|
645
|
-
expect(piVccShouldFire).toBe(false);
|
|
646
|
-
});
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
// ---------------------------------------------------------------------------
|
|
650
|
-
// Pre-prompt compaction race condition
|
|
651
|
-
// ---------------------------------------------------------------------------
|
|
652
|
-
|
|
653
|
-
describe("RC3: pre-prompt compaction race with triggerInvisibleContinue", () => {
|
|
654
|
-
/**
|
|
655
|
-
* When the user sends a message and the pre-prompt check triggers
|
|
656
|
-
* compaction, the flow in session.prompt() is:
|
|
657
|
-
*
|
|
658
|
-
* 1. _checkCompaction() → compaction runs
|
|
659
|
-
* 2. session_compact fires → pi-vcc calls triggerInvisibleContinue()
|
|
660
|
-
* 3. _checkCompaction returns false
|
|
661
|
-
* 4. Session builds user message
|
|
662
|
-
* 5. Session calls _runAgentPrompt(messages) → agent.prompt(messages)
|
|
663
|
-
*
|
|
664
|
-
* The triggerInvisibleContinue() from step 2 is fire-and-forget.
|
|
665
|
-
* It awaits waitForIdle() + 50ms sleep, then calls prompt([]).
|
|
666
|
-
*
|
|
667
|
-
* If step 5's agent.prompt() is called BEFORE the 50ms sleep expires,
|
|
668
|
-
* the user message takes priority. triggerInvisibleContinue's prompt([])
|
|
669
|
-
* would fail with "Agent is already processing" (caught, harmlessly).
|
|
670
|
-
*
|
|
671
|
-
* If the 50ms sleep expires BEFORE step 5, triggerInvisibleContinue's
|
|
672
|
-
* prompt([]) starts first. Then step 5's agent.prompt() fails with
|
|
673
|
-
* "Agent is already processing" — and this is NOT caught by our
|
|
674
|
-
* monkey-patch because it's agent.prompt(), not agent.continue().
|
|
675
|
-
*/
|
|
676
|
-
it("agent.prompt() also throws for busy agent, but is NOT monkey-patched", () => {
|
|
677
|
-
// agent.prompt() checks: if (this.activeRun) throw "Agent is already processing a prompt."
|
|
678
|
-
// Our monkey-patches only cover continue(), not prompt().
|
|
679
|
-
// If triggerInvisibleContinue's prompt([]) races the session's prompt(),
|
|
680
|
-
// the session's prompt() throws — and this error IS visible to the user.
|
|
681
|
-
const promptThrowsForBusyAgent = true;
|
|
682
|
-
expect(promptThrowsForBusyAgent).toBe(true);
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
/**
|
|
686
|
-
* Fix: triggerInvisibleContinue should check if the session is about
|
|
687
|
-
* to send a new prompt. If so, skip the invisible continue — the
|
|
688
|
-
* session will handle continuation naturally.
|
|
689
|
-
*
|
|
690
|
-
* More precisely: the session_compact handler should NOT call
|
|
691
|
-
* triggerInvisibleContinue when the compaction was triggered from
|
|
692
|
-
* the pre-prompt check or from manual /compact. Invisible continue
|
|
693
|
-
* is ONLY appropriate for auto-threshold compaction during an
|
|
694
|
-
* active agent run.
|
|
695
|
-
*
|
|
696
|
-
* Since session_compact doesn't carry a "reason" field, we need
|
|
697
|
-
* another way to distinguish. Options:
|
|
698
|
-
*
|
|
699
|
-
* A) Track compaction reason in a module-level variable, set by
|
|
700
|
-
* session_before_compact (which DOES receive customInstructions
|
|
701
|
-
* and other context).
|
|
702
|
-
*
|
|
703
|
-
* B) Only fire invisible continue when _handlePostAgentRun would
|
|
704
|
-
* return false AND the agent was mid-task. This is our current
|
|
705
|
-
* approach (check last assistant stopReason in session_compact).
|
|
706
|
-
*
|
|
707
|
-
* C) Set a flag when the session is about to send a prompt, and
|
|
708
|
-
* check it in triggerInvisibleContinue.
|
|
709
|
-
*/
|
|
710
|
-
it("only auto-threshold during active run needs invisible continue", () => {
|
|
711
|
-
// Scenarios:
|
|
712
|
-
const scenarios = [
|
|
713
|
-
{
|
|
714
|
-
name: "auto-threshold, mid-task (toolUse)",
|
|
715
|
-
shouldContinueInvisibly: true,
|
|
716
|
-
},
|
|
717
|
-
{
|
|
718
|
-
name: "auto-threshold, clean end (stop)",
|
|
719
|
-
shouldContinueInvisibly: false,
|
|
720
|
-
},
|
|
721
|
-
{
|
|
722
|
-
name: "manual /compact",
|
|
723
|
-
shouldContinueInvisibly: false,
|
|
724
|
-
},
|
|
725
|
-
{
|
|
726
|
-
name: "pre-prompt threshold",
|
|
727
|
-
shouldContinueInvisibly: false,
|
|
728
|
-
},
|
|
729
|
-
{
|
|
730
|
-
name: "overflow (willRetry=true)",
|
|
731
|
-
shouldContinueInvisibly: false, // handled by willRetry
|
|
732
|
-
},
|
|
733
|
-
];
|
|
734
|
-
|
|
735
|
-
for (const s of scenarios) {
|
|
736
|
-
expect(typeof s.shouldContinueInvisibly).toBe("boolean");
|
|
737
|
-
}
|
|
738
|
-
});
|
|
739
|
-
});
|
|
740
|
-
|
|
741
|
-
// ---------------------------------------------------------------------------
|
|
742
|
-
// RC4: Dual trigger from pi-retry + pi-vcc
|
|
743
|
-
// ---------------------------------------------------------------------------
|
|
744
|
-
|
|
745
|
-
describe("RC4: both extensions fire for same compaction", () => {
|
|
746
|
-
/**
|
|
747
|
-
* When an overflow error triggers compaction:
|
|
748
|
-
* - pi-retry sees agent_end with stopReason: "error" → fires triggerInvisibleContinue()
|
|
749
|
-
* - pi-vcc sees session_compact with last assistant stopReason: "error"
|
|
750
|
-
*
|
|
751
|
-
* With the fix: pi-vcc now SKIPS invisible continue for stopReason "error",
|
|
752
|
-
* so only pi-retry fires. No duplicate run, no race.
|
|
753
|
-
*/
|
|
754
|
-
it("pi-vcc defers to pi-retry for error retries (session_compact)", () => {
|
|
755
|
-
const lastAssistantStopReason = "error";
|
|
756
|
-
// Fixed: for stopReason "error", pi-vcc's session_compact handler returns early
|
|
757
|
-
// and does NOT call triggerInvisibleContinue
|
|
758
|
-
const piVccShouldFireFromSessionCompact = lastAssistantStopReason !== "stop"
|
|
759
|
-
&& lastAssistantStopReason !== "aborted"
|
|
760
|
-
&& lastAssistantStopReason !== "error"; // KEY: skip error
|
|
761
|
-
expect(piVccShouldFireFromSessionCompact).toBe(false);
|
|
762
|
-
});
|
|
763
|
-
|
|
764
|
-
/**
|
|
765
|
-
* But pi-vcc's continue() monkey-patch STILL needs to handle the case
|
|
766
|
-
* where pi-retry is also installed. The continue() patch chain:
|
|
767
|
-
* pi-vcc → pi-retry → original
|
|
768
|
-
*
|
|
769
|
-
* If pi-retry is driving (its _continueInProgress is true), pi-vcc's
|
|
770
|
-
* patch passes through (its own mutex is false). pi-retry's patch
|
|
771
|
-
* waits on its mutex. This is correct.
|
|
772
|
-
*/
|
|
773
|
-
it("pi-vcc continue() patch chains correctly with pi-retry", () => {
|
|
774
|
-
// The chain is: pi-vcc → pi-retry → original
|
|
775
|
-
// If pi-retry is driving, its mutex blocks, not pi-vcc's
|
|
776
|
-
const piVccMutex = false;
|
|
777
|
-
const piRetryMutex = true;
|
|
778
|
-
// Session's continue() enters pi-vcc's wrapper → mutex not set → passes through
|
|
779
|
-
// Enters pi-retry's wrapper → mutex set → waits
|
|
780
|
-
expect(piVccMutex).toBe(false);
|
|
781
|
-
expect(piRetryMutex).toBe(true);
|
|
782
|
-
});
|
|
783
|
-
});
|
|
784
|
-
|
|
785
|
-
// ---------------------------------------------------------------------------
|
|
786
|
-
// RC7: Double continuation from continue() fallback after triggerInvisibleContinue
|
|
787
|
-
// ---------------------------------------------------------------------------
|
|
788
|
-
|
|
789
|
-
describe("RC7: continue() fallback races triggerInvisibleContinue", () => {
|
|
790
|
-
/**
|
|
791
|
-
* GAP: When both pi-retry and pi-vcc are installed, overflow compaction
|
|
792
|
-
* creates a race:
|
|
793
|
-
*
|
|
794
|
-
* 1. pi-retry's agent_end handler fires triggerInvisibleContinue()
|
|
795
|
-
* (sets _continueInProgress = true, schedules async prompt([]))
|
|
796
|
-
* 2. _handlePostAgentRun returns true (willRetry)
|
|
797
|
-
* 3. Session's while loop calls continue()
|
|
798
|
-
* 4. pi-retry's wrapper: blocked on _continueInProgress
|
|
799
|
-
* 5. triggerInvisibleContinue() runs prompt([]) → agent runs → completes
|
|
800
|
-
* 6. _continueInProgress = false
|
|
801
|
-
* 7. continue() wrapper unblocks → calls original → throws
|
|
802
|
-
* "Cannot continue from message role: assistant"
|
|
803
|
-
* (because prompt([]) just added a new assistant message!)
|
|
804
|
-
* 8. Wrapper catches, checks stopReason:
|
|
805
|
-
* - "stop" → swallows ✓ (no double run)
|
|
806
|
-
* - "toolUse" → falls back to prompt([]) AGAIN ⚠️ DOUBLE CONTINUATION!
|
|
807
|
-
*
|
|
808
|
-
* The double continuation is wasteful and could cause duplicate tool calls.
|
|
809
|
-
* Fix: the continue() fallback should detect that triggerInvisibleContinue
|
|
810
|
-
* just ran (by checking a _justContinued flag or timestamp) and skip
|
|
811
|
-
* the fallback in that case.
|
|
812
|
-
*/
|
|
813
|
-
it("after triggerInvisibleContinue completes, continue() sees assistant last message", () => {
|
|
814
|
-
// After triggerInvisibleContinue's prompt([]) runs:
|
|
815
|
-
const context = makeContextMessages(
|
|
816
|
-
compactionSummaryMsg("summary"),
|
|
817
|
-
userMsg("u2"),
|
|
818
|
-
assistantMsg("a2", "toolUse"), // produced by the first prompt([])
|
|
819
|
-
);
|
|
820
|
-
|
|
821
|
-
const lastMsg = context[context.length - 1];
|
|
822
|
-
expect(lastMsg.role).toBe("assistant");
|
|
823
|
-
expect(lastMsg.stopReason).toBe("toolUse");
|
|
824
|
-
// continue() would try to fall back to prompt([]) AGAIN
|
|
825
|
-
});
|
|
826
|
-
|
|
827
|
-
it("if first prompt([]) produced stop, no double continuation", () => {
|
|
828
|
-
const context = makeContextMessages(
|
|
829
|
-
compactionSummaryMsg("summary"),
|
|
830
|
-
userMsg("u2"),
|
|
831
|
-
assistantMsg("a2", "stop"), // produced by the first prompt([])
|
|
832
|
-
);
|
|
833
|
-
|
|
834
|
-
const lastMsg = context[context.length - 1];
|
|
835
|
-
// The continue() fallback checks: stopReason !== "stop" → false → swallows
|
|
836
|
-
const shouldFallBack = lastMsg.role === "assistant"
|
|
837
|
-
&& lastMsg.stopReason !== "stop"
|
|
838
|
-
&& lastMsg.stopReason !== "aborted";
|
|
839
|
-
expect(shouldFallBack).toBe(false);
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
it("fix: track last invisible-continue completion to skip fallback", () => {
|
|
843
|
-
// Proposed approach: set a timestamp when triggerInvisibleContinue
|
|
844
|
-
// completes. The continue() fallback checks this timestamp —
|
|
845
|
-
// if it was set within the last 500ms, another prompt([]) just ran,
|
|
846
|
-
// so skip the fallback (the continuation was already handled).
|
|
847
|
-
//
|
|
848
|
-
// Alternative approach: use a shared _justContinued flag that
|
|
849
|
-
// triggerInvisibleContinue sets and continue() checks+clears.
|
|
850
|
-
//
|
|
851
|
-
// Either way, the key insight: the fallback should NOT fire when
|
|
852
|
-
// triggerInvisibleContinue just completed, because the agent already
|
|
853
|
-
// continued.
|
|
854
|
-
const justContinuedTimestamp = Date.now() - 100; // 100ms ago
|
|
855
|
-
const shouldSkipFallback = Date.now() - justContinuedTimestamp < 500;
|
|
856
|
-
expect(shouldSkipFallback).toBe(true);
|
|
857
|
-
});
|
|
858
|
-
});
|