@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,703 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { updateUI, toggleWidget } from '../src/ui/status-widget.js';
|
|
3
|
+
import type { LoopState } from '../src/types.js';
|
|
4
|
+
|
|
5
|
+
// Mock the TUI module
|
|
6
|
+
vi.mock('@earendil-works/pi-tui', () => ({
|
|
7
|
+
truncateToWidth: (text: string, width: number) => {
|
|
8
|
+
if (text.length <= width) return text;
|
|
9
|
+
return text.slice(0, width);
|
|
10
|
+
},
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Helper to create mock ExtensionContext
|
|
14
|
+
function createMockCtx() {
|
|
15
|
+
const setStatusMock = vi.fn();
|
|
16
|
+
const setWidgetMock = vi.fn();
|
|
17
|
+
|
|
18
|
+
return {
|
|
19
|
+
ui: {
|
|
20
|
+
setStatus: setStatusMock,
|
|
21
|
+
setWidget: setWidgetMock,
|
|
22
|
+
notify: vi.fn(),
|
|
23
|
+
select: vi.fn(),
|
|
24
|
+
},
|
|
25
|
+
sessionManager: {
|
|
26
|
+
getBranch: vi.fn(() => []),
|
|
27
|
+
},
|
|
28
|
+
model: { provider: 'test', id: 'test-model' },
|
|
29
|
+
} as any;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Helper to create a mock supervisor state
|
|
33
|
+
function createMockState(overrides?: Partial<LoopState>): LoopState {
|
|
34
|
+
return {
|
|
35
|
+
active: true,
|
|
36
|
+
outcome: 'Test goal',
|
|
37
|
+
interventions: [],
|
|
38
|
+
turnCount: 1,
|
|
39
|
+
justSteered: false,
|
|
40
|
+
lastAnalyzedTurn: 0,
|
|
41
|
+
snapshotBuffer: [],
|
|
42
|
+
reframeTier: 0,
|
|
43
|
+
lastSteerTurn: -1,
|
|
44
|
+
...overrides,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('status-widget', () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
// Reset widget visibility before each test
|
|
51
|
+
while (!toggleWidget()) {
|
|
52
|
+
// Toggle until visible
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('animation flow', () => {
|
|
57
|
+
it('triggers animation timer when leaving analyzing for steering', () => {
|
|
58
|
+
const ctx = createMockCtx();
|
|
59
|
+
const state = createMockState();
|
|
60
|
+
|
|
61
|
+
// Set initial thinking
|
|
62
|
+
updateUI(ctx, state, {
|
|
63
|
+
type: 'analyzing',
|
|
64
|
+
turn: 1,
|
|
65
|
+
thinking: 'Analysis content for animation test',
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Verify initial render has thinking
|
|
69
|
+
let lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
70
|
+
let widget = lastCall[1](null, { fg: (_c: string, t: string) => t });
|
|
71
|
+
let lines = widget.render(100);
|
|
72
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
73
|
+
|
|
74
|
+
// Transition to steering - should set up animation timer
|
|
75
|
+
updateUI(ctx, state, {
|
|
76
|
+
type: 'steering',
|
|
77
|
+
message: 'Please fix',
|
|
78
|
+
reframeTier: 0,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Widget still shows thinking (animation hasn't started yet, timer is set)
|
|
82
|
+
lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
83
|
+
widget = lastCall[1](null, { fg: (_c: string, t: string) => t });
|
|
84
|
+
lines = widget.render(100);
|
|
85
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
86
|
+
expect(lines.join(' ')).toContain('Analysis content');
|
|
87
|
+
expect(lines.join(' ')).toContain('steering');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('hides lines from bottom to top during animation', () => {
|
|
91
|
+
const ctx = createMockCtx();
|
|
92
|
+
const state = createMockState();
|
|
93
|
+
|
|
94
|
+
// Create a long thinking content that will wrap to multiple lines
|
|
95
|
+
const longThinking = 'Word one word two word three word four word five word six word seven';
|
|
96
|
+
|
|
97
|
+
// Start analyzing with thinking
|
|
98
|
+
updateUI(ctx, state, {
|
|
99
|
+
type: 'analyzing',
|
|
100
|
+
turn: 1,
|
|
101
|
+
thinking: longThinking,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Capture the widget and get initial lines
|
|
105
|
+
let lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
106
|
+
const mockTheme = { fg: (_c: string, t: string) => t };
|
|
107
|
+
let widget = lastCall[1](null, mockTheme);
|
|
108
|
+
const width = 30; // Narrow width to force wrapping
|
|
109
|
+
const initialLines = widget.render(width);
|
|
110
|
+
const initialThinkingLines = initialLines.length - 1; // Exclude header
|
|
111
|
+
expect(initialThinkingLines).toBeGreaterThanOrEqual(2);
|
|
112
|
+
|
|
113
|
+
// Transition to done - thinking lines should be preserved for animation
|
|
114
|
+
updateUI(ctx, state, { type: 'done' });
|
|
115
|
+
|
|
116
|
+
// Verify before animation starts, all lines should be visible
|
|
117
|
+
lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
118
|
+
widget = lastCall[1](null, mockTheme);
|
|
119
|
+
const linesBeforeAnimation = widget.render(width);
|
|
120
|
+
expect(linesBeforeAnimation.length).toBe(initialLines.length);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('animation progressively removes lines from bottom using hideFromBottom', () => {
|
|
124
|
+
// This test verifies the core animation mechanism:
|
|
125
|
+
// when hideFromBottom increases, fewer lines are shown from the bottom
|
|
126
|
+
|
|
127
|
+
const ctx = createMockCtx();
|
|
128
|
+
const state = createMockState();
|
|
129
|
+
|
|
130
|
+
// Set multi-line thinking - use a longer text that will wrap even at width 80
|
|
131
|
+
// The thinking indent is 2 spaces, so we need enough content to wrap
|
|
132
|
+
const longThinking =
|
|
133
|
+
'One two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen sixteen seventeen eighteen nineteen twenty';
|
|
134
|
+
|
|
135
|
+
updateUI(ctx, state, {
|
|
136
|
+
type: 'analyzing',
|
|
137
|
+
turn: 1,
|
|
138
|
+
thinking: longThinking,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const mockTheme = { fg: (_c: string, t: string) => t };
|
|
142
|
+
|
|
143
|
+
// Transition to steering to set up animation state
|
|
144
|
+
updateUI(ctx, state, {
|
|
145
|
+
type: 'steering',
|
|
146
|
+
message: 'Fix this',
|
|
147
|
+
reframeTier: 0,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
151
|
+
const widget = lastCall[1](null, mockTheme);
|
|
152
|
+
|
|
153
|
+
// At this point the widget render function is set up with lastThinkingLines
|
|
154
|
+
// The key assertion: the render function is designed to handle hideFromBottom
|
|
155
|
+
// by slicing from 0 to (total - hideFromBottom)
|
|
156
|
+
|
|
157
|
+
const width = 78; // Width that allows some wrapping
|
|
158
|
+
const linesWithFullThinking = widget.render(width);
|
|
159
|
+
|
|
160
|
+
// Should have header + at least 1 thinking line
|
|
161
|
+
expect(linesWithFullThinking.length).toBeGreaterThanOrEqual(2);
|
|
162
|
+
|
|
163
|
+
// Verify the thinking lines contain expected words
|
|
164
|
+
const allText = linesWithFullThinking.join(' ');
|
|
165
|
+
expect(allText).toContain('One');
|
|
166
|
+
expect(allText).toContain('steering');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('animation resets when new thinking arrives mid-animation', () => {
|
|
170
|
+
const ctx = createMockCtx();
|
|
171
|
+
const state = createMockState();
|
|
172
|
+
|
|
173
|
+
// Initial analysis
|
|
174
|
+
updateUI(ctx, state, {
|
|
175
|
+
type: 'analyzing',
|
|
176
|
+
turn: 1,
|
|
177
|
+
thinking: 'First analysis content',
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// Start transition to steering (animation timer set)
|
|
181
|
+
updateUI(ctx, state, {
|
|
182
|
+
type: 'steering',
|
|
183
|
+
message: 'Fix this',
|
|
184
|
+
reframeTier: 0,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Before animation completes, new analyzing content arrives
|
|
188
|
+
updateUI(ctx, state, {
|
|
189
|
+
type: 'analyzing',
|
|
190
|
+
turn: 2,
|
|
191
|
+
thinking: 'Fresh analysis after interrupt',
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// Should show fresh content, animation state reset
|
|
195
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
196
|
+
const widget = lastCall[1](null, { fg: (_c: string, t: string) => t });
|
|
197
|
+
const lines = widget.render(100);
|
|
198
|
+
|
|
199
|
+
const allText = lines.join(' ');
|
|
200
|
+
expect(allText).toContain('Fresh analysis after interrupt');
|
|
201
|
+
expect(allText).not.toContain('First analysis content');
|
|
202
|
+
expect(allText).toContain('⟳ turn 2');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('animation completes and clears thinking lines', () => {
|
|
206
|
+
const ctx = createMockCtx();
|
|
207
|
+
const state = createMockState();
|
|
208
|
+
|
|
209
|
+
// Start with analyzing
|
|
210
|
+
updateUI(ctx, state, {
|
|
211
|
+
type: 'analyzing',
|
|
212
|
+
turn: 1,
|
|
213
|
+
thinking: 'Short thinking',
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Transition to steering
|
|
217
|
+
updateUI(ctx, state, {
|
|
218
|
+
type: 'steering',
|
|
219
|
+
message: 'Please fix',
|
|
220
|
+
reframeTier: 0,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Verify widget was rendered with thinking preserved
|
|
224
|
+
let lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
225
|
+
expect(lastCall[1]).not.toBeUndefined();
|
|
226
|
+
|
|
227
|
+
// Simulate supervisor becoming inactive (this happens after animation or when loop ends)
|
|
228
|
+
const inactiveState = { ...state, active: false };
|
|
229
|
+
updateUI(ctx, inactiveState as any);
|
|
230
|
+
|
|
231
|
+
// Widget should be cleared when inactive
|
|
232
|
+
const finalCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
233
|
+
expect(finalCall[1]).toBeUndefined();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('cancels previous animation timer when new action arrives', () => {
|
|
237
|
+
const ctx = createMockCtx();
|
|
238
|
+
const state = createMockState();
|
|
239
|
+
|
|
240
|
+
// Start analyzing
|
|
241
|
+
updateUI(ctx, state, {
|
|
242
|
+
type: 'analyzing',
|
|
243
|
+
turn: 1,
|
|
244
|
+
thinking: 'Analysis one',
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Go to steering - sets timer
|
|
248
|
+
updateUI(ctx, state, {
|
|
249
|
+
type: 'steering',
|
|
250
|
+
message: 'First steer',
|
|
251
|
+
reframeTier: 0,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const callsAfterFirstSteer = ctx.ui.setWidget.mock.calls.length;
|
|
255
|
+
|
|
256
|
+
// Second steer before animation starts - should cancel first timer and set new one
|
|
257
|
+
updateUI(ctx, state, {
|
|
258
|
+
type: 'steering',
|
|
259
|
+
message: 'Second steer',
|
|
260
|
+
reframeTier: 0,
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Should have updated widget again
|
|
264
|
+
expect(ctx.ui.setWidget.mock.calls.length).toBeGreaterThan(callsAfterFirstSteer);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('steering preserves thinking for animation to clear - regression test for empty lines bug', () => {
|
|
268
|
+
// This test documents the bug fix for the case where lastThinkingLines was empty
|
|
269
|
+
// when the animation timer fired, causing the animation to return early and leave
|
|
270
|
+
// thoughts on screen indefinitely.
|
|
271
|
+
//
|
|
272
|
+
// Bug scenario:
|
|
273
|
+
// 1. updateUI called with analyzing, sets lastThinking
|
|
274
|
+
// 2. renderWithState called, populates lastThinkingLines based on lastThinking
|
|
275
|
+
// 3. updateUI called with steering, sets up animation timer
|
|
276
|
+
// 4. renderWithState called with hideFromBottom > 0, returns early (doesn't update lastThinkingLines)
|
|
277
|
+
// 5. 15 seconds later, animation timer fires
|
|
278
|
+
// 6. startLineClearAnimation checks lastThinkingLines.length === 0, returns early
|
|
279
|
+
// 7. Thoughts stay on screen forever!
|
|
280
|
+
//
|
|
281
|
+
// Fix: Before setting the animation timer, populate lastThinkingLines
|
|
282
|
+
// from lastThinking so the animation can run properly.
|
|
283
|
+
//
|
|
284
|
+
// This test verifies that after steering, the thinking is preserved
|
|
285
|
+
// so the animation (which runs after 15 seconds) can properly clear it.
|
|
286
|
+
|
|
287
|
+
const ctx = createMockCtx();
|
|
288
|
+
const state = createMockState();
|
|
289
|
+
|
|
290
|
+
// Start with analyzing and multi-line thinking content
|
|
291
|
+
updateUI(ctx, state, {
|
|
292
|
+
type: 'analyzing',
|
|
293
|
+
turn: 1,
|
|
294
|
+
thinking: 'First line of thinking that needs to be cleared by animation',
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// Transition to steering - thinking lines should be preserved for animation
|
|
298
|
+
updateUI(ctx, state, {
|
|
299
|
+
type: 'steering',
|
|
300
|
+
message: 'Fix this',
|
|
301
|
+
reframeTier: 0,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// Verify the widget shows the thinking content (will be cleared by animation after 15s)
|
|
305
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
306
|
+
const widgetFactory = lastCall[1];
|
|
307
|
+
const mockTheme = { fg: (_c: string, t: string) => t };
|
|
308
|
+
const widget = widgetFactory(null, mockTheme);
|
|
309
|
+
const lines = widget.render(100);
|
|
310
|
+
|
|
311
|
+
// Should have header + thinking lines
|
|
312
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
313
|
+
const allText = lines.join(' ');
|
|
314
|
+
|
|
315
|
+
// Widget shows steering status
|
|
316
|
+
expect(allText).toContain('steering');
|
|
317
|
+
|
|
318
|
+
// Thinking content is preserved for animation to clear
|
|
319
|
+
expect(allText).toContain('First line of thinking');
|
|
320
|
+
|
|
321
|
+
// The thinking will be cleared by the animation after the 15s timer fires
|
|
322
|
+
// (verified by the animation completing and calling renderFn with empty thinking)
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('thought clearing behavior', () => {
|
|
327
|
+
it('clears old thoughts immediately when new thinking arrives', () => {
|
|
328
|
+
const ctx = createMockCtx();
|
|
329
|
+
const state = createMockState();
|
|
330
|
+
|
|
331
|
+
// First call with initial thinking content
|
|
332
|
+
updateUI(ctx, state, {
|
|
333
|
+
type: 'analyzing',
|
|
334
|
+
turn: 1,
|
|
335
|
+
thinking: 'Initial thinking content that is long enough to wrap',
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// Verify widget was set with initial thinking
|
|
339
|
+
expect(ctx.ui.setWidget).toHaveBeenCalled();
|
|
340
|
+
const firstWidgetCall = ctx.ui.setWidget.mock.calls[0];
|
|
341
|
+
expect(firstWidgetCall[0]).toBe('loop');
|
|
342
|
+
|
|
343
|
+
// Second call with new/different thinking content (simulating agent_end with new analysis)
|
|
344
|
+
updateUI(ctx, state, {
|
|
345
|
+
type: 'analyzing',
|
|
346
|
+
turn: 2,
|
|
347
|
+
thinking: 'Completely new thinking content for turn 2',
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// Verify widget was updated again
|
|
351
|
+
expect(ctx.ui.setWidget).toHaveBeenCalledTimes(2);
|
|
352
|
+
|
|
353
|
+
// Get the render function from the second call
|
|
354
|
+
const secondWidgetCall = ctx.ui.setWidget.mock.calls[1];
|
|
355
|
+
const widgetFactory = secondWidgetCall[1];
|
|
356
|
+
const mockTheme = {
|
|
357
|
+
fg: (color: string, text: string) => text,
|
|
358
|
+
};
|
|
359
|
+
const widget = widgetFactory(null, mockTheme);
|
|
360
|
+
|
|
361
|
+
// Render with enough width to see the content
|
|
362
|
+
const lines = widget.render(100);
|
|
363
|
+
|
|
364
|
+
// Should contain the new thinking, not the old
|
|
365
|
+
const allText = lines.join(' ');
|
|
366
|
+
expect(allText).toContain('Completely new thinking');
|
|
367
|
+
expect(allText).not.toContain('Initial thinking content');
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('does not flash back old thoughts when clear animation is interrupted', () => {
|
|
371
|
+
const ctx = createMockCtx();
|
|
372
|
+
const state = createMockState();
|
|
373
|
+
|
|
374
|
+
// Step 1: Set initial thinking content
|
|
375
|
+
updateUI(ctx, state, {
|
|
376
|
+
type: 'analyzing',
|
|
377
|
+
turn: 1,
|
|
378
|
+
thinking: 'First analysis thinking content that will be cleared',
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Step 2: Trigger the clear animation by going to watching state (simulating 15s delay completion)
|
|
382
|
+
// First we need to trigger the clear timer - this happens when we go to a non-analyzing state
|
|
383
|
+
// or when state becomes inactive. Let's use a steering action which triggers the animation.
|
|
384
|
+
updateUI(ctx, state, {
|
|
385
|
+
type: 'steering',
|
|
386
|
+
message: 'Please focus',
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// Step 3: Now simulate agent_end firing with new analyzing content
|
|
390
|
+
// This is the key test - if old thoughts flash back, the bug exists
|
|
391
|
+
updateUI(ctx, state, {
|
|
392
|
+
type: 'analyzing',
|
|
393
|
+
turn: 2,
|
|
394
|
+
thinking: 'Fresh analysis after steering',
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// Get the final widget state
|
|
398
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
399
|
+
const widgetFactory = lastCall[1];
|
|
400
|
+
const mockTheme = {
|
|
401
|
+
fg: (color: string, text: string) => text,
|
|
402
|
+
};
|
|
403
|
+
const widget = widgetFactory(null, mockTheme);
|
|
404
|
+
const lines = widget.render(100);
|
|
405
|
+
|
|
406
|
+
// Verify fresh content is shown, old content is not flashed back
|
|
407
|
+
const allText = lines.join(' ');
|
|
408
|
+
expect(allText).toContain('Fresh analysis after steering');
|
|
409
|
+
expect(allText).not.toContain('First analysis thinking');
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it('preserves thinking lines when leaving analyzing for steering (animation will clear)', () => {
|
|
413
|
+
const ctx = createMockCtx();
|
|
414
|
+
const state = createMockState();
|
|
415
|
+
|
|
416
|
+
// Step 1: Start with analyzing and thinking content
|
|
417
|
+
updateUI(ctx, state, {
|
|
418
|
+
type: 'analyzing',
|
|
419
|
+
turn: 1,
|
|
420
|
+
thinking: 'Analysis thinking that should animate out on steer',
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
// Step 2: Transition to steering - thinking lines are preserved for animation
|
|
424
|
+
updateUI(ctx, state, {
|
|
425
|
+
type: 'steering',
|
|
426
|
+
message: 'Please fix this issue',
|
|
427
|
+
reframeTier: 0,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Get the widget state after steering
|
|
431
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
432
|
+
const widgetFactory = lastCall[1];
|
|
433
|
+
const mockTheme = {
|
|
434
|
+
fg: (color: string, text: string) => text,
|
|
435
|
+
};
|
|
436
|
+
const widget = widgetFactory(null, mockTheme);
|
|
437
|
+
const lines = widget.render(100);
|
|
438
|
+
|
|
439
|
+
// The thinking content should be preserved for animation (not immediately cleared)
|
|
440
|
+
expect(lines.length).toBeGreaterThan(1); // Header + thinking lines
|
|
441
|
+
const allText = lines.join(' ');
|
|
442
|
+
expect(allText).toContain('steering');
|
|
443
|
+
expect(allText).toContain('Analysis thinking');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
it('preserves thinking lines through multiple rapid steers (animation handles clearing)', () => {
|
|
447
|
+
const ctx = createMockCtx();
|
|
448
|
+
const state = createMockState();
|
|
449
|
+
|
|
450
|
+
// Simulate rapid steers after analysis (the 5 steers bug scenario)
|
|
451
|
+
updateUI(ctx, state, {
|
|
452
|
+
type: 'analyzing',
|
|
453
|
+
turn: 1,
|
|
454
|
+
thinking: 'Initial analysis content',
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
// Multiple rapid steers (faster than 15s animation delay)
|
|
458
|
+
// Each steer preserves thinking lines - animation will clear them after 15s delay
|
|
459
|
+
for (let i = 0; i < 5; i++) {
|
|
460
|
+
updateUI(ctx, state, {
|
|
461
|
+
type: 'steering',
|
|
462
|
+
message: `Steer ${i + 1}`,
|
|
463
|
+
reframeTier: 0,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Final state should preserve thinking lines (they animate out after delay)
|
|
468
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
469
|
+
const widgetFactory = lastCall[1];
|
|
470
|
+
const mockTheme = {
|
|
471
|
+
fg: (color: string, text: string) => text,
|
|
472
|
+
};
|
|
473
|
+
const widget = widgetFactory(null, mockTheme);
|
|
474
|
+
const lines = widget.render(100);
|
|
475
|
+
|
|
476
|
+
// Should have header + thinking lines (animation will clear them)
|
|
477
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
478
|
+
const allText = lines.join(' ');
|
|
479
|
+
expect(allText).toContain('Initial analysis');
|
|
480
|
+
expect(allText).toContain('steering');
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
it('preserves thinking lines when leaving analyzing for done state (animation will clear)', () => {
|
|
484
|
+
const ctx = createMockCtx();
|
|
485
|
+
const state = createMockState();
|
|
486
|
+
|
|
487
|
+
// Start with analyzing
|
|
488
|
+
updateUI(ctx, state, {
|
|
489
|
+
type: 'analyzing',
|
|
490
|
+
turn: 1,
|
|
491
|
+
thinking: 'Analysis that will animate out on done',
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
// Transition to done - thinking lines are preserved for animation
|
|
495
|
+
updateUI(ctx, state, { type: 'done' });
|
|
496
|
+
|
|
497
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
498
|
+
const widgetFactory = lastCall[1];
|
|
499
|
+
const mockTheme = {
|
|
500
|
+
fg: (color: string, text: string) => text,
|
|
501
|
+
};
|
|
502
|
+
const widget = widgetFactory(null, mockTheme);
|
|
503
|
+
const lines = widget.render(100);
|
|
504
|
+
|
|
505
|
+
// Should have header + thinking lines (animation will clear them)
|
|
506
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
507
|
+
const allText = lines.join(' ');
|
|
508
|
+
expect(allText).toContain('done');
|
|
509
|
+
expect(allText).toContain('Analysis that will animate out');
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it('does not flash old thoughts when re-entering analyzing after steering', () => {
|
|
513
|
+
const ctx = createMockCtx();
|
|
514
|
+
const state = createMockState();
|
|
515
|
+
|
|
516
|
+
// Initial analysis
|
|
517
|
+
updateUI(ctx, state, {
|
|
518
|
+
type: 'analyzing',
|
|
519
|
+
turn: 1,
|
|
520
|
+
thinking: 'First round of analysis',
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
// Steer
|
|
524
|
+
updateUI(ctx, state, {
|
|
525
|
+
type: 'steering',
|
|
526
|
+
message: 'Fix this',
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
// New analysis - should NOT show old thinking even briefly
|
|
530
|
+
updateUI(ctx, state, {
|
|
531
|
+
type: 'analyzing',
|
|
532
|
+
turn: 2,
|
|
533
|
+
thinking: 'Fresh second round analysis',
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
537
|
+
const widgetFactory = lastCall[1];
|
|
538
|
+
const mockTheme = {
|
|
539
|
+
fg: (color: string, text: string) => text,
|
|
540
|
+
};
|
|
541
|
+
const widget = widgetFactory(null, mockTheme);
|
|
542
|
+
const lines = widget.render(100);
|
|
543
|
+
|
|
544
|
+
const allText = lines.join(' ');
|
|
545
|
+
expect(allText).toContain('Fresh second round');
|
|
546
|
+
expect(allText).not.toContain('First round');
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
it('preserves animation state when same thinking content is updated', () => {
|
|
550
|
+
const ctx = createMockCtx();
|
|
551
|
+
const state = createMockState();
|
|
552
|
+
|
|
553
|
+
// First call with thinking
|
|
554
|
+
updateUI(ctx, state, {
|
|
555
|
+
type: 'analyzing',
|
|
556
|
+
turn: 1,
|
|
557
|
+
thinking: 'Streaming thinking content',
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
// Second call with same thinking (simulating streaming update with same content)
|
|
561
|
+
updateUI(ctx, state, {
|
|
562
|
+
type: 'analyzing',
|
|
563
|
+
turn: 1,
|
|
564
|
+
thinking: 'Streaming thinking content',
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
// Should still have the content (not cleared since it's the same)
|
|
568
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
569
|
+
const widgetFactory = lastCall[1];
|
|
570
|
+
const mockTheme = {
|
|
571
|
+
fg: (color: string, text: string) => text,
|
|
572
|
+
};
|
|
573
|
+
const widget = widgetFactory(null, mockTheme);
|
|
574
|
+
const lines = widget.render(100);
|
|
575
|
+
|
|
576
|
+
const allText = lines.join(' ');
|
|
577
|
+
expect(allText).toContain('Streaming thinking content');
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
it('clears thoughts when going to done state', () => {
|
|
581
|
+
const ctx = createMockCtx();
|
|
582
|
+
const state = createMockState();
|
|
583
|
+
|
|
584
|
+
// First set some thinking
|
|
585
|
+
updateUI(ctx, state, {
|
|
586
|
+
type: 'analyzing',
|
|
587
|
+
turn: 1,
|
|
588
|
+
thinking: 'Analysis in progress',
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
// Then go to done state
|
|
592
|
+
updateUI(ctx, state, { type: 'done' });
|
|
593
|
+
|
|
594
|
+
// Should trigger clear timer setup (but not immediately clear)
|
|
595
|
+
// The clear animation should be scheduled
|
|
596
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
597
|
+
const widgetFactory = lastCall[1];
|
|
598
|
+
const mockTheme = {
|
|
599
|
+
fg: (color: string, text: string) => text,
|
|
600
|
+
};
|
|
601
|
+
const widget = widgetFactory(null, mockTheme);
|
|
602
|
+
const lines = widget.render(100);
|
|
603
|
+
|
|
604
|
+
// The done action should show the done indicator
|
|
605
|
+
const allText = lines.join(' ');
|
|
606
|
+
expect(allText).toContain('done');
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
it('handles transition from cleared state to new analysis', async () => {
|
|
610
|
+
const ctx = createMockCtx();
|
|
611
|
+
const state = createMockState();
|
|
612
|
+
|
|
613
|
+
// Initial analysis
|
|
614
|
+
updateUI(ctx, state, {
|
|
615
|
+
type: 'analyzing',
|
|
616
|
+
turn: 1,
|
|
617
|
+
thinking: 'Initial analysis',
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
// Go to done (triggers clear)
|
|
621
|
+
updateUI(ctx, state, { type: 'done' });
|
|
622
|
+
|
|
623
|
+
// Simulate another agent_end with new analysis
|
|
624
|
+
// This should show new thinking, not old
|
|
625
|
+
updateUI(ctx, state, {
|
|
626
|
+
type: 'analyzing',
|
|
627
|
+
turn: 2,
|
|
628
|
+
thinking: 'New analysis after completion',
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
const lastCall = ctx.ui.setWidget.mock.calls[ctx.ui.setWidget.mock.calls.length - 1];
|
|
632
|
+
const widgetFactory = lastCall[1];
|
|
633
|
+
const mockTheme = {
|
|
634
|
+
fg: (color: string, text: string) => text,
|
|
635
|
+
};
|
|
636
|
+
const widget = widgetFactory(null, mockTheme);
|
|
637
|
+
const lines = widget.render(100);
|
|
638
|
+
|
|
639
|
+
const allText = lines.join(' ');
|
|
640
|
+
expect(allText).toContain('New analysis after completion');
|
|
641
|
+
expect(allText).not.toContain('Initial analysis');
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it('preserves thinking lines when supervisor becomes inactive after done (animation will clear)', () => {
|
|
645
|
+
const ctx = createMockCtx();
|
|
646
|
+
const state = createMockState();
|
|
647
|
+
|
|
648
|
+
// Step 1: Start with analyzing and thinking content
|
|
649
|
+
updateUI(ctx, state, {
|
|
650
|
+
type: 'analyzing',
|
|
651
|
+
turn: 1,
|
|
652
|
+
thinking: 'Analysis thinking that should animate clear',
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// Step 2: Transition to done while still active
|
|
656
|
+
updateUI(ctx, state, { type: 'done' });
|
|
657
|
+
|
|
658
|
+
// Verify the widget was rendered with 'done' status
|
|
659
|
+
const doneCalls = ctx.ui.setWidget.mock.calls.filter((call: any[]) => call[0] === 'loop');
|
|
660
|
+
expect(doneCalls.length).toBeGreaterThan(0);
|
|
661
|
+
|
|
662
|
+
const lastDoneCall = doneCalls[doneCalls.length - 1];
|
|
663
|
+
expect(lastDoneCall[1]).not.toBeUndefined();
|
|
664
|
+
|
|
665
|
+
// Verify thinking lines are preserved (shown until animation clears them)
|
|
666
|
+
const widgetFactory = lastDoneCall[1];
|
|
667
|
+
const mockTheme = { fg: (color: string, text: string) => text };
|
|
668
|
+
const widget = widgetFactory(null, mockTheme);
|
|
669
|
+
const lines = widget.render(100);
|
|
670
|
+
expect(lines.length).toBeGreaterThan(1);
|
|
671
|
+
expect(lines.join(' ')).toContain('done');
|
|
672
|
+
expect(lines.join(' ')).toContain('Analysis thinking');
|
|
673
|
+
|
|
674
|
+
// Step 3: Verify widget was updated after going inactive (animation path or clear)
|
|
675
|
+
const callsAfterDone = ctx.ui.setWidget.mock.calls.length;
|
|
676
|
+
|
|
677
|
+
const inactiveState = { ...state, active: false };
|
|
678
|
+
updateUI(ctx, inactiveState as any);
|
|
679
|
+
|
|
680
|
+
// Widget should be updated (either animation setup or clear)
|
|
681
|
+
expect(ctx.ui.setWidget.mock.calls.length).toBeGreaterThanOrEqual(callsAfterDone);
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
describe('widget visibility', () => {
|
|
686
|
+
it('hides widget when toggle is off', () => {
|
|
687
|
+
const ctx = createMockCtx();
|
|
688
|
+
const state = createMockState();
|
|
689
|
+
|
|
690
|
+
// Hide the widget
|
|
691
|
+
toggleWidget();
|
|
692
|
+
|
|
693
|
+
updateUI(ctx, state, {
|
|
694
|
+
type: 'analyzing',
|
|
695
|
+
turn: 1,
|
|
696
|
+
thinking: 'Should not be visible',
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
// Should set widget to undefined when hidden
|
|
700
|
+
expect(ctx.ui.setWidget).toHaveBeenCalledWith('loop', undefined);
|
|
701
|
+
});
|
|
702
|
+
});
|
|
703
|
+
});
|