@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,754 @@
1
+ import { describe, expect, it, vi, beforeEach } from 'vitest';
2
+ import { LoopStateManager } from '../src/state/manager.js';
3
+
4
+ // Mock dependencies
5
+ vi.mock('../src/core/analyzer.js', () => ({
6
+ analyze: vi.fn(),
7
+ }));
8
+
9
+ vi.mock('../src/core/prompt-loader.js', () => ({
10
+ loadSystemPrompt: vi.fn().mockReturnValue({ prompt: 'test prompt', source: 'built-in' }),
11
+ }));
12
+
13
+ vi.mock('../src/ui/renderer.js', () => ({
14
+ updateUI: vi.fn(),
15
+ toggleWidget: vi.fn(),
16
+ }));
17
+
18
+ vi.mock('../src/session/client.js', () => ({
19
+ disposeSession: vi.fn(),
20
+ }));
21
+
22
+ import { analyze } from '../src/core/analyzer.js';
23
+ import { updateUI } from '../src/ui/renderer.js';
24
+
25
+ // Mock ExtensionAPI
26
+ function createMockApi() {
27
+ return {
28
+ appendEntry: vi.fn(),
29
+ on: vi.fn(),
30
+ registerCommand: vi.fn(),
31
+ registerTool: vi.fn(),
32
+ sendUserMessage: vi.fn(),
33
+ sendMessage: vi.fn(),
34
+ events: { emit: vi.fn(), on: vi.fn() },
35
+ } as any;
36
+ }
37
+
38
+ function createMockContext(entries: any[] = [], isIdle = true) {
39
+ return {
40
+ ui: {
41
+ notify: vi.fn(),
42
+ setStatus: vi.fn(),
43
+ setWidget: vi.fn(),
44
+ setWorkingMessage: vi.fn(),
45
+ },
46
+ hasUI: true,
47
+ cwd: '/test',
48
+ sessionManager: {
49
+ getBranch: vi.fn().mockReturnValue(entries),
50
+ },
51
+ modelRegistry: {},
52
+ model: undefined,
53
+ isIdle: vi.fn().mockReturnValue(isIdle),
54
+ abort: vi.fn(),
55
+ hasPendingMessages: vi.fn().mockReturnValue(false),
56
+ shutdown: vi.fn(),
57
+ getContextUsage: vi.fn(),
58
+ compact: vi.fn(),
59
+ getSystemPrompt: vi.fn().mockReturnValue('test'),
60
+ } as any;
61
+ }
62
+
63
+ describe('LoopStateManager - compaction survival', () => {
64
+ describe('loadFromSession after compaction', () => {
65
+ it('restores state from custom entry in session', () => {
66
+ const api = createMockApi();
67
+ const state = new LoopStateManager(api);
68
+
69
+ // Simulate a session with a supervisor-state entry
70
+ const sessionEntries = [
71
+ { type: 'message', message: { role: 'user', content: 'Hello' } },
72
+ {
73
+ type: 'custom',
74
+ customType: 'loop-state',
75
+ data: {
76
+ active: true,
77
+ outcome: 'Test goal',
78
+ provider: 'anthropic',
79
+ modelId: 'claude-haiku',
80
+ interventions: [],
81
+ startedAt: Date.now(),
82
+ turnCount: 5,
83
+ reframeTier: 2,
84
+ lastSteerTurn: 3,
85
+ },
86
+ },
87
+ ];
88
+
89
+ const ctx = createMockContext(sessionEntries);
90
+ state.loadFromSession(ctx);
91
+
92
+ expect(state.isActive()).toBe(true);
93
+ expect(state.getState()?.outcome).toBe('Test goal');
94
+ expect(state.getState()?.provider).toBe('anthropic');
95
+ expect(state.getState()?.turnCount).toBe(5);
96
+ expect(state.getReframeTier()).toBe(2);
97
+ });
98
+
99
+ it('restores null state when no supervisor-state entry exists', () => {
100
+ const api = createMockApi();
101
+ const state = new LoopStateManager(api);
102
+
103
+ // Session with no supervisor-state entry (e.g., after compaction summarized it away)
104
+ const sessionEntries = [
105
+ { type: 'message', message: { role: 'user', content: 'Hello' } },
106
+ { type: 'compaction', summary: 'Summary of old messages' },
107
+ ];
108
+
109
+ const ctx = createMockContext(sessionEntries);
110
+ state.loadFromSession(ctx);
111
+
112
+ expect(state.isActive()).toBe(false);
113
+ expect(state.getState()).toBeNull();
114
+ });
115
+
116
+ it('restores ephemeral fields with defaults after compaction', () => {
117
+ const api = createMockApi();
118
+ const state = new LoopStateManager(api);
119
+
120
+ const sessionEntries = [
121
+ {
122
+ type: 'custom',
123
+ customType: 'loop-state',
124
+ data: {
125
+ active: true,
126
+ outcome: 'Test goal',
127
+ provider: 'anthropic',
128
+ modelId: 'claude-haiku',
129
+ interventions: [{ turnCount: 1, message: 'Focus', reasoning: 'Test', timestamp: 123 }],
130
+ startedAt: 1000,
131
+ turnCount: 3,
132
+ reframeTier: 1,
133
+ lastSteerTurn: 2,
134
+ // Ephemeral fields should NOT be in persisted data
135
+ },
136
+ },
137
+ ];
138
+
139
+ const ctx = createMockContext(sessionEntries);
140
+ state.loadFromSession(ctx);
141
+
142
+ // Ephemeral fields should be reset
143
+ expect(state.getState()?.snapshotBuffer).toEqual([]);
144
+ expect(state.getState()?.lastAnalyzedTurn).toBe(-1);
145
+ expect(state.getState()?.justSteered).toBe(false);
146
+
147
+ // Non-ephemeral fields should be preserved
148
+ expect(state.getState()?.turnCount).toBe(3);
149
+ expect(state.getState()?.reframeTier).toBe(1);
150
+ expect(state.getState()?.lastSteerTurn).toBe(2);
151
+ });
152
+
153
+ it('uses the most recent supervisor-state entry when multiple exist', () => {
154
+ const api = createMockApi();
155
+ const state = new LoopStateManager(api);
156
+
157
+ const sessionEntries = [
158
+ {
159
+ type: 'custom',
160
+ customType: 'loop-state',
161
+ data: {
162
+ active: false, // Old - stopped
163
+ outcome: 'Old goal',
164
+ provider: 'openai',
165
+ modelId: 'gpt-4o',
166
+ interventions: [],
167
+ startedAt: 1000,
168
+ turnCount: 10,
169
+ },
170
+ },
171
+ { type: 'message', message: { role: 'user', content: 'Continue' } },
172
+ {
173
+ type: 'custom',
174
+ customType: 'loop-state',
175
+ data: {
176
+ active: true, // Newer - active
177
+ outcome: 'New goal',
178
+ provider: 'anthropic',
179
+ modelId: 'claude-haiku',
180
+ interventions: [],
181
+ startedAt: 2000,
182
+ turnCount: 3,
183
+ },
184
+ },
185
+ ];
186
+
187
+ const ctx = createMockContext(sessionEntries);
188
+ state.loadFromSession(ctx);
189
+
190
+ expect(state.isActive()).toBe(true);
191
+ expect(state.getState()?.outcome).toBe('New goal');
192
+ expect(state.getState()?.provider).toBe('anthropic');
193
+ });
194
+ });
195
+
196
+ describe('persist() behavior', () => {
197
+ it('persists active state to session via appendEntry', () => {
198
+ const api = createMockApi();
199
+ const state = new LoopStateManager(api);
200
+
201
+ state.start('Test goal', 'anthropic', 'claude-haiku');
202
+
203
+ // start() calls persist() with initial state
204
+ expect(api.appendEntry).toHaveBeenCalledWith(
205
+ 'loop-state',
206
+ expect.objectContaining({
207
+ active: true,
208
+ outcome: 'Test goal',
209
+ provider: 'anthropic',
210
+ modelId: 'claude-haiku',
211
+ turnCount: 0,
212
+ })
213
+ );
214
+ });
215
+
216
+ it('does not persist ephemeral fields', () => {
217
+ const api = createMockApi();
218
+ const state = new LoopStateManager(api);
219
+
220
+ state.start('Test goal', 'anthropic', 'claude-haiku');
221
+ state.updateSnapshotBuffer([{ role: 'user', content: 'Hello' }]);
222
+ state.clearJustSteered(); // justSteered would be false anyway
223
+
224
+ const lastCall = api.appendEntry.mock.calls[api.appendEntry.mock.calls.length - 1];
225
+ const persistedData = lastCall[1];
226
+
227
+ // Ephemeral fields should NOT be persisted
228
+ expect(persistedData.snapshotBuffer).toBeUndefined();
229
+ expect(persistedData.lastAnalyzedTurn).toBeUndefined();
230
+ expect(persistedData.justSteered).toBeUndefined();
231
+
232
+ // Non-ephemeral fields should be persisted
233
+ expect(persistedData.outcome).toBe('Test goal');
234
+ expect(persistedData.active).toBe(true);
235
+ });
236
+
237
+ it('can be called manually to re-persist after compaction (public method)', () => {
238
+ const api = createMockApi();
239
+ const state = new LoopStateManager(api);
240
+
241
+ // Start supervision
242
+ state.start('Test goal', 'anthropic', 'claude-haiku');
243
+ expect(api.appendEntry).toHaveBeenCalledTimes(1);
244
+
245
+ // Simulate compaction handler re-persisting
246
+ state.persist();
247
+ expect(api.appendEntry).toHaveBeenCalledTimes(2);
248
+
249
+ // Verify the re-persisted data is correct
250
+ const lastCall = api.appendEntry.mock.calls[api.appendEntry.mock.calls.length - 1];
251
+ expect(lastCall[0]).toBe('loop-state');
252
+ expect(lastCall[1]).toMatchObject({
253
+ active: true,
254
+ outcome: 'Test goal',
255
+ provider: 'anthropic',
256
+ modelId: 'claude-haiku',
257
+ });
258
+ });
259
+
260
+ it('does nothing when persist() is called with no active state', () => {
261
+ const api = createMockApi();
262
+ const state = new LoopStateManager(api);
263
+
264
+ // persist() with no active state should not call appendEntry
265
+ state.persist();
266
+ expect(api.appendEntry).not.toHaveBeenCalled();
267
+ });
268
+
269
+ it('preserves interventions when re-persisting', () => {
270
+ const api = createMockApi();
271
+ const state = new LoopStateManager(api);
272
+
273
+ state.start('Test goal', 'anthropic', 'claude-haiku');
274
+ state.incrementTurnCount();
275
+ state.addIntervention({
276
+ turnCount: 1,
277
+ message: 'Focus on tests',
278
+ reasoning: 'Drift detected',
279
+ timestamp: 1234567890,
280
+ asi: { why_stuck: 'no tests', strategy_used: 'directive' },
281
+ });
282
+
283
+ // Clear mock to test re-persist
284
+ api.appendEntry.mockClear();
285
+ state.persist();
286
+
287
+ const lastCall = api.appendEntry.mock.calls[api.appendEntry.mock.calls.length - 1];
288
+ expect(lastCall[1].interventions).toHaveLength(1);
289
+ expect(lastCall[1].interventions[0].message).toBe('Focus on tests');
290
+ expect(lastCall[1].interventions[0].asi).toEqual({
291
+ why_stuck: 'no tests',
292
+ strategy_used: 'directive',
293
+ });
294
+ });
295
+ });
296
+
297
+ describe('compaction survival scenario', () => {
298
+ it('full lifecycle: start -> compact -> reload -> repersist', () => {
299
+ const api = createMockApi();
300
+ const state = new LoopStateManager(api);
301
+
302
+ // 1. Start supervision
303
+ state.start('Implement auth', 'anthropic', 'claude-haiku');
304
+ // Manually increment and persist to simulate turn progression
305
+ state.incrementTurnCount();
306
+ state.incrementTurnCount();
307
+ state.addIntervention({
308
+ turnCount: 2,
309
+ message: 'Focus on JWT',
310
+ reasoning: 'Drift',
311
+ timestamp: 1000,
312
+ });
313
+ expect(state.isActive()).toBe(true);
314
+ expect(state.getState()?.turnCount).toBe(2);
315
+
316
+ // 2. Simulate compaction (session is reloaded with summary + recent entries)
317
+ // After compaction, the supervisor-state entry is now "old" and in the kept portion
318
+ // The extension's session_compact handler reloads state
319
+ const postCompactionEntries = [
320
+ { type: 'compaction', summary: 'Earlier conversation summarized' },
321
+ { type: 'message', message: { role: 'user', content: 'Continue' } },
322
+ // The supervisor-state entry was in the kept portion (recent)
323
+ {
324
+ type: 'custom',
325
+ customType: 'loop-state',
326
+ data: {
327
+ active: true,
328
+ outcome: 'Implement auth',
329
+ provider: 'anthropic',
330
+ modelId: 'claude-haiku',
331
+ interventions: [
332
+ { turnCount: 2, message: 'Focus on JWT', reasoning: 'Drift', timestamp: 1000 },
333
+ ],
334
+ startedAt: 500,
335
+ turnCount: 2,
336
+ reframeTier: 0,
337
+ lastSteerTurn: 2,
338
+ },
339
+ },
340
+ ];
341
+
342
+ // 3. Reload from session (simulating session_compact handler)
343
+ const ctx = createMockContext(postCompactionEntries);
344
+ state.loadFromSession(ctx);
345
+
346
+ // 4. Verify state was restored
347
+ expect(state.isActive()).toBe(true);
348
+ expect(state.getState()?.outcome).toBe('Implement auth');
349
+ expect(state.getState()?.turnCount).toBe(2);
350
+ expect(state.getState()?.interventions).toHaveLength(1);
351
+
352
+ // 5. Re-persist to ensure future compactions find it in kept portion
353
+ api.appendEntry.mockClear();
354
+ state.persist();
355
+
356
+ expect(api.appendEntry).toHaveBeenCalledWith(
357
+ 'loop-state',
358
+ expect.objectContaining({
359
+ active: true,
360
+ outcome: 'Implement auth',
361
+ turnCount: 2,
362
+ })
363
+ );
364
+ });
365
+
366
+ it('handles state loss when supervisor-state was summarized away', () => {
367
+ const api = createMockApi();
368
+ const state = new LoopStateManager(api);
369
+
370
+ // Start supervision
371
+ state.start('Lost goal', 'anthropic', 'claude-haiku');
372
+ expect(state.isActive()).toBe(true);
373
+
374
+ // Simulate compaction where supervisor-state was in summarized portion
375
+ // (old, far back in history)
376
+ const postCompactionEntries = [
377
+ {
378
+ type: 'compaction',
379
+ summary: 'Earlier conversation summarized (including old supervisor-state)',
380
+ },
381
+ { type: 'message', message: { role: 'user', content: 'Recent message' } },
382
+ // No supervisor-state in kept portion!
383
+ ];
384
+
385
+ // Reload after compaction
386
+ const ctx = createMockContext(postCompactionEntries);
387
+ state.loadFromSession(ctx);
388
+
389
+ // State is lost (as expected - compaction summarized it away)
390
+ expect(state.isActive()).toBe(false);
391
+ expect(state.getState()).toBeNull();
392
+
393
+ // Re-persist does nothing when state is null
394
+ api.appendEntry.mockClear();
395
+ state.persist();
396
+ expect(api.appendEntry).not.toHaveBeenCalled();
397
+ });
398
+
399
+ it('recovers from state loss by re-persisting if was active', () => {
400
+ // This test verifies the compaction handler pattern:
401
+ // After compaction, if we had active state but it was lost,
402
+ // we need some mechanism to recover. In the real implementation,
403
+ // the session_compact handler checks isActive() after loadFromSession
404
+ // and re-persists if needed.
405
+
406
+ const api = createMockApi();
407
+ const state = new LoopStateManager(api);
408
+
409
+ // Start supervision
410
+ state.start('Goal', 'anthropic', 'claude-haiku');
411
+ expect(state.isActive()).toBe(true);
412
+
413
+ // Simulate scenario where state was lost in compaction
414
+ // In reality, we'd need to track this differently, but the test
415
+ // verifies the re-persist behavior when state IS active
416
+ api.appendEntry.mockClear();
417
+
418
+ // Simulate compaction handler: reload, then if active, re-persist
419
+ state.persist(); // This would be called after loadFromSession in handler
420
+
421
+ expect(api.appendEntry).toHaveBeenCalledWith(
422
+ 'loop-state',
423
+ expect.objectContaining({
424
+ active: true,
425
+ outcome: 'Goal',
426
+ turnCount: 0,
427
+ })
428
+ );
429
+ });
430
+ });
431
+
432
+ describe('event handler behavior', () => {
433
+ beforeEach(() => {
434
+ vi.clearAllMocks();
435
+ });
436
+
437
+ it('session_before_compact persists state when supervision is active', () => {
438
+ const api = createMockApi();
439
+ const state = new LoopStateManager(api);
440
+
441
+ // Start supervision
442
+ state.start('Test goal', 'anthropic', 'claude-haiku');
443
+ expect(api.appendEntry).toHaveBeenCalledTimes(1); // From start()
444
+
445
+ // Simulate session_before_compact handler behavior
446
+ if (state.isActive()) {
447
+ state.persist();
448
+ }
449
+
450
+ expect(api.appendEntry).toHaveBeenCalledTimes(2);
451
+ expect(api.appendEntry).toHaveBeenLastCalledWith(
452
+ 'loop-state',
453
+ expect.objectContaining({
454
+ active: true,
455
+ outcome: 'Test goal',
456
+ provider: 'anthropic',
457
+ modelId: 'claude-haiku',
458
+ })
459
+ );
460
+ });
461
+
462
+ it('session_before_compact does nothing when supervision is inactive', () => {
463
+ const api = createMockApi();
464
+ const state = new LoopStateManager(api);
465
+
466
+ // No active supervision
467
+ expect(state.isActive()).toBe(false);
468
+
469
+ // Simulate session_before_compact handler behavior
470
+ if (state.isActive()) {
471
+ state.persist();
472
+ }
473
+
474
+ // Should not have called appendEntry
475
+ expect(api.appendEntry).not.toHaveBeenCalled();
476
+ });
477
+
478
+ it('session_compact handler reloads state and updates UI', () => {
479
+ const api = createMockApi();
480
+ const state = new LoopStateManager(api);
481
+
482
+ // Pre-populate with post-compaction entries containing supervisor-state
483
+ const postCompactionEntries = [
484
+ { type: 'compaction', summary: 'Earlier conversation summarized' },
485
+ {
486
+ type: 'custom',
487
+ customType: 'loop-state',
488
+ data: {
489
+ active: true,
490
+ outcome: 'Survived goal',
491
+ provider: 'anthropic',
492
+ modelId: 'claude-haiku',
493
+ interventions: [],
494
+ startedAt: 1000,
495
+ turnCount: 5,
496
+ reframeTier: 1,
497
+ lastSteerTurn: 4,
498
+ },
499
+ },
500
+ ];
501
+
502
+ const ctx = createMockContext(postCompactionEntries, true);
503
+
504
+ // Simulate session_compact handler
505
+ state.loadFromSession(ctx);
506
+
507
+ if (state.isActive()) {
508
+ // Update UI to show we're back
509
+ updateUI(ctx, state.getState(), { type: 'watching', reframeTier: state.getReframeTier() });
510
+ } else {
511
+ updateUI(ctx, null);
512
+ }
513
+
514
+ expect(state.isActive()).toBe(true);
515
+ expect(state.getState()?.outcome).toBe('Survived goal');
516
+ expect(updateUI).toHaveBeenCalledWith(
517
+ ctx,
518
+ expect.objectContaining({ outcome: 'Survived goal' }),
519
+ { type: 'watching', reframeTier: 1 }
520
+ );
521
+ });
522
+
523
+ it('session_compact handler clears UI when state is lost', () => {
524
+ const api = createMockApi();
525
+ const state = new LoopStateManager(api);
526
+
527
+ // Entries with NO supervisor-state (simulating lost state)
528
+ const lostStateEntries = [
529
+ {
530
+ type: 'compaction',
531
+ summary: 'Earlier conversation summarized including supervisor-state',
532
+ },
533
+ { type: 'message', message: { role: 'user', content: 'Recent message' } },
534
+ ];
535
+
536
+ const ctx = createMockContext(lostStateEntries, true);
537
+
538
+ // Simulate session_compact handler
539
+ state.loadFromSession(ctx);
540
+
541
+ if (state.isActive()) {
542
+ updateUI(ctx, state.getState(), { type: 'watching', reframeTier: state.getReframeTier() });
543
+ } else {
544
+ updateUI(ctx, null);
545
+ }
546
+
547
+ expect(state.isActive()).toBe(false);
548
+ expect(updateUI).toHaveBeenCalledWith(ctx, null);
549
+ });
550
+
551
+ it('session_compact triggers steering when agent is idle and steering is needed', async () => {
552
+ const api = createMockApi();
553
+ const state = new LoopStateManager(api);
554
+
555
+ // Mock analyze to return a steering decision
556
+ vi.mocked(analyze).mockResolvedValue({
557
+ action: 'steer',
558
+ message: 'Please continue with the implementation',
559
+ reasoning: 'Agent idle after compaction',
560
+ confidence: 0.9,
561
+ asi: { why_stuck: 'compaction_interrupt', strategy_used: 'directive' },
562
+ });
563
+
564
+ const postCompactionEntries = [
565
+ { type: 'compaction', summary: 'Earlier conversation summarized' },
566
+ {
567
+ type: 'custom',
568
+ customType: 'loop-state',
569
+ data: {
570
+ active: true,
571
+ outcome: 'Implement feature X',
572
+ provider: 'anthropic',
573
+ modelId: 'claude-haiku',
574
+ interventions: [],
575
+ startedAt: 1000,
576
+ turnCount: 3,
577
+ reframeTier: 0,
578
+ lastSteerTurn: -1,
579
+ },
580
+ },
581
+ ];
582
+
583
+ const ctx = createMockContext(postCompactionEntries, true); // Agent is idle
584
+
585
+ // Simulate session_compact handler flow
586
+ state.loadFromSession(ctx);
587
+
588
+ if (!state.isActive()) {
589
+ updateUI(ctx, null);
590
+ return;
591
+ }
592
+
593
+ updateUI(ctx, state.getState(), { type: 'watching', reframeTier: state.getReframeTier() });
594
+
595
+ // If agent is idle, analyze and steer
596
+ if (ctx.isIdle()) {
597
+ const s = state.getState()!;
598
+ updateUI(ctx, s, {
599
+ type: 'analyzing',
600
+ turn: s.turnCount,
601
+ reframeTier: state.getReframeTier(),
602
+ });
603
+
604
+ const decision = await analyze(
605
+ ctx,
606
+ s,
607
+ true,
608
+ undefined,
609
+ undefined,
610
+ (accumulated: string) => {
611
+ // onDelta callback
612
+ }
613
+ );
614
+
615
+ if (decision.action === 'steer' && decision.message) {
616
+ state.addIntervention({
617
+ turnCount: s.turnCount,
618
+ message: decision.message,
619
+ reasoning: decision.reasoning,
620
+ timestamp: Date.now(),
621
+ asi: decision.asi,
622
+ });
623
+ updateUI(ctx, state.getState(), {
624
+ type: 'steering',
625
+ message: decision.message,
626
+ reframeTier: state.getReframeTier(),
627
+ });
628
+ api.sendUserMessage(decision.message);
629
+ }
630
+ }
631
+
632
+ expect(analyze).toHaveBeenCalledWith(
633
+ ctx,
634
+ expect.objectContaining({ outcome: 'Implement feature X' }),
635
+ true,
636
+ undefined,
637
+ undefined,
638
+ expect.any(Function)
639
+ );
640
+ expect(state.getState()?.interventions).toHaveLength(1);
641
+ expect(api.sendUserMessage).toHaveBeenCalledWith('Please continue with the implementation');
642
+ expect(updateUI).toHaveBeenLastCalledWith(
643
+ ctx,
644
+ expect.any(Object),
645
+ expect.objectContaining({
646
+ type: 'steering',
647
+ message: 'Please continue with the implementation',
648
+ })
649
+ );
650
+ });
651
+
652
+ it('session_compact marks done when goal achieved after compaction', async () => {
653
+ const api = createMockApi();
654
+ const state = new LoopStateManager(api);
655
+
656
+ // Mock analyze to return 'done'
657
+ vi.mocked(analyze).mockResolvedValue({
658
+ action: 'done',
659
+ reasoning: 'Goal achieved',
660
+ confidence: 0.95,
661
+ });
662
+
663
+ const postCompactionEntries = [
664
+ { type: 'compaction', summary: 'Earlier conversation summarized' },
665
+ {
666
+ type: 'custom',
667
+ customType: 'loop-state',
668
+ data: {
669
+ active: true,
670
+ outcome: 'Complete the task',
671
+ provider: 'anthropic',
672
+ modelId: 'claude-haiku',
673
+ interventions: [],
674
+ startedAt: 1000,
675
+ turnCount: 10,
676
+ reframeTier: 0,
677
+ lastSteerTurn: -1,
678
+ },
679
+ },
680
+ ];
681
+
682
+ const ctx = createMockContext(postCompactionEntries, true);
683
+
684
+ // Simulate session_compact handler
685
+ state.loadFromSession(ctx);
686
+
687
+ if (!state.isActive()) {
688
+ updateUI(ctx, null);
689
+ return;
690
+ }
691
+
692
+ updateUI(ctx, state.getState(), { type: 'watching', reframeTier: state.getReframeTier() });
693
+
694
+ if (ctx.isIdle()) {
695
+ const s = state.getState()!;
696
+ const decision = await analyze(ctx, s, true);
697
+
698
+ if (decision.action === 'done') {
699
+ state.resetReframeTier();
700
+ updateUI(ctx, state.getState(), { type: 'done' });
701
+ state.stop();
702
+ }
703
+ }
704
+
705
+ expect(state.isActive()).toBe(false);
706
+ expect(updateUI).toHaveBeenLastCalledWith(ctx, expect.any(Object), { type: 'done' });
707
+ });
708
+
709
+ it('session_compact does not steer when agent is busy', async () => {
710
+ const api = createMockApi();
711
+ const state = new LoopStateManager(api);
712
+
713
+ const postCompactionEntries = [
714
+ { type: 'compaction', summary: 'Earlier conversation summarized' },
715
+ {
716
+ type: 'custom',
717
+ customType: 'loop-state',
718
+ data: {
719
+ active: true,
720
+ outcome: 'Implement feature',
721
+ provider: 'anthropic',
722
+ modelId: 'claude-haiku',
723
+ interventions: [],
724
+ startedAt: 1000,
725
+ turnCount: 3,
726
+ reframeTier: 0,
727
+ lastSteerTurn: -1,
728
+ },
729
+ },
730
+ ];
731
+
732
+ // Agent is NOT idle (busy streaming)
733
+ const ctx = createMockContext(postCompactionEntries, false);
734
+
735
+ // Simulate session_compact handler
736
+ state.loadFromSession(ctx);
737
+
738
+ if (!state.isActive()) {
739
+ updateUI(ctx, null);
740
+ return;
741
+ }
742
+
743
+ updateUI(ctx, state.getState(), { type: 'watching', reframeTier: state.getReframeTier() });
744
+
745
+ // Should NOT analyze/steer when agent is busy
746
+ if (ctx.isIdle()) {
747
+ await analyze(ctx, state.getState()!, true);
748
+ }
749
+
750
+ expect(ctx.isIdle).toHaveBeenCalled();
751
+ expect(analyze).not.toHaveBeenCalled();
752
+ });
753
+ });
754
+ });