@monotykamary/pi-loop 0.1.12

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 (41) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +21 -0
  3. package/README.md +285 -0
  4. package/media/demo.mp4 +0 -0
  5. package/media/pi-loop.jpg +0 -0
  6. package/media/screenshot.png +0 -0
  7. package/package.json +89 -0
  8. package/src/core/analyzer.ts +51 -0
  9. package/src/core/content-extractor.ts +79 -0
  10. package/src/core/inference.ts +137 -0
  11. package/src/core/prompt-builder.ts +217 -0
  12. package/src/core/prompt-loader.ts +126 -0
  13. package/src/core/reframe.ts +30 -0
  14. package/src/core/snapshot-builder.ts +252 -0
  15. package/src/global-config.ts +38 -0
  16. package/src/index.ts +532 -0
  17. package/src/session/client.ts +47 -0
  18. package/src/session/loop-session.ts +102 -0
  19. package/src/session/response-parser.ts +37 -0
  20. package/src/state/manager.ts +164 -0
  21. package/src/state/patterns.ts +81 -0
  22. package/src/state/reframe.ts +33 -0
  23. package/src/subagent-detector.ts +94 -0
  24. package/src/types.ts +83 -0
  25. package/src/ui/animations.ts +70 -0
  26. package/src/ui/model-picker.ts +79 -0
  27. package/src/ui/renderer.ts +257 -0
  28. package/src/ui/status-widget.ts +30 -0
  29. package/src/ui/types.ts +48 -0
  30. package/tests/compaction.test.ts +754 -0
  31. package/tests/continue-action-regression.test.ts +456 -0
  32. package/tests/engine.test.ts +770 -0
  33. package/tests/ephemeral-supervision.test.ts +391 -0
  34. package/tests/full-fidelity-snapshot.test.ts +843 -0
  35. package/tests/parsing.test.ts +303 -0
  36. package/tests/state.test.ts +525 -0
  37. package/tests/status-widget.test.ts +703 -0
  38. package/tests/subagent-detector.test.ts +191 -0
  39. package/tests/supervise-command.test.ts +381 -0
  40. package/tsconfig.json +14 -0
  41. package/vitest.config.ts +15 -0
