@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.
- package/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +285 -0
- package/media/demo.mp4 +0 -0
- package/media/pi-loop.jpg +0 -0
- package/media/screenshot.png +0 -0
- package/package.json +89 -0
- package/src/core/analyzer.ts +51 -0
- package/src/core/content-extractor.ts +79 -0
- package/src/core/inference.ts +137 -0
- package/src/core/prompt-builder.ts +217 -0
- package/src/core/prompt-loader.ts +126 -0
- package/src/core/reframe.ts +30 -0
- package/src/core/snapshot-builder.ts +252 -0
- package/src/global-config.ts +38 -0
- package/src/index.ts +532 -0
- package/src/session/client.ts +47 -0
- package/src/session/loop-session.ts +102 -0
- package/src/session/response-parser.ts +37 -0
- package/src/state/manager.ts +164 -0
- package/src/state/patterns.ts +81 -0
- package/src/state/reframe.ts +33 -0
- package/src/subagent-detector.ts +94 -0
- package/src/types.ts +83 -0
- package/src/ui/animations.ts +70 -0
- package/src/ui/model-picker.ts +79 -0
- package/src/ui/renderer.ts +257 -0
- package/src/ui/status-widget.ts +30 -0
- package/src/ui/types.ts +48 -0
- package/tests/compaction.test.ts +754 -0
- package/tests/continue-action-regression.test.ts +456 -0
- package/tests/engine.test.ts +770 -0
- package/tests/ephemeral-supervision.test.ts +391 -0
- package/tests/full-fidelity-snapshot.test.ts +843 -0
- package/tests/parsing.test.ts +303 -0
- package/tests/state.test.ts +525 -0
- package/tests/status-widget.test.ts +703 -0
- package/tests/subagent-detector.test.ts +191 -0
- package/tests/supervise-command.test.ts +381 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +15 -0
|
@@ -0,0 +1,391 @@
|
|
|
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/inference.js', () => ({
|
|
10
|
+
inferOutcome: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
vi.mock('../src/core/prompt-loader.js', () => ({
|
|
14
|
+
loadSystemPrompt: vi.fn().mockReturnValue({ prompt: 'test prompt', source: 'built-in' }),
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
vi.mock('../src/ui/renderer.js', () => ({
|
|
18
|
+
updateUI: vi.fn(),
|
|
19
|
+
toggleWidget: vi.fn(),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('../src/ui/model-picker.js', () => ({
|
|
23
|
+
pickModel: vi.fn(),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('../src/ui/settings-panel.js', () => ({
|
|
27
|
+
openSettings: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('../src/global-config.js', () => ({
|
|
31
|
+
loadGlobalModel: vi.fn().mockReturnValue(null),
|
|
32
|
+
saveGlobalModel: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('../src/session/client.js', () => ({
|
|
36
|
+
disposeSession: vi.fn(),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock('../src/subagent-detector.js', () => ({
|
|
40
|
+
checkChildPiProcesses: vi.fn().mockResolvedValue({ hasActiveSubagents: false, count: 0 }),
|
|
41
|
+
waitForSubagents: vi
|
|
42
|
+
.fn()
|
|
43
|
+
.mockResolvedValue({ completed: true, finalStatus: { hasActiveSubagents: false, count: 0 } }),
|
|
44
|
+
}));
|
|
45
|
+
|
|
46
|
+
import { updateUI } from '../src/ui/renderer.js';
|
|
47
|
+
import { disposeSession } from '../src/session/client.js';
|
|
48
|
+
|
|
49
|
+
// Mock ExtensionAPI
|
|
50
|
+
function createMockApi() {
|
|
51
|
+
return {
|
|
52
|
+
appendEntry: vi.fn(),
|
|
53
|
+
on: vi.fn(),
|
|
54
|
+
registerCommand: vi.fn(),
|
|
55
|
+
registerTool: vi.fn(),
|
|
56
|
+
sendUserMessage: vi.fn(),
|
|
57
|
+
sendMessage: vi.fn(),
|
|
58
|
+
events: { emit: vi.fn(), on: vi.fn() },
|
|
59
|
+
} as any;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function createMockContext(entries: any[] = [], isIdle = true) {
|
|
63
|
+
return {
|
|
64
|
+
ui: {
|
|
65
|
+
notify: vi.fn(),
|
|
66
|
+
setStatus: vi.fn(),
|
|
67
|
+
setWidget: vi.fn(),
|
|
68
|
+
setWorkingMessage: vi.fn(),
|
|
69
|
+
},
|
|
70
|
+
hasUI: true,
|
|
71
|
+
cwd: '/test',
|
|
72
|
+
sessionManager: {
|
|
73
|
+
getBranch: vi.fn().mockReturnValue(entries),
|
|
74
|
+
},
|
|
75
|
+
modelRegistry: {},
|
|
76
|
+
model: undefined,
|
|
77
|
+
isIdle: vi.fn().mockReturnValue(isIdle),
|
|
78
|
+
abort: vi.fn(),
|
|
79
|
+
hasPendingMessages: vi.fn().mockReturnValue(false),
|
|
80
|
+
shutdown: vi.fn(),
|
|
81
|
+
getContextUsage: vi.fn(),
|
|
82
|
+
compact: vi.fn(),
|
|
83
|
+
getSystemPrompt: vi.fn().mockReturnValue('test'),
|
|
84
|
+
} as any;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('Ephemeral Supervision - idle agent clears supervision', () => {
|
|
88
|
+
let api: ReturnType<typeof createMockApi>;
|
|
89
|
+
let state: LoopStateManager;
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
api = createMockApi();
|
|
93
|
+
state = new LoopStateManager(api);
|
|
94
|
+
vi.clearAllMocks();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
function startActiveSupervision() {
|
|
98
|
+
state.start('Test goal', 'anthropic', 'claude-haiku');
|
|
99
|
+
state.incrementTurnCount();
|
|
100
|
+
expect(state.isActive()).toBe(true);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function createSessionWithSupervision() {
|
|
104
|
+
return [
|
|
105
|
+
{ type: 'message', message: { role: 'user', content: 'Hello' } },
|
|
106
|
+
{
|
|
107
|
+
type: 'custom',
|
|
108
|
+
customType: 'loop-state',
|
|
109
|
+
data: {
|
|
110
|
+
active: true,
|
|
111
|
+
outcome: 'Test goal',
|
|
112
|
+
provider: 'anthropic',
|
|
113
|
+
modelId: 'claude-haiku',
|
|
114
|
+
interventions: [],
|
|
115
|
+
startedAt: Date.now(),
|
|
116
|
+
turnCount: 1,
|
|
117
|
+
reframeTier: 0,
|
|
118
|
+
lastSteerTurn: 0,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe('session_start (crash resume)', () => {
|
|
125
|
+
it('clears supervision when agent is idle', () => {
|
|
126
|
+
startActiveSupervision();
|
|
127
|
+
|
|
128
|
+
// Simulate session_start with idle agent (e.g., after crash)
|
|
129
|
+
const entries = createSessionWithSupervision();
|
|
130
|
+
const ctx = createMockContext(entries, true /* idle */);
|
|
131
|
+
|
|
132
|
+
state.loadFromSession(ctx);
|
|
133
|
+
|
|
134
|
+
// Ephemeral rule: idle agent means supervision is cleared
|
|
135
|
+
expect(state.isActive()).toBe(true); // Still active after load
|
|
136
|
+
|
|
137
|
+
// But onSessionLoad handler should stop it
|
|
138
|
+
if (state.isActive() && ctx.isIdle()) {
|
|
139
|
+
state.stop();
|
|
140
|
+
disposeSession();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
expect(state.isActive()).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('keeps supervision when agent is working', () => {
|
|
147
|
+
startActiveSupervision();
|
|
148
|
+
|
|
149
|
+
// Simulate session_start with working agent
|
|
150
|
+
const entries = createSessionWithSupervision();
|
|
151
|
+
const ctx = createMockContext(entries, false /* working */);
|
|
152
|
+
|
|
153
|
+
state.loadFromSession(ctx);
|
|
154
|
+
|
|
155
|
+
// Ephemeral rule: working agent means supervision continues
|
|
156
|
+
expect(state.isActive()).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('session_start with resume reason (resume another session)', () => {
|
|
161
|
+
it('clears supervision when agent is idle', () => {
|
|
162
|
+
startActiveSupervision();
|
|
163
|
+
|
|
164
|
+
const entries = createSessionWithSupervision();
|
|
165
|
+
const ctx = createMockContext(entries, true /* idle */);
|
|
166
|
+
|
|
167
|
+
state.loadFromSession(ctx);
|
|
168
|
+
|
|
169
|
+
// Ephemeral rule applies
|
|
170
|
+
if (state.isActive() && ctx.isIdle()) {
|
|
171
|
+
state.stop();
|
|
172
|
+
disposeSession();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
expect(state.isActive()).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('keeps supervision when agent is working', () => {
|
|
179
|
+
startActiveSupervision();
|
|
180
|
+
|
|
181
|
+
const entries = createSessionWithSupervision();
|
|
182
|
+
const ctx = createMockContext(entries, false /* working */);
|
|
183
|
+
|
|
184
|
+
state.loadFromSession(ctx);
|
|
185
|
+
|
|
186
|
+
expect(state.isActive()).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe('session_tree (navigate history)', () => {
|
|
191
|
+
it('clears supervision when navigating to history while idle', () => {
|
|
192
|
+
startActiveSupervision();
|
|
193
|
+
|
|
194
|
+
const entries = createSessionWithSupervision();
|
|
195
|
+
const ctx = createMockContext(entries, true /* idle */);
|
|
196
|
+
|
|
197
|
+
state.loadFromSession(ctx);
|
|
198
|
+
|
|
199
|
+
// Ephemeral rule: viewing history + idle = no supervision
|
|
200
|
+
if (state.isActive() && ctx.isIdle()) {
|
|
201
|
+
state.stop();
|
|
202
|
+
disposeSession();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
expect(state.isActive()).toBe(false);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('keeps supervision when at current head with working agent', () => {
|
|
209
|
+
startActiveSupervision();
|
|
210
|
+
|
|
211
|
+
const entries = createSessionWithSupervision();
|
|
212
|
+
const ctx = createMockContext(entries, false /* working */);
|
|
213
|
+
|
|
214
|
+
state.loadFromSession(ctx);
|
|
215
|
+
|
|
216
|
+
expect(state.isActive()).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
describe('session_start with fork reason (fork session)', () => {
|
|
221
|
+
it('clears supervision when agent is idle', () => {
|
|
222
|
+
startActiveSupervision();
|
|
223
|
+
|
|
224
|
+
const entries = createSessionWithSupervision();
|
|
225
|
+
const ctx = createMockContext(entries, true /* idle */);
|
|
226
|
+
|
|
227
|
+
state.loadFromSession(ctx);
|
|
228
|
+
|
|
229
|
+
if (state.isActive() && ctx.isIdle()) {
|
|
230
|
+
state.stop();
|
|
231
|
+
disposeSession();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
expect(state.isActive()).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('keeps supervision when agent is working', () => {
|
|
238
|
+
startActiveSupervision();
|
|
239
|
+
|
|
240
|
+
const entries = createSessionWithSupervision();
|
|
241
|
+
const ctx = createMockContext(entries, false /* working */);
|
|
242
|
+
|
|
243
|
+
state.loadFromSession(ctx);
|
|
244
|
+
|
|
245
|
+
expect(state.isActive()).toBe(true);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe('Ephemeral Supervision - compaction behavior', () => {
|
|
251
|
+
let api: ReturnType<typeof createMockApi>;
|
|
252
|
+
let state: LoopStateManager;
|
|
253
|
+
|
|
254
|
+
beforeEach(() => {
|
|
255
|
+
api = createMockApi();
|
|
256
|
+
state = new LoopStateManager(api);
|
|
257
|
+
vi.clearAllMocks();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
function createSessionWithSupervision() {
|
|
261
|
+
return [
|
|
262
|
+
{ type: 'compaction', summary: 'Earlier conversation' },
|
|
263
|
+
{ type: 'message', message: { role: 'user', content: 'Continue' } },
|
|
264
|
+
{
|
|
265
|
+
type: 'custom',
|
|
266
|
+
customType: 'loop-state',
|
|
267
|
+
data: {
|
|
268
|
+
active: true,
|
|
269
|
+
outcome: 'Test goal',
|
|
270
|
+
provider: 'anthropic',
|
|
271
|
+
modelId: 'claude-haiku',
|
|
272
|
+
interventions: [],
|
|
273
|
+
startedAt: Date.now(),
|
|
274
|
+
turnCount: 5,
|
|
275
|
+
reframeTier: 1,
|
|
276
|
+
lastSteerTurn: 3,
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
];
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
it('continues supervision when agent is working after compaction (long-horizon sessions)', () => {
|
|
283
|
+
// Start supervision
|
|
284
|
+
state.start('Test goal', 'anthropic', 'claude-haiku');
|
|
285
|
+
state.incrementTurnCount();
|
|
286
|
+
expect(state.isActive()).toBe(true);
|
|
287
|
+
|
|
288
|
+
// Simulate post-compaction state with WORKING agent
|
|
289
|
+
const entries = createSessionWithSupervision();
|
|
290
|
+
const ctx = createMockContext(entries, false /* working, not idle */);
|
|
291
|
+
|
|
292
|
+
state.loadFromSession(ctx);
|
|
293
|
+
|
|
294
|
+
// Ephemeral rule: working agent means supervision continues
|
|
295
|
+
// This enables long-horizon supervised sessions that auto-compact
|
|
296
|
+
expect(state.isActive()).toBe(true);
|
|
297
|
+
expect(state.getState()?.turnCount).toBe(5);
|
|
298
|
+
expect(state.getReframeTier()).toBe(1);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('clears supervision when agent is idle after compaction', () => {
|
|
302
|
+
// Start supervision
|
|
303
|
+
state.start('Test goal', 'anthropic', 'claude-haiku');
|
|
304
|
+
state.incrementTurnCount();
|
|
305
|
+
expect(state.isActive()).toBe(true);
|
|
306
|
+
|
|
307
|
+
// Simulate post-compaction state with IDLE agent
|
|
308
|
+
const entries = createSessionWithSupervision();
|
|
309
|
+
const ctx = createMockContext(entries, true /* idle */);
|
|
310
|
+
|
|
311
|
+
state.loadFromSession(ctx);
|
|
312
|
+
|
|
313
|
+
// Ephemeral rule: idle agent means supervision is cleared
|
|
314
|
+
if (state.isActive() && ctx.isIdle()) {
|
|
315
|
+
state.stop();
|
|
316
|
+
disposeSession();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
expect(state.isActive()).toBe(false);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
describe('Ephemeral Supervision - UI notifications', () => {
|
|
324
|
+
let api: ReturnType<typeof createMockApi>;
|
|
325
|
+
let state: LoopStateManager;
|
|
326
|
+
|
|
327
|
+
beforeEach(() => {
|
|
328
|
+
api = createMockApi();
|
|
329
|
+
state = new LoopStateManager(api);
|
|
330
|
+
vi.clearAllMocks();
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
function createSessionWithSupervision() {
|
|
334
|
+
return [
|
|
335
|
+
{
|
|
336
|
+
type: 'custom',
|
|
337
|
+
customType: 'loop-state',
|
|
338
|
+
data: {
|
|
339
|
+
active: true,
|
|
340
|
+
outcome: 'Test goal',
|
|
341
|
+
provider: 'anthropic',
|
|
342
|
+
modelId: 'claude-haiku',
|
|
343
|
+
interventions: [],
|
|
344
|
+
startedAt: Date.now(),
|
|
345
|
+
turnCount: 1,
|
|
346
|
+
reframeTier: 0,
|
|
347
|
+
lastSteerTurn: 0,
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
it('notifies user when supervision is cleared on idle session load', () => {
|
|
354
|
+
const entries = createSessionWithSupervision();
|
|
355
|
+
const notify = vi.fn();
|
|
356
|
+
const ctx = {
|
|
357
|
+
...createMockContext(entries, true /* idle */),
|
|
358
|
+
ui: { ...createMockContext().ui, notify },
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
state.loadFromSession(ctx);
|
|
362
|
+
|
|
363
|
+
// Simulate onSessionLoad notification
|
|
364
|
+
if (state.isActive() && ctx.isIdle()) {
|
|
365
|
+
notify('Supervision cleared: agent is idle', 'info');
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
expect(notify).toHaveBeenCalledWith('Supervision cleared: agent is idle', 'info');
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('notifies user when supervision is cleared after compaction', () => {
|
|
372
|
+
const entries = createSessionWithSupervision();
|
|
373
|
+
const notify = vi.fn();
|
|
374
|
+
const ctx = {
|
|
375
|
+
...createMockContext(entries, true /* idle */),
|
|
376
|
+
ui: { ...createMockContext().ui, notify },
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
state.loadFromSession(ctx);
|
|
380
|
+
|
|
381
|
+
// Simulate session_compact handler notification
|
|
382
|
+
if (state.isActive() && ctx.isIdle()) {
|
|
383
|
+
notify('Supervision cleared: compaction complete, agent idle', 'info');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
expect(notify).toHaveBeenCalledWith(
|
|
387
|
+
'Supervision cleared: compaction complete, agent idle',
|
|
388
|
+
'info'
|
|
389
|
+
);
|
|
390
|
+
});
|
|
391
|
+
});
|