@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.
Files changed (49) hide show
  1. package/npm-shrinkwrap.json +2 -2
  2. package/package.json +1 -1
  3. package/src/core/brief.ts +245 -1
  4. package/src/core/causal-keys.ts +56 -0
  5. package/src/core/summarize.ts +53 -3
  6. package/__tests__/helpers.ts +0 -135
  7. package/__tests__/root-causes.test.ts +0 -858
  8. package/demo.gif +0 -0
  9. package/knip.json +0 -4
  10. package/scripts/audit-sessions.ts +0 -88
  11. package/scripts/benchmark-real-sessions.ts +0 -25
  12. package/scripts/compare-before-after.ts +0 -36
  13. package/scripts/dump-branch-output.ts +0 -20
  14. package/stress-debug.ts +0 -202
  15. package/stress-degradation-curve.ts +0 -251
  16. package/tests/australian-government.test.ts +0 -479
  17. package/tests/before-compact-hook.test.ts +0 -185
  18. package/tests/before-compact.test.ts +0 -227
  19. package/tests/breadcrumbs.test.ts +0 -312
  20. package/tests/brief.test.ts +0 -215
  21. package/tests/build-sections.test.ts +0 -59
  22. package/tests/compile.test.ts +0 -89
  23. package/tests/content.test.ts +0 -66
  24. package/tests/deep-extraction.test.ts +0 -322
  25. package/tests/extract-goals.test.ts +0 -86
  26. package/tests/extract-preferences.test.ts +0 -30
  27. package/tests/filter-noise.test.ts +0 -61
  28. package/tests/fixtures.ts +0 -61
  29. package/tests/format-recall.test.ts +0 -87
  30. package/tests/format.test.ts +0 -73
  31. package/tests/government-domain.test.ts +0 -542
  32. package/tests/lineage.test.ts +0 -33
  33. package/tests/load-messages.test.ts +0 -49
  34. package/tests/model-threshold-hook.test.ts +0 -311
  35. package/tests/model-threshold.test.ts +0 -223
  36. package/tests/normalize.test.ts +0 -104
  37. package/tests/proactive-threshold.test.ts +0 -490
  38. package/tests/real-sessions.test.ts +0 -38
  39. package/tests/recall-expand.test.ts +0 -15
  40. package/tests/recall-scope.test.ts +0 -32
  41. package/tests/recall-tool-scope.test.ts +0 -67
  42. package/tests/render-entries.test.ts +0 -92
  43. package/tests/report.test.ts +0 -58
  44. package/tests/sanitize.test.ts +0 -24
  45. package/tests/search-entries.test.ts +0 -233
  46. package/tests/stress-100-compactions.test.ts +0 -373
  47. package/tests/support/load-session.ts +0 -23
  48. package/tests/support/real-sessions.ts +0 -51
  49. 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
- });