@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,770 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
2
+ import type { LoopState } from '../src/types.js';
3
+
4
+ // Mock fs for loadSystemPrompt tests
5
+ vi.mock('node:fs', async () => {
6
+ const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
7
+ return {
8
+ ...actual,
9
+ existsSync: vi.fn(),
10
+ readFileSync: vi.fn(),
11
+ };
12
+ });
13
+
14
+ import { existsSync, readFileSync } from 'node:fs';
15
+ import { loadSystemPrompt } from '../src/core/prompt-loader.js';
16
+ import { SNAPSHOT_LIMIT, updateSnapshot } from '../src/core/snapshot-builder.js';
17
+ import { buildUserPrompt } from '../src/core/prompt-builder.js';
18
+ import { getReframeGuidance } from '../src/core/reframe.js';
19
+ import { extractMetrics } from '../src/core/content-extractor.js';
20
+ import { inferOutcome } from '../src/core/inference.js';
21
+ import { LoopSession } from '../src/session/loop-session.js';
22
+
23
+ beforeEach(() => {
24
+ vi.clearAllMocks();
25
+ vi.stubEnv('PI_CODING_AGENT_DIR', '/home/test/.pi/agent');
26
+ });
27
+
28
+ afterEach(() => {
29
+ vi.unstubAllEnvs();
30
+ });
31
+
32
+ describe('loadSystemPrompt', () => {
33
+ it('returns built-in prompt when no files exist', () => {
34
+ vi.mocked(existsSync).mockReturnValue(false);
35
+
36
+ const result = loadSystemPrompt('/test/cwd');
37
+
38
+ expect(result.source).toBe('built-in');
39
+ expect(result.prompt).toContain('You ensure outcomes');
40
+ expect(result.prompt).toContain('context-specific JSON schema');
41
+ });
42
+
43
+ it('loads project LOOP.md when it exists', () => {
44
+ vi.mocked(existsSync).mockImplementation((path) => {
45
+ return String(path).includes('/test/cwd/.pi/LOOP.md');
46
+ });
47
+ vi.mocked(readFileSync).mockReturnValue('Custom project prompt');
48
+
49
+ const result = loadSystemPrompt('/test/cwd');
50
+
51
+ expect(result.source).toBe('/test/cwd/.pi/LOOP.md');
52
+ expect(result.prompt).toBe('Custom project prompt');
53
+ });
54
+
55
+ it("falls back to global when project doesn't exist", () => {
56
+ vi.mocked(existsSync).mockImplementation((path) => {
57
+ return String(path).includes('/home/test/.pi/agent/LOOP.md');
58
+ });
59
+ vi.mocked(readFileSync).mockReturnValue('Global prompt');
60
+
61
+ const result = loadSystemPrompt('/test/cwd');
62
+
63
+ expect(result.source).toBe('/home/test/.pi/agent/LOOP.md');
64
+ expect(result.prompt).toBe('Global prompt');
65
+ });
66
+
67
+ it('prefers project over global', () => {
68
+ vi.mocked(existsSync).mockReturnValue(true); // Both exist
69
+ vi.mocked(readFileSync).mockReturnValue('Project wins');
70
+
71
+ const result = loadSystemPrompt('/test/cwd');
72
+
73
+ expect(result.source).toBe('/test/cwd/.pi/LOOP.md');
74
+ });
75
+
76
+ it('built-in prompt includes honesty check section', () => {
77
+ vi.mocked(existsSync).mockReturnValue(false); // Neither project nor global exists
78
+
79
+ const result = loadSystemPrompt('/test/cwd');
80
+
81
+ expect(result.source).toBe('built-in');
82
+ expect(result.prompt).toContain('The Honesty Check');
83
+ expect(result.prompt).toContain('Contradicted claims');
84
+ expect(result.prompt).toContain('Missing evidence');
85
+ expect(result.prompt).toContain('Test manipulation');
86
+ expect(result.prompt).toContain('Short-circuiting');
87
+ });
88
+
89
+ it('built-in prompt includes ASI memory section', () => {
90
+ vi.mocked(existsSync).mockReturnValue(false); // Neither project nor global exists
91
+
92
+ const result = loadSystemPrompt('/test/cwd');
93
+
94
+ expect(result.source).toBe('built-in');
95
+ expect(result.prompt).toContain('═══ YOUR MEMORY (ASI) ═══');
96
+ expect(result.prompt).toContain('ASI is your recall across turns');
97
+ expect(result.prompt).toContain('Before deciding, READ your past ASI');
98
+ expect(result.prompt).toContain('Populate it when steering');
99
+ });
100
+ });
101
+
102
+ describe('SNAPSHOT_LIMIT', () => {
103
+ it('is set to 20 messages', () => {
104
+ expect(SNAPSHOT_LIMIT).toBe(20);
105
+ });
106
+ });
107
+
108
+ describe('updateSnapshot', () => {
109
+ it('returns existing buffer when turn already analyzed', () => {
110
+ const mockCtx = {
111
+ sessionManager: {
112
+ getBranch: () => [],
113
+ },
114
+ } as any;
115
+
116
+ const state: LoopState = {
117
+ active: true,
118
+ outcome: 'Test',
119
+ provider: 'anthropic',
120
+ modelId: 'claude',
121
+ interventions: [],
122
+ startedAt: Date.now(),
123
+ turnCount: 5,
124
+ snapshotBuffer: [{ role: 'user', content: 'Old' }],
125
+ lastAnalyzedTurn: 5, // Already analyzed this turn
126
+ };
127
+
128
+ const result = updateSnapshot(mockCtx, state);
129
+
130
+ // Should return existing buffer limited to SNAPSHOT_LIMIT
131
+ expect(result).toEqual([{ role: 'user', content: 'Old' }]);
132
+ });
133
+
134
+ it('updates lastAnalyzedTurn after building snapshot', () => {
135
+ const mockCtx = {
136
+ sessionManager: {
137
+ getBranch: () => [],
138
+ },
139
+ } as any;
140
+
141
+ const state: LoopState = {
142
+ active: true,
143
+ outcome: 'Test',
144
+ provider: 'anthropic',
145
+ modelId: 'claude',
146
+ interventions: [],
147
+ startedAt: Date.now(),
148
+ turnCount: 3,
149
+ snapshotBuffer: [],
150
+ lastAnalyzedTurn: -1,
151
+ };
152
+
153
+ updateSnapshot(mockCtx, state);
154
+
155
+ expect(state.lastAnalyzedTurn).toBe(3);
156
+ });
157
+ });
158
+
159
+ describe('getReframeGuidance', () => {
160
+ it('returns empty string for tier 0 without ineffective pattern', () => {
161
+ const result = getReframeGuidance(0);
162
+ expect(result).toBe('');
163
+ });
164
+
165
+ it('returns pattern warning even for tier 0 when ineffective pattern detected', () => {
166
+ const result = getReframeGuidance(0, {
167
+ detected: true,
168
+ similarCount: 2,
169
+ turnsSinceLastSteer: 3,
170
+ });
171
+ expect(result).toContain('INEFFECTIVE PATTERN DETECTED');
172
+ });
173
+
174
+ it('returns tier 1 guidance', () => {
175
+ const result = getReframeGuidance(1);
176
+ expect(result).toContain('REFRAME TIER 1');
177
+ expect(result).toContain('DIRECTIVE');
178
+ expect(result).toContain('extremely specific');
179
+ });
180
+
181
+ it('returns tier 2 guidance', () => {
182
+ const result = getReframeGuidance(2);
183
+ expect(result).toContain('REFRAME TIER 2');
184
+ expect(result).toContain('SUBGOAL');
185
+ expect(result).toContain('smaller, verifiable milestone');
186
+ });
187
+
188
+ it('returns tier 3 guidance', () => {
189
+ const result = getReframeGuidance(3);
190
+ expect(result).toContain('REFRAME TIER 3');
191
+ expect(result).toContain('PIVOT');
192
+ expect(result).toContain('completely different strategy');
193
+ });
194
+
195
+ it('returns tier 4 guidance', () => {
196
+ const result = getReframeGuidance(4);
197
+ expect(result).toContain('REFRAME TIER 4');
198
+ expect(result).toContain('MINIMAL SLICE');
199
+ expect(result).toContain('smallest working version');
200
+ });
201
+
202
+ it('includes ineffective pattern warning when detected', () => {
203
+ const result = getReframeGuidance(2, {
204
+ detected: true,
205
+ similarCount: 2,
206
+ turnsSinceLastSteer: 3,
207
+ });
208
+ expect(result).toContain('INEFFECTIVE PATTERN DETECTED');
209
+ expect(result).toContain('Last 2 steering messages');
210
+ expect(result).toContain('no progress in 3 turns');
211
+ });
212
+
213
+ it('does not include pattern warning when not detected', () => {
214
+ const result = getReframeGuidance(2, {
215
+ detected: false,
216
+ similarCount: 1,
217
+ turnsSinceLastSteer: 1,
218
+ });
219
+ expect(result).not.toContain('INEFFECTIVE PATTERN DETECTED');
220
+ });
221
+ });
222
+
223
+ describe('buildUserPrompt', () => {
224
+ it('includes reframe guidance when tier > 0', () => {
225
+ const state: LoopState = {
226
+ active: true,
227
+ outcome: 'Implement auth',
228
+ provider: 'anthropic',
229
+ modelId: 'claude',
230
+ interventions: [],
231
+ startedAt: Date.now(),
232
+ turnCount: 1,
233
+ reframeTier: 2,
234
+ };
235
+
236
+ const result = buildUserPrompt(state, [], true);
237
+ expect(result).toContain('REFRAME TIER 2');
238
+ expect(result).toContain('SUBGOAL');
239
+ });
240
+
241
+ it('does not include reframe guidance when tier is 0', () => {
242
+ const state: LoopState = {
243
+ active: true,
244
+ outcome: 'Implement auth',
245
+ provider: 'anthropic',
246
+ modelId: 'claude',
247
+ interventions: [],
248
+ startedAt: Date.now(),
249
+ turnCount: 1,
250
+ reframeTier: 0,
251
+ };
252
+
253
+ const result = buildUserPrompt(state, [], true);
254
+ expect(result).not.toContain('REFRAME TIER');
255
+ });
256
+
257
+ it('includes ineffective pattern warning in prompt', () => {
258
+ const state: LoopState = {
259
+ active: true,
260
+ outcome: 'Implement auth',
261
+ provider: 'anthropic',
262
+ modelId: 'claude',
263
+ interventions: [
264
+ { turnCount: 1, message: 'Focus on auth', reasoning: 'Test', timestamp: Date.now() },
265
+ ],
266
+ startedAt: Date.now(),
267
+ turnCount: 4,
268
+ reframeTier: 1,
269
+ };
270
+
271
+ const ineffectivePattern = { detected: true, similarCount: 1, turnsSinceLastSteer: 3 };
272
+ const result = buildUserPrompt(state, [], true, ineffectivePattern);
273
+
274
+ expect(result).toContain('INEFFECTIVE PATTERN DETECTED');
275
+ expect(result).toContain('no progress in 3 turns');
276
+ });
277
+
278
+ it('includes outcome and agent status', () => {
279
+ const state: LoopState = {
280
+ active: true,
281
+ outcome: 'Build API',
282
+ provider: 'anthropic',
283
+ modelId: 'claude',
284
+ interventions: [],
285
+ startedAt: Date.now(),
286
+ turnCount: 1,
287
+ };
288
+
289
+ const result = buildUserPrompt(state, [], true);
290
+ expect(result).toContain('DESIRED OUTCOME:');
291
+ expect(result).toContain('Build API');
292
+ expect(result).toContain('AGENT STATUS: IDLE');
293
+ });
294
+
295
+ it('shows WORKING status when agent is not idle', () => {
296
+ const state: LoopState = {
297
+ active: true,
298
+ outcome: 'Build API',
299
+ provider: 'anthropic',
300
+ modelId: 'claude',
301
+ interventions: [],
302
+ startedAt: Date.now(),
303
+ turnCount: 1,
304
+ };
305
+
306
+ const result = buildUserPrompt(state, [], false);
307
+ expect(result).toContain('AGENT STATUS: WORKING');
308
+ });
309
+
310
+ it('includes conversation messages in prompt', () => {
311
+ const state: LoopState = {
312
+ active: true,
313
+ outcome: 'Build API',
314
+ provider: 'anthropic',
315
+ modelId: 'claude',
316
+ interventions: [],
317
+ startedAt: Date.now(),
318
+ turnCount: 1,
319
+ };
320
+
321
+ const snapshot = [
322
+ { role: 'user' as const, content: 'Hello' },
323
+ { role: 'assistant' as const, content: 'Hi there' },
324
+ ];
325
+
326
+ const result = buildUserPrompt(state, snapshot, true);
327
+ expect(result).toContain('USER: Hello');
328
+ expect(result).toContain('ASSISTANT: Hi there');
329
+ });
330
+
331
+ it('includes intervention history', () => {
332
+ const state: LoopState = {
333
+ active: true,
334
+ outcome: 'Build API',
335
+ provider: 'anthropic',
336
+ modelId: 'claude',
337
+ interventions: [
338
+ { turnCount: 1, message: 'Focus on X', reasoning: 'Drift', timestamp: 123456 },
339
+ ],
340
+ startedAt: Date.now(),
341
+ turnCount: 2,
342
+ };
343
+
344
+ const result = buildUserPrompt(state, [], true);
345
+ expect(result).toContain('YOUR INTERVENTION HISTORY (with ASI observations):');
346
+ expect(result).toContain('[1] Turn 1:');
347
+ expect(result).toContain('Focus on X');
348
+ });
349
+
350
+ it('includes ASI from previous interventions', () => {
351
+ const state: LoopState = {
352
+ active: true,
353
+ outcome: 'Build API',
354
+ provider: 'anthropic',
355
+ modelId: 'claude',
356
+ interventions: [
357
+ {
358
+ turnCount: 1,
359
+ message: 'Focus on tests',
360
+ reasoning: 'Drift',
361
+ timestamp: 123456,
362
+ asi: {
363
+ why_stuck: 'agent refactoring without tests',
364
+ strategy_used: 'directive',
365
+ },
366
+ },
367
+ ],
368
+ startedAt: Date.now(),
369
+ turnCount: 2,
370
+ };
371
+
372
+ const result = buildUserPrompt(state, [], true);
373
+ expect(result).toContain('ASI {');
374
+ expect(result).toContain('why_stuck: "agent refactoring without tests"');
375
+ expect(result).toContain('strategy_used: "directive"');
376
+ });
377
+
378
+ it('surfaces recurring ASI patterns in summary', () => {
379
+ const state: LoopState = {
380
+ active: true,
381
+ outcome: 'Build API',
382
+ provider: 'anthropic',
383
+ modelId: 'claude',
384
+ interventions: [
385
+ {
386
+ turnCount: 1,
387
+ message: 'Focus on tests',
388
+ reasoning: 'Drift',
389
+ timestamp: 123456,
390
+ asi: { suspicious_claim: true, pattern: 'unverified' },
391
+ },
392
+ {
393
+ turnCount: 2,
394
+ message: 'Verify the output',
395
+ reasoning: 'Contradiction',
396
+ timestamp: 123457,
397
+ asi: { suspicious_claim: true, pattern: 'contradicted' },
398
+ },
399
+ {
400
+ turnCount: 3,
401
+ message: 'Show proof',
402
+ reasoning: 'Unverified',
403
+ timestamp: 123458,
404
+ asi: { requires_proof: true },
405
+ },
406
+ ],
407
+ startedAt: Date.now(),
408
+ turnCount: 4,
409
+ };
410
+
411
+ const result = buildUserPrompt(state, [], true);
412
+ expect(result).toContain('ASI PATTERN SUMMARY');
413
+ expect(result).toContain('Pattern seen 2x: "suspicious_claim"');
414
+ expect(result).toContain('⚠️ Previous interventions flagged suspicious claims');
415
+ });
416
+
417
+ it('warns about verification failures in ASI summary', () => {
418
+ const state: LoopState = {
419
+ active: true,
420
+ outcome: 'Build API',
421
+ provider: 'anthropic',
422
+ modelId: 'claude',
423
+ interventions: [
424
+ {
425
+ turnCount: 1,
426
+ message: 'Focus on tests',
427
+ reasoning: 'Drift',
428
+ timestamp: 123456,
429
+ asi: { claim_status: 'contradicted_by_tool_output' },
430
+ },
431
+ {
432
+ turnCount: 2,
433
+ message: 'Verify the output',
434
+ reasoning: 'Contradiction',
435
+ timestamp: 123457,
436
+ asi: { claim_status: 'unverified' },
437
+ },
438
+ {
439
+ turnCount: 3,
440
+ message: 'Show proof',
441
+ reasoning: 'Unverified',
442
+ timestamp: 123458,
443
+ asi: { claim_status: 'contradicted_by_tool_output' },
444
+ },
445
+ ],
446
+ startedAt: Date.now(),
447
+ turnCount: 4,
448
+ };
449
+
450
+ const result = buildUserPrompt(state, [], true);
451
+ expect(result).toContain('ASI PATTERN SUMMARY');
452
+ expect(result).toContain('⚠️ 3 interventions involved unverified/contradicted claims');
453
+ expect(result).toContain('agent has pattern of unreliable reporting');
454
+ });
455
+
456
+ it('detects suspicious keywords in ASI values', () => {
457
+ const state: LoopState = {
458
+ active: true,
459
+ outcome: 'Build API',
460
+ provider: 'anthropic',
461
+ modelId: 'claude',
462
+ interventions: [
463
+ {
464
+ turnCount: 1,
465
+ message: 'Check for cheating',
466
+ reasoning: 'Suspicious',
467
+ timestamp: 123456,
468
+ asi: { observation: 'agent_attempted_to_fake_test_results' },
469
+ },
470
+ ],
471
+ startedAt: Date.now(),
472
+ turnCount: 2,
473
+ };
474
+
475
+ const result = buildUserPrompt(state, [], true);
476
+ expect(result).toContain('ASI PATTERN SUMMARY');
477
+ expect(result).toContain('⚠️ Previous interventions flagged suspicious claims');
478
+ });
479
+
480
+ it('handles empty ASI gracefully', () => {
481
+ const state: LoopState = {
482
+ active: true,
483
+ outcome: 'Build API',
484
+ provider: 'anthropic',
485
+ modelId: 'claude',
486
+ interventions: [
487
+ {
488
+ turnCount: 1,
489
+ message: 'Focus on X',
490
+ reasoning: 'Drift',
491
+ timestamp: 123456,
492
+ // No ASI
493
+ },
494
+ ],
495
+ startedAt: Date.now(),
496
+ turnCount: 2,
497
+ };
498
+
499
+ const result = buildUserPrompt(state, [], true);
500
+ // Should not show ASI section for empty ASI
501
+ expect(result).toContain('[1] Turn 1: "Focus on X"');
502
+ expect(result).not.toContain('ASI {}');
503
+ // No ASI summary when no patterns
504
+ expect(result).not.toContain('ASI PATTERN SUMMARY');
505
+ });
506
+
507
+ it('does not include metrics section when only natural language text', () => {
508
+ const state: LoopState = {
509
+ active: true,
510
+ outcome: 'Build API',
511
+ provider: 'anthropic',
512
+ modelId: 'claude',
513
+ interventions: [],
514
+ startedAt: Date.now(),
515
+ turnCount: 1,
516
+ };
517
+
518
+ const snapshot = [
519
+ { role: 'assistant' as const, content: 'Coverage is now 87% and tests passing' },
520
+ ];
521
+
522
+ const result = buildUserPrompt(state, snapshot, true);
523
+ // The LLM reads raw text; no metrics section inserted for natural language
524
+ expect(result).not.toContain('METRICS DETECTED IN CONVERSATION:');
525
+ });
526
+
527
+ it('includes METRIC section when explicit markers present', () => {
528
+ const state: LoopState = {
529
+ active: true,
530
+ outcome: 'Build API',
531
+ provider: 'anthropic',
532
+ modelId: 'claude',
533
+ interventions: [],
534
+ startedAt: Date.now(),
535
+ turnCount: 1,
536
+ };
537
+
538
+ const snapshot = [
539
+ { role: 'assistant' as const, content: 'METRIC coverage=87\nAll tests passing' },
540
+ ];
541
+
542
+ const result = buildUserPrompt(state, snapshot, true);
543
+ expect(result).toContain('METRICS DETECTED IN CONVERSATION:');
544
+ expect(result).toContain('coverage: 87');
545
+ });
546
+ });
547
+
548
+ describe('extractMetrics', () => {
549
+ it('extracts autoresearch-style METRIC lines', () => {
550
+ const text = 'METRIC coverage=87.5\nMETRIC tests=42';
551
+ const result = extractMetrics(text);
552
+ expect(result).toEqual({ coverage: 87.5, tests: 42 });
553
+ });
554
+
555
+ it('returns empty object when no metrics found', () => {
556
+ const text = 'Coverage is now 87% and tests passing';
557
+ const result = extractMetrics(text);
558
+ expect(result).toEqual({});
559
+ });
560
+
561
+ it('returns empty object for regular conversation', () => {
562
+ const text = 'Just some regular conversation without METRIC markers';
563
+ const result = extractMetrics(text);
564
+ expect(result).toEqual({});
565
+ });
566
+
567
+ it('handles decimal values in METRIC lines', () => {
568
+ const text = 'METRIC accuracy=94.73';
569
+ const result = extractMetrics(text);
570
+ expect(result.accuracy).toBe(94.73);
571
+ });
572
+
573
+ it('ignores percentage patterns without METRIC marker', () => {
574
+ // The LLM supervisor reads the raw text - no need for us to parse
575
+ const text = 'Test coverage: 87% and everything looks good';
576
+ const result = extractMetrics(text);
577
+ expect(result).toEqual({});
578
+ });
579
+ });
580
+
581
+ describe('inferOutcome', () => {
582
+ // Spy on LoopSession prototype methods
583
+ let ensureStartedSpy: ReturnType<typeof vi.spyOn>;
584
+ let promptSpy: ReturnType<typeof vi.spyOn>;
585
+ let disposeSpy: ReturnType<typeof vi.spyOn>;
586
+
587
+ beforeEach(() => {
588
+ // Create spies on the prototype methods
589
+ ensureStartedSpy = vi.spyOn(LoopSession.prototype, 'ensureStarted');
590
+ promptSpy = vi.spyOn(LoopSession.prototype, 'prompt');
591
+ disposeSpy = vi.spyOn(LoopSession.prototype, 'dispose');
592
+
593
+ // Set default success behavior
594
+ ensureStartedSpy.mockResolvedValue(true);
595
+ promptSpy.mockResolvedValue('Build auth system');
596
+ });
597
+
598
+ afterEach(() => {
599
+ // Restore original implementations
600
+ ensureStartedSpy.mockRestore();
601
+ promptSpy.mockRestore();
602
+ disposeSpy.mockRestore();
603
+ });
604
+
605
+ it('returns null when sessionManager has no branch entries', async () => {
606
+ const mockCtx = {
607
+ sessionManager: {
608
+ getBranch: () => [],
609
+ },
610
+ modelRegistry: {
611
+ find: () => ({ name: 'test-model' }),
612
+ },
613
+ } as any;
614
+
615
+ const result = await inferOutcome(mockCtx, 'anthropic', 'claude');
616
+ expect(result).toBeNull();
617
+ });
618
+
619
+ it('returns null when model not found in registry', async () => {
620
+ const mockCtx = {
621
+ sessionManager: {
622
+ getBranch: () => [{ type: 'message', message: { role: 'user', content: 'Hello' } }],
623
+ },
624
+ modelRegistry: {
625
+ find: () => null, // Model not found
626
+ },
627
+ } as any;
628
+
629
+ ensureStartedSpy.mockResolvedValue(false);
630
+
631
+ const result = await inferOutcome(mockCtx, 'anthropic', 'claude');
632
+ expect(result).toBeNull();
633
+ });
634
+
635
+ it('returns null when session fails to start', async () => {
636
+ const mockCtx = {
637
+ sessionManager: {
638
+ getBranch: () => [
639
+ {
640
+ type: 'message',
641
+ message: { role: 'user', content: [{ type: 'text', text: 'Build auth' }] },
642
+ },
643
+ ],
644
+ },
645
+ modelRegistry: {
646
+ find: () => ({ name: 'test-model' }),
647
+ },
648
+ } as any;
649
+
650
+ ensureStartedSpy.mockResolvedValue(false);
651
+
652
+ const result = await inferOutcome(mockCtx, 'anthropic', 'claude');
653
+ expect(result).toBeNull();
654
+ });
655
+
656
+ it('extracts outcome successfully', async () => {
657
+ const mockCtx = {
658
+ sessionManager: {
659
+ getBranch: () => [
660
+ {
661
+ type: 'message',
662
+ message: { role: 'user', content: [{ type: 'text', text: 'Build auth' }] },
663
+ },
664
+ ],
665
+ },
666
+ modelRegistry: {
667
+ find: () => ({ name: 'test-model' }),
668
+ },
669
+ } as any;
670
+
671
+ promptSpy.mockResolvedValue('Add JWT authentication with refresh tokens');
672
+
673
+ const result = await inferOutcome(mockCtx, 'anthropic', 'claude');
674
+
675
+ expect(result).toBe('Add JWT authentication with refresh tokens');
676
+ });
677
+
678
+ it('cleans up result: removes quotes, newlines, and limits length', async () => {
679
+ const mockCtx = {
680
+ sessionManager: {
681
+ getBranch: () => [
682
+ {
683
+ type: 'message',
684
+ message: { role: 'user', content: [{ type: 'text', text: 'Build auth' }] },
685
+ },
686
+ ],
687
+ },
688
+ modelRegistry: {
689
+ find: () => ({ name: 'test-model' }),
690
+ },
691
+ } as any;
692
+
693
+ promptSpy.mockResolvedValue('"Fix the\nbug in the handler"');
694
+
695
+ const result = await inferOutcome(mockCtx, 'anthropic', 'claude');
696
+
697
+ expect(result).toBe('Fix the bug in the handler'); // No quotes, newlines replaced with spaces
698
+ expect(result.length).toBeLessThanOrEqual(200);
699
+ });
700
+
701
+ it('returns null when prompt returns null', async () => {
702
+ const mockCtx = {
703
+ sessionManager: {
704
+ getBranch: () => [
705
+ {
706
+ type: 'message',
707
+ message: { role: 'user', content: [{ type: 'text', text: 'Build auth' }] },
708
+ },
709
+ ],
710
+ },
711
+ modelRegistry: {
712
+ find: () => ({ name: 'test-model' }),
713
+ },
714
+ } as any;
715
+
716
+ promptSpy.mockResolvedValue(null);
717
+
718
+ const result = await inferOutcome(mockCtx, 'anthropic', 'claude');
719
+
720
+ expect(result).toBeNull();
721
+ });
722
+
723
+ it('returns null when exception thrown', async () => {
724
+ const mockCtx = {
725
+ sessionManager: {
726
+ getBranch: () => [
727
+ {
728
+ type: 'message',
729
+ message: { role: 'user', content: [{ type: 'text', text: 'Build auth' }] },
730
+ },
731
+ ],
732
+ },
733
+ modelRegistry: {
734
+ find: () => ({ name: 'test-model' }),
735
+ },
736
+ } as any;
737
+
738
+ ensureStartedSpy.mockRejectedValue(new Error('Network error'));
739
+
740
+ const result = await inferOutcome(mockCtx, 'anthropic', 'claude');
741
+
742
+ expect(result).toBeNull();
743
+ });
744
+
745
+ it('uses goal extraction system prompt', async () => {
746
+ const mockCtx = {
747
+ sessionManager: {
748
+ getBranch: () => [
749
+ {
750
+ type: 'message',
751
+ message: { role: 'user', content: [{ type: 'text', text: 'Build auth' }] },
752
+ },
753
+ ],
754
+ },
755
+ modelRegistry: {
756
+ find: () => ({ name: 'test-model' }),
757
+ },
758
+ } as any;
759
+
760
+ await inferOutcome(mockCtx, 'anthropic', 'claude');
761
+
762
+ // Verify the system prompt passed to ensureStarted contains goal extraction content
763
+ expect(ensureStartedSpy).toHaveBeenCalledWith(
764
+ mockCtx,
765
+ 'anthropic',
766
+ 'claude',
767
+ expect.stringContaining('goal extraction')
768
+ );
769
+ });
770
+ });