@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,843 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { buildIncrementalSnapshot } from '../src/core/snapshot-builder.js';
|
|
3
|
+
import { buildUserPrompt } from '../src/core/prompt-builder.js';
|
|
4
|
+
import type { LoopState, ConversationMessage } from '../src/types.js';
|
|
5
|
+
|
|
6
|
+
// Mock fs/os for the module
|
|
7
|
+
vi.mock('node:fs', async () => {
|
|
8
|
+
const actual = await vi.importActual<typeof import('node:fs')>('node:fs');
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
existsSync: vi.fn().mockReturnValue(false),
|
|
12
|
+
readFileSync: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
vi.mock('node:os', async () => {
|
|
17
|
+
return {
|
|
18
|
+
homedir: () => '/home/test',
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('Full Fidelity Snapshot Capture', () => {
|
|
23
|
+
describe('extractAllBlocks (via buildIncrementalSnapshot)', () => {
|
|
24
|
+
it('captures text blocks from user messages', () => {
|
|
25
|
+
const mockCtx = {
|
|
26
|
+
sessionManager: {
|
|
27
|
+
getBranch: () => [
|
|
28
|
+
{
|
|
29
|
+
type: 'message',
|
|
30
|
+
message: {
|
|
31
|
+
role: 'user',
|
|
32
|
+
content: [{ type: 'text', text: 'Show me the terminal output' }],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
} as any;
|
|
38
|
+
|
|
39
|
+
const state: LoopState = {
|
|
40
|
+
active: true,
|
|
41
|
+
outcome: 'Test',
|
|
42
|
+
provider: 'anthropic',
|
|
43
|
+
modelId: 'claude',
|
|
44
|
+
interventions: [],
|
|
45
|
+
startedAt: Date.now(),
|
|
46
|
+
turnCount: 1,
|
|
47
|
+
snapshotBuffer: [],
|
|
48
|
+
lastAnalyzedTurn: -1,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
52
|
+
|
|
53
|
+
expect(result).toHaveLength(1);
|
|
54
|
+
expect(result[0].role).toBe('user');
|
|
55
|
+
expect(result[0].content).toBe('Show me the terminal output');
|
|
56
|
+
expect(result[0].blocks).toHaveLength(1);
|
|
57
|
+
expect(result[0].blocks?.[0]).toEqual({ type: 'text', text: 'Show me the terminal output' });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('captures image blocks with base64 data', () => {
|
|
61
|
+
const mockCtx = {
|
|
62
|
+
sessionManager: {
|
|
63
|
+
getBranch: () => [
|
|
64
|
+
{
|
|
65
|
+
type: 'message',
|
|
66
|
+
message: {
|
|
67
|
+
role: 'user',
|
|
68
|
+
content: [
|
|
69
|
+
{ type: 'text', text: 'Look at this screenshot' },
|
|
70
|
+
{
|
|
71
|
+
type: 'image',
|
|
72
|
+
source: 'data:image/png;base64,abc123...',
|
|
73
|
+
mimeType: 'image/png',
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
} as any;
|
|
81
|
+
|
|
82
|
+
const state: LoopState = {
|
|
83
|
+
active: true,
|
|
84
|
+
outcome: 'Test',
|
|
85
|
+
provider: 'anthropic',
|
|
86
|
+
modelId: 'claude',
|
|
87
|
+
interventions: [],
|
|
88
|
+
startedAt: Date.now(),
|
|
89
|
+
turnCount: 1,
|
|
90
|
+
snapshotBuffer: [],
|
|
91
|
+
lastAnalyzedTurn: -1,
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
95
|
+
|
|
96
|
+
expect(result).toHaveLength(1);
|
|
97
|
+
expect(result[0].blocks).toHaveLength(2);
|
|
98
|
+
expect(result[0].blocks?.[1]).toEqual({
|
|
99
|
+
type: 'image',
|
|
100
|
+
source: 'data:image/png;base64,abc123...',
|
|
101
|
+
mimeType: 'image/png',
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('captures tool_use blocks from assistant messages', () => {
|
|
106
|
+
const mockCtx = {
|
|
107
|
+
sessionManager: {
|
|
108
|
+
getBranch: () => [
|
|
109
|
+
{
|
|
110
|
+
type: 'message',
|
|
111
|
+
message: {
|
|
112
|
+
role: 'assistant',
|
|
113
|
+
content: [
|
|
114
|
+
{ type: 'text', text: 'Let me check the files.' },
|
|
115
|
+
{
|
|
116
|
+
type: 'tool_use',
|
|
117
|
+
id: 'tool_abc123',
|
|
118
|
+
name: 'bash',
|
|
119
|
+
input: { command: 'ls -la' },
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
} as any;
|
|
127
|
+
|
|
128
|
+
const state: LoopState = {
|
|
129
|
+
active: true,
|
|
130
|
+
outcome: 'Test',
|
|
131
|
+
provider: 'anthropic',
|
|
132
|
+
modelId: 'claude',
|
|
133
|
+
interventions: [],
|
|
134
|
+
startedAt: Date.now(),
|
|
135
|
+
turnCount: 1,
|
|
136
|
+
snapshotBuffer: [],
|
|
137
|
+
lastAnalyzedTurn: -1,
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
141
|
+
|
|
142
|
+
expect(result).toHaveLength(1);
|
|
143
|
+
expect(result[0].role).toBe('assistant');
|
|
144
|
+
expect(result[0].blocks).toHaveLength(2);
|
|
145
|
+
expect(result[0].blocks?.[1]).toEqual({
|
|
146
|
+
type: 'tool_call',
|
|
147
|
+
id: 'tool_abc123',
|
|
148
|
+
name: 'bash',
|
|
149
|
+
input: { command: 'ls -la' },
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('captures tool_result blocks with full output', () => {
|
|
154
|
+
const mockCtx = {
|
|
155
|
+
sessionManager: {
|
|
156
|
+
getBranch: () => [
|
|
157
|
+
{
|
|
158
|
+
type: 'message',
|
|
159
|
+
message: {
|
|
160
|
+
role: 'assistant',
|
|
161
|
+
content: [
|
|
162
|
+
{ type: 'text', text: 'Here is the output:' },
|
|
163
|
+
{
|
|
164
|
+
type: 'tool_result',
|
|
165
|
+
tool_use_id: 'tool_abc123',
|
|
166
|
+
content: [
|
|
167
|
+
{ type: 'text', text: 'drwxr-xr-x 5 user staff 160 Apr 1 12:00 .' },
|
|
168
|
+
],
|
|
169
|
+
is_error: false,
|
|
170
|
+
},
|
|
171
|
+
],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
],
|
|
175
|
+
},
|
|
176
|
+
} as any;
|
|
177
|
+
|
|
178
|
+
const state: LoopState = {
|
|
179
|
+
active: true,
|
|
180
|
+
outcome: 'Test',
|
|
181
|
+
provider: 'anthropic',
|
|
182
|
+
modelId: 'claude',
|
|
183
|
+
interventions: [],
|
|
184
|
+
startedAt: Date.now(),
|
|
185
|
+
turnCount: 1,
|
|
186
|
+
snapshotBuffer: [],
|
|
187
|
+
lastAnalyzedTurn: -1,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
191
|
+
|
|
192
|
+
expect(result).toHaveLength(1);
|
|
193
|
+
expect(result[0].blocks?.[1]).toEqual({
|
|
194
|
+
type: 'tool_result',
|
|
195
|
+
toolCallId: 'tool_abc123',
|
|
196
|
+
content: [{ type: 'text', text: 'drwxr-xr-x 5 user staff 160 Apr 1 12:00 .' }],
|
|
197
|
+
isError: false,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
describe('Tool Result Capture', () => {
|
|
203
|
+
it('captures custom_message entries as tool results', () => {
|
|
204
|
+
const mockCtx = {
|
|
205
|
+
sessionManager: {
|
|
206
|
+
getBranch: () => [
|
|
207
|
+
{
|
|
208
|
+
type: 'custom_message',
|
|
209
|
+
id: 'bash_001',
|
|
210
|
+
customType: 'bash',
|
|
211
|
+
content: 'total 32\ndrwxr-xr-x 5 user staff 160 Apr 1 12:00 .',
|
|
212
|
+
details: { command: 'ls -la' },
|
|
213
|
+
},
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
} as any;
|
|
217
|
+
|
|
218
|
+
const state: LoopState = {
|
|
219
|
+
active: true,
|
|
220
|
+
outcome: 'Test',
|
|
221
|
+
provider: 'anthropic',
|
|
222
|
+
modelId: 'claude',
|
|
223
|
+
interventions: [],
|
|
224
|
+
startedAt: Date.now(),
|
|
225
|
+
turnCount: 1,
|
|
226
|
+
snapshotBuffer: [],
|
|
227
|
+
lastAnalyzedTurn: -1,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
231
|
+
|
|
232
|
+
expect(result).toHaveLength(1);
|
|
233
|
+
expect(result[0].role).toBe('tool_results');
|
|
234
|
+
expect(result[0].content).toContain('total 32');
|
|
235
|
+
expect(result[0].blocks).toHaveLength(1);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('captures rich content custom_messages with images', () => {
|
|
239
|
+
const mockCtx = {
|
|
240
|
+
sessionManager: {
|
|
241
|
+
getBranch: () => [
|
|
242
|
+
{
|
|
243
|
+
type: 'custom_message',
|
|
244
|
+
id: 'img_001',
|
|
245
|
+
customType: 'image_display',
|
|
246
|
+
content: [
|
|
247
|
+
{ type: 'text', text: 'Screenshot of terminal:' },
|
|
248
|
+
{ type: 'image', source: 'data:image/png;base64,abc...', mimeType: 'image/png' },
|
|
249
|
+
],
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
},
|
|
253
|
+
} as any;
|
|
254
|
+
|
|
255
|
+
const state: LoopState = {
|
|
256
|
+
active: true,
|
|
257
|
+
outcome: 'Test',
|
|
258
|
+
provider: 'anthropic',
|
|
259
|
+
modelId: 'claude',
|
|
260
|
+
interventions: [],
|
|
261
|
+
startedAt: Date.now(),
|
|
262
|
+
turnCount: 1,
|
|
263
|
+
snapshotBuffer: [],
|
|
264
|
+
lastAnalyzedTurn: -1,
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
268
|
+
|
|
269
|
+
expect(result).toHaveLength(1);
|
|
270
|
+
expect(result[0].blocks).toHaveLength(2);
|
|
271
|
+
expect(result[0].blocks?.[1].type).toBe('image');
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('associates tool results with the next assistant message', () => {
|
|
275
|
+
const mockCtx = {
|
|
276
|
+
sessionManager: {
|
|
277
|
+
getBranch: () => [
|
|
278
|
+
{
|
|
279
|
+
type: 'message',
|
|
280
|
+
message: {
|
|
281
|
+
role: 'assistant',
|
|
282
|
+
content: [
|
|
283
|
+
{ type: 'text', text: 'Let me run the command.' },
|
|
284
|
+
{
|
|
285
|
+
type: 'tool_use',
|
|
286
|
+
id: 'tool_001',
|
|
287
|
+
name: 'bash',
|
|
288
|
+
input: { command: 'git diff' },
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
{
|
|
294
|
+
type: 'custom_message',
|
|
295
|
+
id: 'tool_result_001',
|
|
296
|
+
customType: 'bash',
|
|
297
|
+
content: '11 files changed, 411 insertions(+), 38 deletions(-)',
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
type: 'message',
|
|
301
|
+
message: {
|
|
302
|
+
role: 'assistant',
|
|
303
|
+
content: [{ type: 'text', text: 'Here is the git diff output.' }],
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
],
|
|
307
|
+
},
|
|
308
|
+
} as any;
|
|
309
|
+
|
|
310
|
+
const state: LoopState = {
|
|
311
|
+
active: true,
|
|
312
|
+
outcome: 'Test',
|
|
313
|
+
provider: 'anthropic',
|
|
314
|
+
modelId: 'claude',
|
|
315
|
+
interventions: [],
|
|
316
|
+
startedAt: Date.now(),
|
|
317
|
+
turnCount: 1,
|
|
318
|
+
snapshotBuffer: [],
|
|
319
|
+
lastAnalyzedTurn: -1,
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
323
|
+
|
|
324
|
+
// Tool result is its own message
|
|
325
|
+
const toolResultMsg = result.find((m) => m.role === 'tool_results');
|
|
326
|
+
expect(toolResultMsg).toBeDefined();
|
|
327
|
+
expect(toolResultMsg?.content).toContain('11 files changed');
|
|
328
|
+
|
|
329
|
+
// The second assistant message gets the tool results attached
|
|
330
|
+
const secondAssistant = result.filter((m) => m.role === 'assistant')[1];
|
|
331
|
+
expect(secondAssistant).toBeDefined();
|
|
332
|
+
expect(secondAssistant?.toolResults).toBeDefined();
|
|
333
|
+
expect(secondAssistant?.toolResults).toHaveLength(1);
|
|
334
|
+
expect(secondAssistant?.toolResults?.[0].toolName).toBe('bash');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it('captures tool role messages as tool results', () => {
|
|
338
|
+
const mockCtx = {
|
|
339
|
+
sessionManager: {
|
|
340
|
+
getBranch: () => [
|
|
341
|
+
{
|
|
342
|
+
type: 'message',
|
|
343
|
+
message: {
|
|
344
|
+
role: 'tool',
|
|
345
|
+
tool_call_id: 'call_abc',
|
|
346
|
+
name: 'read',
|
|
347
|
+
content: [{ type: 'text', text: 'File contents here' }],
|
|
348
|
+
is_error: false,
|
|
349
|
+
},
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
},
|
|
353
|
+
} as any;
|
|
354
|
+
|
|
355
|
+
const state: LoopState = {
|
|
356
|
+
active: true,
|
|
357
|
+
outcome: 'Test',
|
|
358
|
+
provider: 'anthropic',
|
|
359
|
+
modelId: 'claude',
|
|
360
|
+
interventions: [],
|
|
361
|
+
startedAt: Date.now(),
|
|
362
|
+
turnCount: 1,
|
|
363
|
+
snapshotBuffer: [],
|
|
364
|
+
lastAnalyzedTurn: -1,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
368
|
+
|
|
369
|
+
expect(result).toHaveLength(1);
|
|
370
|
+
expect(result[0].role).toBe('tool_results');
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe('Snapshot Limiting (6 messages)', () => {
|
|
375
|
+
it('limits to 20 messages total', () => {
|
|
376
|
+
// Test with more messages than the limit
|
|
377
|
+
const manyMessages = Array.from({ length: 25 }, (_, i) => ({
|
|
378
|
+
type: 'message' as const,
|
|
379
|
+
message: {
|
|
380
|
+
role: 'user' as const,
|
|
381
|
+
content: [{ type: 'text' as const, text: String(i + 1) }],
|
|
382
|
+
},
|
|
383
|
+
}));
|
|
384
|
+
|
|
385
|
+
const mockCtx = {
|
|
386
|
+
sessionManager: {
|
|
387
|
+
getBranch: () => manyMessages,
|
|
388
|
+
},
|
|
389
|
+
} as any;
|
|
390
|
+
|
|
391
|
+
const state: LoopState = {
|
|
392
|
+
active: true,
|
|
393
|
+
outcome: 'Test',
|
|
394
|
+
provider: 'anthropic',
|
|
395
|
+
modelId: 'claude',
|
|
396
|
+
interventions: [],
|
|
397
|
+
startedAt: Date.now(),
|
|
398
|
+
turnCount: 1,
|
|
399
|
+
snapshotBuffer: [],
|
|
400
|
+
lastAnalyzedTurn: -1,
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
404
|
+
|
|
405
|
+
expect(result).toHaveLength(20);
|
|
406
|
+
// Should keep the most recent 20 (messages 6-25)
|
|
407
|
+
expect(result[0].content).toBe('6');
|
|
408
|
+
expect(result[19].content).toBe('25');
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('preserves tool results within the 20-message window', () => {
|
|
412
|
+
const mockCtx = {
|
|
413
|
+
sessionManager: {
|
|
414
|
+
getBranch: () => [
|
|
415
|
+
{
|
|
416
|
+
type: 'message',
|
|
417
|
+
message: { role: 'user', content: [{ type: 'text', text: 'Old 1' }] },
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
type: 'message',
|
|
421
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'Old 2' }] },
|
|
422
|
+
},
|
|
423
|
+
{
|
|
424
|
+
type: 'message',
|
|
425
|
+
message: { role: 'user', content: [{ type: 'text', text: 'Recent user' }] },
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
type: 'message',
|
|
429
|
+
message: {
|
|
430
|
+
role: 'assistant',
|
|
431
|
+
content: [
|
|
432
|
+
{ type: 'text', text: 'Recent assistant' },
|
|
433
|
+
{ type: 'tool_use', id: 't1', name: 'bash', input: { command: 'ls' } },
|
|
434
|
+
],
|
|
435
|
+
},
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
type: 'custom_message',
|
|
439
|
+
id: 'r1',
|
|
440
|
+
customType: 'bash',
|
|
441
|
+
content: 'file1.txt\nfile2.txt',
|
|
442
|
+
},
|
|
443
|
+
{
|
|
444
|
+
type: 'message',
|
|
445
|
+
message: { role: 'user', content: [{ type: 'text', text: 'Latest user' }] },
|
|
446
|
+
},
|
|
447
|
+
{
|
|
448
|
+
type: 'message',
|
|
449
|
+
message: { role: 'assistant', content: [{ type: 'text', text: 'Latest assistant' }] },
|
|
450
|
+
},
|
|
451
|
+
],
|
|
452
|
+
},
|
|
453
|
+
} as any;
|
|
454
|
+
|
|
455
|
+
const state: LoopState = {
|
|
456
|
+
active: true,
|
|
457
|
+
outcome: 'Test',
|
|
458
|
+
provider: 'anthropic',
|
|
459
|
+
modelId: 'claude',
|
|
460
|
+
interventions: [],
|
|
461
|
+
startedAt: Date.now(),
|
|
462
|
+
turnCount: 1,
|
|
463
|
+
snapshotBuffer: [],
|
|
464
|
+
lastAnalyzedTurn: -1,
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
468
|
+
|
|
469
|
+
expect(result.length).toBeLessThanOrEqual(20);
|
|
470
|
+
// Should include the tool result message
|
|
471
|
+
const toolResult = result.find((m) => m.role === 'tool_results');
|
|
472
|
+
expect(toolResult).toBeDefined();
|
|
473
|
+
expect(toolResult?.content).toContain('file1.txt');
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
it('captures write tool results with non-standard entry types', () => {
|
|
477
|
+
const mockCtx = {
|
|
478
|
+
sessionManager: {
|
|
479
|
+
getBranch: () => [
|
|
480
|
+
{
|
|
481
|
+
type: 'message',
|
|
482
|
+
message: {
|
|
483
|
+
role: 'assistant',
|
|
484
|
+
content: [
|
|
485
|
+
{ type: 'text', text: 'Writing the file.' },
|
|
486
|
+
{
|
|
487
|
+
type: 'tool_use',
|
|
488
|
+
id: 'w1',
|
|
489
|
+
name: 'write',
|
|
490
|
+
input: { path: '/src/index.ts', content: '...' },
|
|
491
|
+
},
|
|
492
|
+
],
|
|
493
|
+
},
|
|
494
|
+
},
|
|
495
|
+
// Non-standard entry types that might come from write/edit tools
|
|
496
|
+
{
|
|
497
|
+
type: 'write_result',
|
|
498
|
+
id: 'w1',
|
|
499
|
+
name: 'write',
|
|
500
|
+
content: 'Written: /src/index.ts',
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
type: 'message',
|
|
504
|
+
message: {
|
|
505
|
+
role: 'assistant',
|
|
506
|
+
content: [{ type: 'text', text: 'File written successfully.' }],
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
},
|
|
511
|
+
} as any;
|
|
512
|
+
|
|
513
|
+
const state: LoopState = {
|
|
514
|
+
active: true,
|
|
515
|
+
outcome: 'Write code',
|
|
516
|
+
provider: 'anthropic',
|
|
517
|
+
modelId: 'claude',
|
|
518
|
+
interventions: [],
|
|
519
|
+
startedAt: Date.now(),
|
|
520
|
+
turnCount: 1,
|
|
521
|
+
snapshotBuffer: [],
|
|
522
|
+
lastAnalyzedTurn: -1,
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
526
|
+
|
|
527
|
+
// Verify the write tool result was captured
|
|
528
|
+
const writeResult = result.find(
|
|
529
|
+
(m) => m.role === 'tool_results' && m.content.includes('Written')
|
|
530
|
+
);
|
|
531
|
+
expect(writeResult).toBeDefined();
|
|
532
|
+
expect(writeResult?.content).toContain('/src/index.ts');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
it('captures edit tool results with non-standard entry types', () => {
|
|
536
|
+
const mockCtx = {
|
|
537
|
+
sessionManager: {
|
|
538
|
+
getBranch: () => [
|
|
539
|
+
{
|
|
540
|
+
type: 'edit_result',
|
|
541
|
+
id: 'e1',
|
|
542
|
+
name: 'edit',
|
|
543
|
+
content: 'Edited: /src/utils.ts',
|
|
544
|
+
},
|
|
545
|
+
],
|
|
546
|
+
},
|
|
547
|
+
} as any;
|
|
548
|
+
|
|
549
|
+
const state: LoopState = {
|
|
550
|
+
active: true,
|
|
551
|
+
outcome: 'Edit code',
|
|
552
|
+
provider: 'anthropic',
|
|
553
|
+
modelId: 'claude',
|
|
554
|
+
interventions: [],
|
|
555
|
+
startedAt: Date.now(),
|
|
556
|
+
turnCount: 1,
|
|
557
|
+
snapshotBuffer: [],
|
|
558
|
+
lastAnalyzedTurn: -1,
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
562
|
+
|
|
563
|
+
// Verify the edit tool result was captured
|
|
564
|
+
const editResult = result.find(
|
|
565
|
+
(m) => m.role === 'tool_results' && m.content.includes('Edited')
|
|
566
|
+
);
|
|
567
|
+
expect(editResult).toBeDefined();
|
|
568
|
+
expect(editResult?.content).toContain('/src/utils.ts');
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
it('captures tool results with array content format', () => {
|
|
572
|
+
const mockCtx = {
|
|
573
|
+
sessionManager: {
|
|
574
|
+
getBranch: () => [
|
|
575
|
+
{
|
|
576
|
+
type: 'tool_output',
|
|
577
|
+
id: 't1',
|
|
578
|
+
name: 'write',
|
|
579
|
+
content: [{ type: 'text', text: 'Written: /path/file.ts' }],
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
},
|
|
583
|
+
} as any;
|
|
584
|
+
|
|
585
|
+
const state: LoopState = {
|
|
586
|
+
active: true,
|
|
587
|
+
outcome: 'Write',
|
|
588
|
+
provider: 'anthropic',
|
|
589
|
+
modelId: 'claude',
|
|
590
|
+
interventions: [],
|
|
591
|
+
startedAt: Date.now(),
|
|
592
|
+
turnCount: 1,
|
|
593
|
+
snapshotBuffer: [],
|
|
594
|
+
lastAnalyzedTurn: -1,
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
598
|
+
|
|
599
|
+
const toolResult = result.find((m) => m.role === 'tool_results');
|
|
600
|
+
expect(toolResult).toBeDefined();
|
|
601
|
+
expect(toolResult?.content).toContain('/path/file.ts');
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('skips state entries without content (no false positives)', () => {
|
|
605
|
+
const mockCtx = {
|
|
606
|
+
sessionManager: {
|
|
607
|
+
getBranch: () => [
|
|
608
|
+
{
|
|
609
|
+
type: 'custom',
|
|
610
|
+
customType: 'loop-state',
|
|
611
|
+
data: { active: true, outcome: 'Test' }, // State entry, no content
|
|
612
|
+
},
|
|
613
|
+
{
|
|
614
|
+
type: 'session_entry',
|
|
615
|
+
id: 'state1',
|
|
616
|
+
// No content field - should be skipped
|
|
617
|
+
},
|
|
618
|
+
],
|
|
619
|
+
},
|
|
620
|
+
} as any;
|
|
621
|
+
|
|
622
|
+
const state: LoopState = {
|
|
623
|
+
active: true,
|
|
624
|
+
outcome: 'Test',
|
|
625
|
+
provider: 'anthropic',
|
|
626
|
+
modelId: 'claude',
|
|
627
|
+
interventions: [],
|
|
628
|
+
startedAt: Date.now(),
|
|
629
|
+
turnCount: 1,
|
|
630
|
+
snapshotBuffer: [],
|
|
631
|
+
lastAnalyzedTurn: -1,
|
|
632
|
+
};
|
|
633
|
+
|
|
634
|
+
const result = buildIncrementalSnapshot(mockCtx, state);
|
|
635
|
+
|
|
636
|
+
// Should not have any tool_results from state entries
|
|
637
|
+
expect(result.filter((m) => m.role === 'tool_results')).toHaveLength(0);
|
|
638
|
+
});
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
describe('Prompt Building with Tool Outputs', () => {
|
|
643
|
+
it('includes tool calls in assistant message rendering', () => {
|
|
644
|
+
const state: LoopState = {
|
|
645
|
+
active: true,
|
|
646
|
+
outcome: 'Show terminal output',
|
|
647
|
+
provider: 'anthropic',
|
|
648
|
+
modelId: 'claude',
|
|
649
|
+
interventions: [],
|
|
650
|
+
startedAt: Date.now(),
|
|
651
|
+
turnCount: 1,
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
const snapshot: ConversationMessage[] = [
|
|
655
|
+
{
|
|
656
|
+
role: 'assistant',
|
|
657
|
+
content: 'I will run the command.',
|
|
658
|
+
blocks: [
|
|
659
|
+
{ type: 'text', text: 'I will run the command.' },
|
|
660
|
+
{ type: 'tool_call', id: 't1', name: 'bash', input: { command: 'ls -la' } },
|
|
661
|
+
],
|
|
662
|
+
},
|
|
663
|
+
];
|
|
664
|
+
|
|
665
|
+
const result = buildUserPrompt(state, snapshot, true);
|
|
666
|
+
|
|
667
|
+
expect(result).toContain('[Tool calls made]:');
|
|
668
|
+
expect(result).toContain('bash({"command":"ls -la"})');
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it('includes full tool outputs attached to assistant messages', () => {
|
|
672
|
+
const state: LoopState = {
|
|
673
|
+
active: true,
|
|
674
|
+
outcome: 'Show terminal output',
|
|
675
|
+
provider: 'anthropic',
|
|
676
|
+
modelId: 'claude',
|
|
677
|
+
interventions: [],
|
|
678
|
+
startedAt: Date.now(),
|
|
679
|
+
turnCount: 1,
|
|
680
|
+
};
|
|
681
|
+
|
|
682
|
+
const snapshot: ConversationMessage[] = [
|
|
683
|
+
{
|
|
684
|
+
role: 'assistant',
|
|
685
|
+
content: 'The terminal outputs are being shown.',
|
|
686
|
+
blocks: [{ type: 'text', text: 'The terminal outputs are being shown.' }],
|
|
687
|
+
toolResults: [
|
|
688
|
+
{
|
|
689
|
+
toolCallId: 't1',
|
|
690
|
+
toolName: 'bash',
|
|
691
|
+
input: { command: 'ls -la' },
|
|
692
|
+
content: [{ type: 'text', text: 'drwxr-xr-x 5 user staff 160 Apr 1 12:00 .' }],
|
|
693
|
+
isError: false,
|
|
694
|
+
},
|
|
695
|
+
{
|
|
696
|
+
toolCallId: 't2',
|
|
697
|
+
toolName: 'read',
|
|
698
|
+
input: { path: '/tmp/output.txt' },
|
|
699
|
+
content: [{ type: 'text', text: 'File contents: 11 files changed' }],
|
|
700
|
+
isError: false,
|
|
701
|
+
},
|
|
702
|
+
],
|
|
703
|
+
},
|
|
704
|
+
];
|
|
705
|
+
|
|
706
|
+
const result = buildUserPrompt(state, snapshot, true);
|
|
707
|
+
|
|
708
|
+
expect(result).toContain('[Tool outputs received]:');
|
|
709
|
+
expect(result).toContain('--- bash output ---');
|
|
710
|
+
expect(result).toContain('drwxr-xr-x');
|
|
711
|
+
expect(result).toContain('--- read output ---');
|
|
712
|
+
expect(result).toContain('11 files changed');
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
it('marks error outputs with [ERROR] tag', () => {
|
|
716
|
+
const state: LoopState = {
|
|
717
|
+
active: true,
|
|
718
|
+
outcome: 'Run command',
|
|
719
|
+
provider: 'anthropic',
|
|
720
|
+
modelId: 'claude',
|
|
721
|
+
interventions: [],
|
|
722
|
+
startedAt: Date.now(),
|
|
723
|
+
turnCount: 1,
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
const snapshot: ConversationMessage[] = [
|
|
727
|
+
{
|
|
728
|
+
role: 'assistant',
|
|
729
|
+
content: 'Command failed.',
|
|
730
|
+
blocks: [{ type: 'text', text: 'Command failed.' }],
|
|
731
|
+
toolResults: [
|
|
732
|
+
{
|
|
733
|
+
toolCallId: 't1',
|
|
734
|
+
toolName: 'bash',
|
|
735
|
+
input: { command: 'badcommand' },
|
|
736
|
+
content: [{ type: 'text', text: 'bash: badcommand: command not found' }],
|
|
737
|
+
isError: true,
|
|
738
|
+
},
|
|
739
|
+
],
|
|
740
|
+
},
|
|
741
|
+
];
|
|
742
|
+
|
|
743
|
+
const result = buildUserPrompt(state, snapshot, true);
|
|
744
|
+
|
|
745
|
+
expect(result).toContain('[ERROR]');
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it('renders tool_results role messages correctly', () => {
|
|
749
|
+
const state: LoopState = {
|
|
750
|
+
active: true,
|
|
751
|
+
outcome: 'Show output',
|
|
752
|
+
provider: 'anthropic',
|
|
753
|
+
modelId: 'claude',
|
|
754
|
+
interventions: [],
|
|
755
|
+
startedAt: Date.now(),
|
|
756
|
+
turnCount: 1,
|
|
757
|
+
};
|
|
758
|
+
|
|
759
|
+
const snapshot: ConversationMessage[] = [
|
|
760
|
+
{ role: 'user', content: 'Run ls' },
|
|
761
|
+
{
|
|
762
|
+
role: 'tool_results',
|
|
763
|
+
content: 'file1.txt\nfile2.txt',
|
|
764
|
+
blocks: [
|
|
765
|
+
{ type: 'text', text: 'file1.txt\nfile2.txt' },
|
|
766
|
+
{ type: 'image', source: 'data:png;base64,abc...', mimeType: 'image/png' },
|
|
767
|
+
],
|
|
768
|
+
},
|
|
769
|
+
];
|
|
770
|
+
|
|
771
|
+
const result = buildUserPrompt(state, snapshot, true);
|
|
772
|
+
|
|
773
|
+
expect(result).toContain('TOOL RESULTS:');
|
|
774
|
+
expect(result).toContain('file1.txt');
|
|
775
|
+
expect(result).toContain('[Contains image data');
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
it('shows full conversation flow with tools in prompt', () => {
|
|
779
|
+
const state: LoopState = {
|
|
780
|
+
active: true,
|
|
781
|
+
outcome: 'Show raw terminal output',
|
|
782
|
+
provider: 'anthropic',
|
|
783
|
+
modelId: 'claude',
|
|
784
|
+
interventions: [],
|
|
785
|
+
startedAt: Date.now(),
|
|
786
|
+
turnCount: 1,
|
|
787
|
+
};
|
|
788
|
+
|
|
789
|
+
const snapshot: ConversationMessage[] = [
|
|
790
|
+
{ role: 'user', content: 'Show me the raw terminal output from ls -la' },
|
|
791
|
+
{
|
|
792
|
+
role: 'assistant',
|
|
793
|
+
content: 'Here are the outputs:',
|
|
794
|
+
blocks: [
|
|
795
|
+
{ type: 'text', text: 'Here are the outputs:' },
|
|
796
|
+
{ type: 'tool_call', id: 't1', name: 'bash', input: { command: 'ls -la' } },
|
|
797
|
+
],
|
|
798
|
+
toolResults: [
|
|
799
|
+
{
|
|
800
|
+
toolCallId: 't1',
|
|
801
|
+
toolName: 'bash',
|
|
802
|
+
input: { command: 'ls -la' },
|
|
803
|
+
content: [
|
|
804
|
+
{ type: 'text', text: 'total 32\ndrwxr-xr-x 5 user staff 160 Apr 1 12:00 .' },
|
|
805
|
+
],
|
|
806
|
+
isError: false,
|
|
807
|
+
},
|
|
808
|
+
],
|
|
809
|
+
},
|
|
810
|
+
{ role: 'user', content: 'That was just a summary. Show the RAW text.' },
|
|
811
|
+
{
|
|
812
|
+
role: 'assistant',
|
|
813
|
+
content: 'I will show the raw output.',
|
|
814
|
+
blocks: [
|
|
815
|
+
{ type: 'text', text: 'I will show the raw output.' },
|
|
816
|
+
{ type: 'tool_call', id: 't2', name: 'bash', input: { command: 'ls -la /tmp' } },
|
|
817
|
+
],
|
|
818
|
+
toolResults: [
|
|
819
|
+
{
|
|
820
|
+
toolCallId: 't2',
|
|
821
|
+
toolName: 'bash',
|
|
822
|
+
input: { command: 'ls -la /tmp' },
|
|
823
|
+
content: [{ type: 'text', text: 'drwxrwxrwt 10 root wheel 320 Apr 1 12:00 /tmp' }],
|
|
824
|
+
isError: false,
|
|
825
|
+
},
|
|
826
|
+
],
|
|
827
|
+
},
|
|
828
|
+
];
|
|
829
|
+
|
|
830
|
+
const result = buildUserPrompt(state, snapshot, true);
|
|
831
|
+
|
|
832
|
+
// Verify supervisor can see the user's demand AND the actual tool output
|
|
833
|
+
expect(result).toContain('USER: Show me the raw terminal output from ls -la');
|
|
834
|
+
expect(result).toContain('USER: That was just a summary. Show the RAW text.');
|
|
835
|
+
expect(result).toContain('[Tool outputs received]:');
|
|
836
|
+
expect(result).toContain('total 32');
|
|
837
|
+
expect(result).toContain('drwxr-xr-x');
|
|
838
|
+
|
|
839
|
+
// This is the key: supervisor can now compare what user demanded vs what was shown
|
|
840
|
+
// If agent claims "outputs are displayed" but tool result shows they weren't,
|
|
841
|
+
// supervisor will detect this and steer
|
|
842
|
+
});
|
|
843
|
+
});
|