@@ -0,0 +1,456 @@
1
+ /**
2
+ * Regression test for: Continue action at agent_end sanity check
3
+ *
4
+ * Bug: When analyzer returned action: 'continue' at agent_end (agent idle),
5
+ * the supervisor would nudge the agent to continue even when work was complete,
6
+ * causing infinite loops.
7
+ *
8
+ * Fix: Added sanity check at agent_end. If 'continue' is received when agent is idle:
9
+ * 1. If confidence >= 0.8 and reasoning suggests completion (contains 'complete',
10
+ * 'verified', 'achieved', 'done', 'implemented'), treat as 'done'
11
+ * 2. Otherwise convert to 'steer' with the provided message or default
12
+ *
13
+ * The prompt also explicitly warns the model that returning 'continue' when idle
14
+ * is an error and will be converted.
15
+ */
16
+
17
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
18
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
19
+ import type { LoopState, SteeringDecision } from '../src/types.js';
20
+
21
+ // Mocks must be before imports
22
+ vi.mock('../src/core/analyzer.js', () => ({
23
+ analyze: vi.fn(),
24
+ }));
25
+
26
+ vi.mock('../src/core/inference.js', () => ({
27
+ inferOutcome: vi.fn(),
28
+ }));
29
+
30
+ vi.mock('../src/core/prompt-loader.js', () => ({
31
+ loadSystemPrompt: vi.fn().mockReturnValue({ prompt: 'test prompt', source: 'built-in' }),
32
+ }));
33
+
34
+ vi.mock('../src/ui/renderer.js', () => ({
35
+ updateUI: vi.fn(),
36
+ toggleWidget: vi.fn().mockReturnValue(true),
37
+ }));
38
+
39
+ vi.mock('../src/ui/model-picker.js', () => ({
40
+ pickModel: vi.fn(),
41
+ }));
42
+
43
+ vi.mock('../src/global-config.js', () => ({
44
+ loadGlobalModel: vi.fn().mockReturnValue(null),
45
+ }));
46
+
47
+ vi.mock('../src/session/client.js', () => ({
48
+ disposeSession: vi.fn(),
49
+ }));
50
+
51
+ vi.mock('../src/subagent-detector.js', () => ({
52
+ checkChildPiProcesses: vi.fn().mockResolvedValue({ hasActiveSubagents: false, count: 0 }),
53
+ waitForSubagents: vi
54
+ .fn()
55
+ .mockResolvedValue({ completed: true, finalStatus: { hasActiveSubagents: false, count: 0 } }),
56
+ }));
57
+
58
+ // Now import the mocked modules
59
+ import { analyze } from '../src/core/analyzer.js';
60
+ import { updateUI } from '../src/ui/renderer.js';
61
+ import { checkChildPiProcesses, waitForSubagents } from '../src/subagent-detector.js';
62
+ import { LoopStateManager } from '../src/state/manager.js';
63
+
64
+ describe('Continue action at agent_end - sanity check behavior', () => {
65
+ let mockPi: ExtensionAPI;
66
+ let mockCtx: ExtensionContext;
67
+ let sendUserMessageSpy: ReturnType<typeof vi.fn>;
68
+ let stateManager: LoopStateManager;
69
+
70
+ beforeEach(() => {
71
+ vi.clearAllMocks();
72
+
73
+ // Create spy for sendUserMessage
74
+ sendUserMessageSpy = vi.fn();
75
+
76
+ // Mock the ExtensionAPI
77
+ mockPi = {
78
+ appendEntry: vi.fn(),
79
+ on: vi.fn(),
80
+ registerCommand: vi.fn(),
81
+ registerTool: vi.fn(),
82
+ sendUserMessage: sendUserMessageSpy,
83
+ sendMessage: vi.fn(),
84
+ events: { emit: vi.fn(), on: vi.fn() },
85
+ } as any;
86
+
87
+ // Mock the ExtensionContext
88
+ mockCtx = {
89
+ ui: {
90
+ notify: vi.fn(),
91
+ setStatus: vi.fn(),
92
+ setWidget: vi.fn(),
93
+ },
94
+ sessionManager: {
95
+ getBranch: vi.fn().mockReturnValue([]),
96
+ },
97
+ model: { provider: 'anthropic', id: 'claude-haiku' },
98
+ modelRegistry: {
99
+ getApiKeyForProvider: vi.fn().mockResolvedValue('test-key'),
100
+ find: vi.fn().mockReturnValue({ name: 'test-model' }),
101
+ },
102
+ isIdle: vi.fn().mockReturnValue(true),
103
+ cwd: '/test',
104
+ } as any;
105
+
106
+ stateManager = new LoopStateManager(mockPi);
107
+ });
108
+
109
+ afterEach(() => {
110
+ vi.restoreAllMocks();
111
+ });
112
+
113
+ /**
114
+ * NEW SANITY CHECK LOGIC:
115
+ * At agent_end, 'continue' is not valid when agent is idle.
116
+ *
117
+ * If confidence >= 0.8 AND reasoning suggests completion:
118
+ * → Treat as 'done'
119
+ * Else:
120
+ * → Treat as 'steer' with message
121
+ */
122
+ function shouldTreatContinueAsDone(decision: SteeringDecision): boolean {
123
+ return (
124
+ decision.confidence >= 0.8 &&
125
+ (decision.reasoning?.toLowerCase().includes('complete') ||
126
+ decision.reasoning?.toLowerCase().includes('verified') ||
127
+ decision.reasoning?.toLowerCase().includes('achieved') ||
128
+ decision.reasoning?.toLowerCase().includes('done') ||
129
+ decision.reasoning?.toLowerCase().includes('implemented'))
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Simulates the UPDATED agent_end handler behavior with sanity check
135
+ */
136
+ async function simulateAgentEnd(decision: SteeringDecision): Promise<void> {
137
+ // Start loop state
138
+ stateManager.start('Fix hydration inconsistency', 'anthropic', 'claude-haiku');
139
+
140
+ // Mock analyzer to return our test decision
141
+ vi.mocked(analyze).mockResolvedValue(decision);
142
+
143
+ // Mock subagent checks
144
+ vi.mocked(checkChildPiProcesses).mockResolvedValue({ hasActiveSubagents: false, count: 0 });
145
+ vi.mocked(waitForSubagents).mockResolvedValue({
146
+ completed: true,
147
+ finalStatus: { hasActiveSubagents: false, count: 0 },
148
+ });
149
+
150
+ // Simulate the agent_end handler logic
151
+ stateManager.incrementTurnCount();
152
+ const s = stateManager.getState()!;
153
+
154
+ // Check subagents (bypassed in test)
155
+ await checkChildPiProcesses();
156
+
157
+ // Call analyzer
158
+ const actualDecision = await analyze(mockCtx, s, true);
159
+
160
+ // UPDATED agent_end handler logic with sanity check
161
+ if (actualDecision.action === 'steer' && actualDecision.message) {
162
+ stateManager.addIntervention({
163
+ turnCount: s.turnCount,
164
+ message: actualDecision.message,
165
+ reasoning: actualDecision.reasoning,
166
+ timestamp: Date.now(),
167
+ asi: actualDecision.asi,
168
+ });
169
+ updateUI(mockCtx, {} as any, stateManager.getState(), {
170
+ type: 'steering',
171
+ message: actualDecision.message,
172
+ reframeTier: stateManager.getReframeTier(),
173
+ });
174
+ mockPi.sendUserMessage(actualDecision.message);
175
+ } else if (actualDecision.action === 'done') {
176
+ stateManager.resetReframeTier();
177
+ updateUI(mockCtx, {} as any, stateManager.getState(), { type: 'done' });
178
+ stateManager.stop();
179
+ } else if (actualDecision.action === 'continue') {
180
+ // SANITY CHECK: At agent_end, the agent is IDLE. The prompt explicitly
181
+ // instructs supervisor to NEVER return 'continue' when agent is idle.
182
+
183
+ const highConfidenceOfCompletion = shouldTreatContinueAsDone(actualDecision);
184
+
185
+ if (highConfidenceOfCompletion) {
186
+ // Model likely meant to return 'done' but returned 'continue' by mistake
187
+ stateManager.resetReframeTier();
188
+ updateUI(mockCtx, {} as any, stateManager.getState(), { type: 'done' });
189
+ stateManager.stop();
190
+ } else {
191
+ // Treat as steer — goal not achieved, agent needs direction
192
+ const steerMessage = actualDecision.message?.trim()
193
+ ? actualDecision.message
194
+ : 'Please continue working toward the goal.';
195
+ stateManager.addIntervention({
196
+ turnCount: s.turnCount,
197
+ message: steerMessage,
198
+ reasoning: actualDecision.reasoning || 'Goal not yet achieved, continuing work',
199
+ timestamp: Date.now(),
200
+ asi: { ...actualDecision.asi, _sanity: 'converted_continue_at_idle_to_steer' },
201
+ });
202
+ updateUI(mockCtx, {} as any, stateManager.getState(), {
203
+ type: 'steering',
204
+ message: steerMessage,
205
+ reframeTier: stateManager.getReframeTier(),
206
+ });
207
+ mockPi.sendUserMessage(steerMessage);
208
+ }
209
+ } else {
210
+ // Set watching without sending a message
211
+ updateUI(mockCtx, {} as any, stateManager.getState(), {
212
+ type: 'watching',
213
+ reframeTier: stateManager.getReframeTier(),
214
+ });
215
+ }
216
+ }
217
+
218
+ describe('when analyzer returns continue with high confidence of completion', () => {
219
+ it('should treat as DONE - stop loop without sending message', async () => {
220
+ const decision: SteeringDecision = {
221
+ action: 'continue',
222
+ reasoning: 'Both fixes have been fully implemented and verified. All tests pass.',
223
+ confidence: 0.95,
224
+ };
225
+
226
+ await simulateAgentEnd(decision);
227
+
228
+ // With high confidence and completion keywords, should stop loop
229
+ expect(sendUserMessageSpy).not.toHaveBeenCalled();
230
+ expect(stateManager.isActive()).toBe(false);
231
+ });
232
+
233
+ it('should treat as DONE when reasoning contains "verified"', async () => {
234
+ const decision: SteeringDecision = {
235
+ action: 'continue',
236
+ reasoning: 'The implementation is verified and complete.',
237
+ confidence: 0.85,
238
+ };
239
+
240
+ await simulateAgentEnd(decision);
241
+
242
+ expect(sendUserMessageSpy).not.toHaveBeenCalled();
243
+ expect(stateManager.isActive()).toBe(false);
244
+ });
245
+
246
+ it('should treat as DONE when reasoning contains "achieved"', async () => {
247
+ const decision: SteeringDecision = {
248
+ action: 'continue',
249
+ reasoning: 'Goal has been achieved.',
250
+ confidence: 0.9,
251
+ };
252
+
253
+ await simulateAgentEnd(decision);
254
+
255
+ expect(sendUserMessageSpy).not.toHaveBeenCalled();
256
+ expect(stateManager.isActive()).toBe(false);
257
+ });
258
+
259
+ it('should treat as DONE when reasoning contains "implemented"', async () => {
260
+ const decision: SteeringDecision = {
261
+ action: 'continue',
262
+ reasoning: 'All changes have been implemented successfully.',
263
+ confidence: 0.88,
264
+ };
265
+
266
+ await simulateAgentEnd(decision);
267
+
268
+ expect(sendUserMessageSpy).not.toHaveBeenCalled();
269
+ expect(stateManager.isActive()).toBe(false);
270
+ });
271
+
272
+ it('should update UI to done state', async () => {
273
+ const decision: SteeringDecision = {
274
+ action: 'continue',
275
+ reasoning: 'Fixes complete and verified.',
276
+ confidence: 0.95,
277
+ };
278
+
279
+ await simulateAgentEnd(decision);
280
+
281
+ const lastCall = vi.mocked(updateUI).mock.calls[vi.mocked(updateUI).mock.calls.length - 1];
282
+ const uiUpdate = lastCall[3] as any;
283
+ expect(uiUpdate.type).toBe('done');
284
+ });
285
+ });
286
+
287
+ describe('when analyzer returns continue with low confidence or no completion signals', () => {
288
+ it('should convert to STEER and send continuation message', async () => {
289
+ const decision: SteeringDecision = {
290
+ action: 'continue',
291
+ reasoning: 'Goal not yet achieved, need more work',
292
+ confidence: 0.7,
293
+ };
294
+
295
+ await simulateAgentEnd(decision);
296
+
297
+ // Should send message to prompt agent to continue
298
+ expect(sendUserMessageSpy).toHaveBeenCalledTimes(1);
299
+ expect(sendUserMessageSpy).toHaveBeenCalledWith('Please continue working toward the goal.');
300
+ });
301
+
302
+ it('should convert to STEER when confidence is high but no completion keywords', async () => {
303
+ const decision: SteeringDecision = {
304
+ action: 'continue',
305
+ reasoning: 'Progress looks good so far, agent should keep working',
306
+ confidence: 0.85,
307
+ };
308
+
309
+ await simulateAgentEnd(decision);
310
+
311
+ // Even with high confidence, no completion keywords means steer
312
+ expect(sendUserMessageSpy).toHaveBeenCalledTimes(1);
313
+ expect(stateManager.isActive()).toBe(true); // Loop still active
314
+ });
315
+
316
+ it('should use custom message when provided', async () => {
317
+ const customMessage = 'Continue working on the ChatMessage textContent fix';
318
+ const decision: SteeringDecision = {
319
+ action: 'continue',
320
+ message: customMessage,
321
+ reasoning: 'More work needed',
322
+ confidence: 0.6,
323
+ };
324
+
325
+ await simulateAgentEnd(decision);
326
+
327
+ expect(sendUserMessageSpy).toHaveBeenCalledTimes(1);
328
+ expect(sendUserMessageSpy).toHaveBeenCalledWith(customMessage);
329
+ });
330
+
331
+ it('should update UI to steering state', async () => {
332
+ const decision: SteeringDecision = {
333
+ action: 'continue',
334
+ reasoning: 'More work needed',
335
+ confidence: 0.6,
336
+ };
337
+
338
+ await simulateAgentEnd(decision);
339
+
340
+ // UI should show steering (active intervention)
341
+ const lastCall = vi.mocked(updateUI).mock.calls[vi.mocked(updateUI).mock.calls.length - 1];
342
+ const uiUpdate = lastCall[3] as any;
343
+ expect(uiUpdate.type).toBe('steering');
344
+ });
345
+
346
+ it('should record intervention with sanity check marker', async () => {
347
+ const decision: SteeringDecision = {
348
+ action: 'continue',
349
+ reasoning: 'Incomplete',
350
+ confidence: 0.6,
351
+ };
352
+
353
+ await simulateAgentEnd(decision);
354
+
355
+ const state = stateManager.getState()!;
356
+ expect(state.interventions).toHaveLength(1);
357
+ expect(state.interventions[0].asi?._sanity).toBe('converted_continue_at_idle_to_steer');
358
+ });
359
+
360
+ it('should keep loop active when converting to steer', async () => {
361
+ const decision: SteeringDecision = {
362
+ action: 'continue',
363
+ reasoning: 'Not done yet',
364
+ confidence: 0.5,
365
+ };
366
+
367
+ await simulateAgentEnd(decision);
368
+
369
+ expect(stateManager.isActive()).toBe(true);
370
+ });
371
+ });
372
+
373
+ describe('contrast with proper actions', () => {
374
+ it('steer action: works normally without conversion', async () => {
375
+ const decision: SteeringDecision = {
376
+ action: 'steer',
377
+ message: 'Fix this specific issue',
378
+ reasoning: 'Agent off track',
379
+ confidence: 0.9,
380
+ };
381
+
382
+ await simulateAgentEnd(decision);
383
+
384
+ expect(sendUserMessageSpy).toHaveBeenCalledTimes(1);
385
+ expect(sendUserMessageSpy).toHaveBeenCalledWith('Fix this specific issue');
386
+ expect(stateManager.isActive()).toBe(true);
387
+ });
388
+
389
+ it('done action: stops loop without sending message', async () => {
390
+ const decision: SteeringDecision = {
391
+ action: 'done',
392
+ reasoning: 'Goal achieved',
393
+ confidence: 0.95,
394
+ };
395
+
396
+ await simulateAgentEnd(decision);
397
+
398
+ expect(sendUserMessageSpy).not.toHaveBeenCalled();
399
+ expect(stateManager.isActive()).toBe(false);
400
+ });
401
+ });
402
+
403
+ describe('regression: infinite continue loop', () => {
404
+ it('OLD BEHAVIOR (before fix): would keep sending continue messages forever', async () => {
405
+ // This test documents what used to happen
406
+ stateManager.start('Test goal', 'anthropic', 'claude-haiku');
407
+
408
+ // Simulate multiple agent_end events with continue
409
+ for (let i = 0; i < 5; i++) {
410
+ stateManager.incrementTurnCount();
411
+ const s = stateManager.getState()!;
412
+
413
+ // OLD behavior: always send continue message
414
+ const continueMessage = 'Please continue working toward the goal.';
415
+ stateManager.addIntervention({
416
+ turnCount: s.turnCount,
417
+ message: continueMessage,
418
+ reasoning: 'Goal not yet achieved',
419
+ timestamp: Date.now(),
420
+ });
421
+ }
422
+
423
+ // Old behavior would accumulate many interventions
424
+ expect(stateManager.getState()!.interventions.length).toBe(5);
425
+ expect(stateManager.isActive()).toBe(true); // Never stops
426
+ });
427
+
428
+ it('NEW BEHAVIOR: stops loop when continue looks like completion', async () => {
429
+ const decision: SteeringDecision = {
430
+ action: 'continue',
431
+ reasoning: 'Both fixes fully implemented and verified. All 1375 tests pass.',
432
+ confidence: 0.95,
433
+ };
434
+
435
+ await simulateAgentEnd(decision);
436
+
437
+ // Should stop instead of continuing forever
438
+ expect(stateManager.isActive()).toBe(false);
439
+ expect(sendUserMessageSpy).not.toHaveBeenCalled();
440
+ });
441
+
442
+ it('NEW BEHAVIOR: converts incomplete continue to steer', async () => {
443
+ const decision: SteeringDecision = {
444
+ action: 'continue',
445
+ reasoning: 'Need more work',
446
+ confidence: 0.6,
447
+ };
448
+
449
+ await simulateAgentEnd(decision);
450
+
451
+ // Should steer, not just loop
452
+ expect(sendUserMessageSpy).toHaveBeenCalled();
453
+ expect(stateManager.isActive()).toBe(true);
454
+ });
455
+ });
456
+ });