repoburg 1.3.201 → 1.3.202
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/agent-pane/backend/data.db-shm +0 -0
- package/agent-pane/backend/data.db-wal +0 -0
- package/agent-pane/backend/dist/index.js +478 -250
- package/agent-pane/backend/src/db/index.ts +6 -0
- package/agent-pane/backend/src/providers/runtime-provider.ts +6 -0
- package/agent-pane/backend/src/repoburg/action-mapping.test.ts +122 -0
- package/agent-pane/backend/src/repoburg/action-mapping.ts +89 -11
- package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +23 -0
- package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +85 -0
- package/agent-pane/backend/src/repoburg/backend-resolver.ts +28 -13
- package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +21 -0
- package/agent-pane/backend/src/repoburg/repoburg-client.ts +13 -0
- package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +57 -0
- package/agent-pane/backend/src/repoburg/repoburg-provider.ts +49 -0
- package/agent-pane/backend/src/repoburg/turn-projector.test.ts +147 -1
- package/agent-pane/backend/src/repoburg/turn-projector.ts +101 -1
- package/agent-pane/backend/src/routes/sessions.test.ts +215 -1
- package/agent-pane/backend/src/routes/sessions.ts +70 -9
- package/agent-pane/frontend/dist/assets/index-CJxD4YmJ.js +370 -0
- package/agent-pane/frontend/dist/assets/{index-34WaZYsW.css → index-r-xwfOKQ.css} +1 -1
- package/agent-pane/frontend/dist/index.html +2 -2
- package/agent-pane/frontend/src/api/sessions.test.ts +1 -0
- package/agent-pane/frontend/src/api/websocket.test.ts +2 -2
- package/agent-pane/frontend/src/api/websocket.ts +3 -1
- package/agent-pane/frontend/src/components/AskUserItem.test.tsx +143 -0
- package/agent-pane/frontend/src/components/AskUserItem.tsx +207 -0
- package/agent-pane/frontend/src/components/TurnContent.test.tsx +79 -0
- package/agent-pane/frontend/src/components/TurnContent.tsx +49 -3
- package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +48 -0
- package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +7 -2
- package/agent-pane/frontend/src/store/store.test.ts +19 -0
- package/agent-pane/frontend/src/store/wsSlice.ts +10 -3
- package/agent-pane/frontend/src/styles/app.css +16 -0
- package/agent-pane/frontend/src/test/fixtures.ts +1 -0
- package/agent-pane/frontend/src/types/index.ts +2 -0
- package/agent-pane/frontend/src/utils/duration.test.ts +1 -0
- package/agent-pane/shared/types.ts +17 -0
- package/askuser-after-step5-postgres-selected.png +0 -0
- package/askuser-after-step7-answer-sent.png +0 -0
- package/askuser-card-visible.png +0 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +16 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +87 -0
- package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js.map +1 -1
- package/backend/dist/src/agent-runtime/agent-runtime.types.d.ts +16 -1
- package/backend/dist/src/app.controller.d.ts +1 -0
- package/backend/dist/src/app.service.d.ts +1 -0
- package/backend/dist/src/app.service.js +4 -1
- package/backend/dist/src/app.service.js.map +1 -1
- package/backend/dist/src/interactive-chat/chat.service.d.ts +4 -0
- package/backend/dist/src/interactive-chat/chat.service.js +21 -1
- package/backend/dist/src/interactive-chat/chat.service.js.map +1 -1
- package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js +12 -1
- package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js.map +1 -1
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.d.ts +1 -0
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js +1 -0
- package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js.map +1 -1
- package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js +2 -0
- package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js.map +1 -1
- package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
- package/daemon/dist/api/services.js +4 -0
- package/daemon/dist/serviceManager.js +30 -11
- package/daemon/dist/serviceManager.spec.js +26 -0
- package/daemon/dist/workspace-validation.js +63 -0
- package/daemon/dist/workspace-validation.spec.js +63 -0
- package/package.json +1 -1
- package/r2-step4-apple-banana.png +0 -0
- package/r2-step5-after-rewind-apple-last.png +0 -0
- package/r2-step7-apple-cherry.png +0 -0
- package/r2-step8-cherry-menu-no-restore.png +0 -0
- package/r3-step3-live-task-card-running.png +0 -0
- package/r3-step4-after-completion.png +0 -0
- package/r3-step5-persistence-after-reload.png +0 -0
- package/r3-step6-tasks-tab-card.png +0 -0
- package/reverify-answered-state-answer-sent.png +0 -0
- package/reverify-card-visible-after-completion.png +0 -0
- package/rewind-step3-apple-banana-turns.png +0 -0
- package/rewind-step5-ui-unchanged-after-confirmed-rewind.png +0 -0
- package/rewind-step6-apple-menu-no-restore.png +0 -0
- package/rewind-step7-final-turnlist-unchanged.png +0 -0
- package/rewind-step7-negative-error-toast.png +0 -0
- package/rewind-step7-negative-error-toast2.png +0 -0
- package/subagent-r2-step5-turn-after-reload-no-card.png +0 -0
- package/subagent-r2-step6-tasks-panel-card.png +0 -0
- package/subagent-step3-tasks-tab-done-card.png +0 -0
- package/subagent-step4-turn-stuck-no-cards.png +0 -0
- package/agent-pane/frontend/dist/assets/index-y7xlT86e.js +0 -362
|
@@ -66,6 +66,7 @@ function makeMockClient(): { client: RepoburgRuntimeClient } {
|
|
|
66
66
|
createInput: vi.fn(),
|
|
67
67
|
getInputWithActions: vi.fn(),
|
|
68
68
|
getSessionInputs: vi.fn(),
|
|
69
|
+
deleteInputsUntilHere: vi.fn().mockResolvedValue(undefined),
|
|
69
70
|
cancel: vi.fn().mockResolvedValue({ success: true, cancelled: true }),
|
|
70
71
|
getModels: vi.fn(),
|
|
71
72
|
} as unknown as RepoburgRuntimeClient;
|
|
@@ -136,6 +137,58 @@ describe('repoburgProvider', () => {
|
|
|
136
137
|
expect(repoburgProvider.getModels).toBeTypeOf('function');
|
|
137
138
|
expect(repoburgProvider.shutdown).toBeTypeOf('function');
|
|
138
139
|
});
|
|
140
|
+
it('implements all RuntimeProvider methods', () => {
|
|
141
|
+
expect(repoburgProvider.startTurn).toBeTypeOf('function');
|
|
142
|
+
expect(repoburgProvider.interruptTurn).toBeTypeOf('function');
|
|
143
|
+
expect(repoburgProvider.clearThread).toBeTypeOf('function');
|
|
144
|
+
expect(repoburgProvider.clearAllThreads).toBeTypeOf('function');
|
|
145
|
+
expect(repoburgProvider.getModels).toBeTypeOf('function');
|
|
146
|
+
expect(repoburgProvider.rewindRuntime).toBeTypeOf('function');
|
|
147
|
+
expect(repoburgProvider.shutdown).toBeTypeOf('function');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('rewindRuntime truncates via until-here with the LAST runtime input id', async () => {
|
|
151
|
+
db.prepare('UPDATE sessions SET external_thread_id = ? WHERE id = ?').run('rs-1', 's1');
|
|
152
|
+
const { session } = seedTurn('t1');
|
|
153
|
+
db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
|
|
154
|
+
JSON.stringify(['in-1', 'in-2', 'in-3']),
|
|
155
|
+
't1',
|
|
156
|
+
);
|
|
157
|
+
// Re-load the turn row AFTER the update (seedTurn fetched a stale row).
|
|
158
|
+
const turn = db.prepare('SELECT * FROM turns WHERE id = ?').get('t1') as Turn;
|
|
159
|
+
|
|
160
|
+
await repoburgProvider.rewindRuntime!(session, turn);
|
|
161
|
+
|
|
162
|
+
expect(mock.client.deleteInputsUntilHere).toHaveBeenCalledWith('rs-1', 'in-3');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('rewindRuntime throws when the turn has no runtime input ids', async () => {
|
|
166
|
+
db.prepare('UPDATE sessions SET external_thread_id = ? WHERE id = ?').run('rs-1', 's1');
|
|
167
|
+
const { session, turn } = seedTurn('t1');
|
|
168
|
+
|
|
169
|
+
await expect(repoburgProvider.rewindRuntime!(session, turn)).rejects.toThrow(
|
|
170
|
+
'no runtime input ids',
|
|
171
|
+
);
|
|
172
|
+
expect(mock.client.deleteInputsUntilHere).not.toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('rewindRuntime is a no-op when the session has no runtime thread', async () => {
|
|
176
|
+
const { session, turn } = seedTurn('t1');
|
|
177
|
+
|
|
178
|
+
await repoburgProvider.rewindRuntime!(session, turn);
|
|
179
|
+
|
|
180
|
+
expect(mock.client.deleteInputsUntilHere).not.toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('rewindRuntime accepts the typed Turn array form of runtime_input_ids', async () => {
|
|
184
|
+
db.prepare('UPDATE sessions SET external_thread_id = ? WHERE id = ?').run('rs-1', 's1');
|
|
185
|
+
const { session, turn } = seedTurn('t1');
|
|
186
|
+
const typedTurn = { ...turn, runtime_input_ids: ['in-a', 'in-b'] } as Turn;
|
|
187
|
+
|
|
188
|
+
await repoburgProvider.rewindRuntime!(session, typedTurn);
|
|
189
|
+
|
|
190
|
+
expect(mock.client.deleteInputsUntilHere).toHaveBeenCalledWith('rs-1', 'in-b');
|
|
191
|
+
});
|
|
139
192
|
|
|
140
193
|
describe('mapReasoningEffort', () => {
|
|
141
194
|
it('passes valid Ollama levels through and clamps xhigh to high', () => {
|
|
@@ -442,6 +495,7 @@ describe('repoburgProvider', () => {
|
|
|
442
495
|
tool_name: null,
|
|
443
496
|
arguments: null,
|
|
444
497
|
plain: null,
|
|
498
|
+
selections: null,
|
|
445
499
|
order_of_execution: 0,
|
|
446
500
|
},
|
|
447
501
|
},
|
|
@@ -460,6 +514,7 @@ describe('repoburgProvider', () => {
|
|
|
460
514
|
tool_name: null,
|
|
461
515
|
arguments: null,
|
|
462
516
|
plain: null,
|
|
517
|
+
selections: null,
|
|
463
518
|
order_of_execution: 1,
|
|
464
519
|
},
|
|
465
520
|
},
|
|
@@ -606,6 +661,7 @@ describe('repoburgProvider', () => {
|
|
|
606
661
|
tool_name: null,
|
|
607
662
|
arguments: null,
|
|
608
663
|
plain: null,
|
|
664
|
+
selections: null,
|
|
609
665
|
order_of_execution: 0,
|
|
610
666
|
},
|
|
611
667
|
},
|
|
@@ -641,6 +697,7 @@ describe('repoburgProvider', () => {
|
|
|
641
697
|
tool_name: null,
|
|
642
698
|
arguments: null,
|
|
643
699
|
plain: null,
|
|
700
|
+
selections: null,
|
|
644
701
|
order_of_execution: 0,
|
|
645
702
|
},
|
|
646
703
|
},
|
|
@@ -262,6 +262,16 @@ export async function startTurn(session: Session, turn: Turn): Promise<void> {
|
|
|
262
262
|
return;
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// Pinned-session cross-check (belt and braces): a daemon reshuffle may
|
|
266
|
+
// resolve a different URL than the one pinned in the session entry. The
|
|
267
|
+
// pinned entry wins until the session ends (deliberate) — log the mismatch.
|
|
268
|
+
const pinned = sessionMap.get(session.id);
|
|
269
|
+
if (pinned && pinned.baseUrl !== baseUrl) {
|
|
270
|
+
console.warn(
|
|
271
|
+
`[repoburg] resolved backend ${baseUrl} differs from pinned ${pinned.baseUrl} for session ${session.id} — keeping the pinned entry.`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
265
275
|
// Phase 1: ensure the runtime session exists.
|
|
266
276
|
let entry: SessionEntry;
|
|
267
277
|
try {
|
|
@@ -372,6 +382,44 @@ export function clearThread(sessionId: string): void {
|
|
|
372
382
|
sessionMap.delete(sessionId);
|
|
373
383
|
}
|
|
374
384
|
|
|
385
|
+
/** Parse a turn row's runtime_input_ids (JSON string in DB, array in typed Turn). */
|
|
386
|
+
function parseRuntimeInputIds(turn: Turn): string[] {
|
|
387
|
+
const raw = (turn as Turn & { runtime_input_ids?: string | string[] | null }).runtime_input_ids;
|
|
388
|
+
if (Array.isArray(raw)) return raw;
|
|
389
|
+
if (typeof raw === 'string' && raw) {
|
|
390
|
+
try {
|
|
391
|
+
const parsed = JSON.parse(raw);
|
|
392
|
+
if (Array.isArray(parsed)) return parsed as string[];
|
|
393
|
+
} catch {
|
|
394
|
+
// Corrupt JSON — no anchor.
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return [];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Truncate the runtime session IN PLACE (platform-parity rewind): hard-delete
|
|
401
|
+
* every runtime input after the target turn's LAST runtime input via the
|
|
402
|
+
* until-here endpoint. Throws on failure — the caller skips the local rewind.
|
|
403
|
+
* forkSession is intentionally NOT implemented for repoburg (a hard-deleted
|
|
404
|
+
* session cannot be forked; truncation is the rewind primitive). */
|
|
405
|
+
export async function rewindRuntime(session: Session, targetTurn: Turn): Promise<void> {
|
|
406
|
+
if (!session.external_thread_id) return; // no runtime session yet — nothing to truncate
|
|
407
|
+
const anchor = parseRuntimeInputIds(targetTurn).pop();
|
|
408
|
+
if (!anchor) {
|
|
409
|
+
throw new Error(
|
|
410
|
+
'Cannot truncate runtime history — the target turn has no runtime input ids (turn predates rewind support or never ran on repoburg).',
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
const entry = sessionMap.get(session.id);
|
|
414
|
+
let client = entry?.client ?? null;
|
|
415
|
+
if (!client) {
|
|
416
|
+
const resolved = await resolveBackendUrl(session.cwd ?? process.cwd());
|
|
417
|
+
if (!resolved) throw new Error('No repoburg backend resolved.');
|
|
418
|
+
client = new RepoburgHttpClient(resolved.baseUrl);
|
|
419
|
+
}
|
|
420
|
+
await client.deleteInputsUntilHere(session.external_thread_id, anchor);
|
|
421
|
+
}
|
|
422
|
+
|
|
375
423
|
/** Clear all session state. Used in test cleanup. */
|
|
376
424
|
export function clearAllThreads(): void {
|
|
377
425
|
for (const entry of sessionMap.values()) {
|
|
@@ -418,5 +466,6 @@ export const repoburgProvider: RuntimeProvider = {
|
|
|
418
466
|
clearThread,
|
|
419
467
|
clearAllThreads,
|
|
420
468
|
getModels,
|
|
469
|
+
rewindRuntime,
|
|
421
470
|
shutdown,
|
|
422
471
|
};
|
|
@@ -34,6 +34,7 @@ function actionInfo(overrides: Partial<AgentRuntimeActionInfo> = {}): AgentRunti
|
|
|
34
34
|
tool_name: null,
|
|
35
35
|
arguments: null,
|
|
36
36
|
plain: null,
|
|
37
|
+
selections: null,
|
|
37
38
|
order_of_execution: 0,
|
|
38
39
|
...overrides,
|
|
39
40
|
};
|
|
@@ -116,6 +117,36 @@ describe('TurnProjector', () => {
|
|
|
116
117
|
expect(delta).toBeDefined();
|
|
117
118
|
expect(delta!.data).toMatchObject({ turnId: TURN_ID, itemId: 'in-1-text', delta: 'lo' });
|
|
118
119
|
});
|
|
120
|
+
it('records runtime input ids into turns.runtime_input_ids (rewind anchor mapping)', () => {
|
|
121
|
+
const p = makeProjector();
|
|
122
|
+
|
|
123
|
+
// Round 1
|
|
124
|
+
p.handle({
|
|
125
|
+
type: 'assistant',
|
|
126
|
+
sessionId: RUNTIME_SESSION_ID,
|
|
127
|
+
inputId: 'in-1',
|
|
128
|
+
content: [{ type: 'text', text: 'Round one' }],
|
|
129
|
+
});
|
|
130
|
+
// Round 2 (multi-round turn)
|
|
131
|
+
p.handle({
|
|
132
|
+
type: 'assistant',
|
|
133
|
+
sessionId: RUNTIME_SESSION_ID,
|
|
134
|
+
inputId: 'in-2',
|
|
135
|
+
content: [{ type: 'text', text: 'Round two' }],
|
|
136
|
+
});
|
|
137
|
+
// Replay of an existing round must NOT duplicate the id
|
|
138
|
+
p.handle({
|
|
139
|
+
type: 'stream_event',
|
|
140
|
+
sessionId: RUNTIME_SESSION_ID,
|
|
141
|
+
inputId: 'in-2',
|
|
142
|
+
delta: { type: 'text', text: ' again' },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const row = db
|
|
146
|
+
.prepare('SELECT runtime_input_ids FROM turns WHERE id = ?')
|
|
147
|
+
.get(TURN_ID) as { runtime_input_ids: string | null };
|
|
148
|
+
expect(JSON.parse(row.runtime_input_ids!)).toEqual(['in-1', 'in-2']);
|
|
149
|
+
});
|
|
119
150
|
|
|
120
151
|
it('completes streamed items when the assistant message arrives', () => {
|
|
121
152
|
const p = makeProjector();
|
|
@@ -292,6 +323,10 @@ describe('TurnProjector', () => {
|
|
|
292
323
|
cached_input_tokens: 2,
|
|
293
324
|
});
|
|
294
325
|
expect(captured.find((b) => b.event === 'turn:completed')).toBeDefined();
|
|
326
|
+
// The completion broadcast carries the final text — the FE renders it
|
|
327
|
+
// for final-action turns that have no agent_message item.
|
|
328
|
+
const completed = captured.find((b) => b.event === 'turn:completed');
|
|
329
|
+
expect(completed!.data).toMatchObject({ response: 'All done' });
|
|
295
330
|
|
|
296
331
|
// Session marked not-running with unreviewed actions.
|
|
297
332
|
const session = db.prepare('SELECT is_running, status, has_unreviewed FROM sessions WHERE id = ?').get(SESSION_ID) as never;
|
|
@@ -453,7 +488,118 @@ describe('TurnProjector', () => {
|
|
|
453
488
|
expect(row.error).toMatch(/still running/);
|
|
454
489
|
});
|
|
455
490
|
|
|
456
|
-
|
|
491
|
+
describe('subagent lifecycle → background_task', () => {
|
|
492
|
+
/** One registered sub-agent event message. */
|
|
493
|
+
function subagent(event: 'started' | 'progress' | 'completed' | 'failed', extra: Record<string, unknown> = {}): AgentRuntimeMessage {
|
|
494
|
+
return {
|
|
495
|
+
type: 'subagent',
|
|
496
|
+
sessionId: RUNTIME_SESSION_ID,
|
|
497
|
+
event,
|
|
498
|
+
task_id: 'child-1',
|
|
499
|
+
agent_name: 'explore-codebase',
|
|
500
|
+
description: 'Find auth files',
|
|
501
|
+
input_id: 'in-1',
|
|
502
|
+
action_id: 'act-1',
|
|
503
|
+
...extra,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
it('started/progress/completed converge on ONE background_task item', () => {
|
|
508
|
+
const p = makeProjector();
|
|
509
|
+
|
|
510
|
+
p.handle(subagent('started'));
|
|
511
|
+
let rows = items();
|
|
512
|
+
expect(rows).toHaveLength(1);
|
|
513
|
+
expect(rows[0]).toMatchObject({
|
|
514
|
+
external_item_id: 'in-1-action-act-1',
|
|
515
|
+
type: 'background_task',
|
|
516
|
+
status: 'in_progress',
|
|
517
|
+
});
|
|
518
|
+
expect(JSON.parse(rows[0].data)).toMatchObject({
|
|
519
|
+
task_id: 'child-1',
|
|
520
|
+
description: 'Find auth files',
|
|
521
|
+
task_type: 'subagent',
|
|
522
|
+
subagent_type: 'explore-codebase',
|
|
523
|
+
status: 'running',
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
p.handle(subagent('progress', { description: 'create_file: src/auth.ts', last_tool_name: 'create_file', token_count: 120 }));
|
|
527
|
+
rows = items();
|
|
528
|
+
expect(rows).toHaveLength(1); // SAME item — no duplicates
|
|
529
|
+
expect(JSON.parse(rows[0].data)).toMatchObject({
|
|
530
|
+
description: 'create_file: src/auth.ts',
|
|
531
|
+
last_tool_name: 'create_file',
|
|
532
|
+
token_count: 120,
|
|
533
|
+
status: 'running',
|
|
534
|
+
});
|
|
535
|
+
const updated = captured.filter((b) => b.event === 'item:updated');
|
|
536
|
+
expect(updated).toHaveLength(1);
|
|
537
|
+
|
|
538
|
+
p.handle(subagent('completed', { summary: 'auth files found', usage: { input_tokens: 100, output_tokens: 20, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 } }));
|
|
539
|
+
rows = items();
|
|
540
|
+
expect(rows).toHaveLength(1);
|
|
541
|
+
expect(rows[0].status).toBe('completed');
|
|
542
|
+
expect(JSON.parse(rows[0].data)).toMatchObject({
|
|
543
|
+
status: 'completed',
|
|
544
|
+
summary: 'auth files found',
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it('failed settlement marks the card failed', () => {
|
|
549
|
+
const p = makeProjector();
|
|
550
|
+
|
|
551
|
+
p.handle(subagent('started'));
|
|
552
|
+
p.handle(subagent('failed', { summary: 'boom' }));
|
|
553
|
+
|
|
554
|
+
const rows = items();
|
|
555
|
+
expect(rows).toHaveLength(1);
|
|
556
|
+
expect(rows[0].status).toBe('failed');
|
|
557
|
+
expect(JSON.parse(rows[0].data)).toMatchObject({ status: 'failed', summary: 'boom' });
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('invoke_subagent tool_result with the same action id does NOT duplicate the live card', () => {
|
|
561
|
+
const p = makeProjector();
|
|
562
|
+
|
|
563
|
+
p.handle(subagent('started'));
|
|
564
|
+
p.handle({
|
|
565
|
+
type: 'user',
|
|
566
|
+
sessionId: RUNTIME_SESSION_ID,
|
|
567
|
+
inputId: 'in-1',
|
|
568
|
+
content: [
|
|
569
|
+
{
|
|
570
|
+
type: 'tool_result',
|
|
571
|
+
tool_use_id: 'act-1',
|
|
572
|
+
content: 'child output',
|
|
573
|
+
is_error: false,
|
|
574
|
+
action: actionInfo({
|
|
575
|
+
id: 'act-1',
|
|
576
|
+
action_type: 'invoke_subagent',
|
|
577
|
+
arguments: JSON.stringify({ agent_name: 'explore-codebase', prompt: 'Find auth files' }),
|
|
578
|
+
file_path: null,
|
|
579
|
+
}),
|
|
580
|
+
},
|
|
581
|
+
],
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
const rows = items();
|
|
585
|
+
expect(rows).toHaveLength(1);
|
|
586
|
+
expect(rows[0].external_item_id).toBe('in-1-action-act-1');
|
|
587
|
+
expect(rows[0].type).toBe('background_task');
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it('a completed card from subagent events survives a later completed event idempotently', () => {
|
|
591
|
+
const p = makeProjector();
|
|
592
|
+
|
|
593
|
+
p.handle(subagent('started'));
|
|
594
|
+
p.handle(subagent('completed', { summary: 'done' }));
|
|
595
|
+
p.handle(subagent('completed', { summary: 'done' })); // replay
|
|
596
|
+
|
|
597
|
+
const rows = items();
|
|
598
|
+
expect(rows).toHaveLength(1);
|
|
599
|
+
expect(rows[0].status).toBe('completed');
|
|
600
|
+
});
|
|
601
|
+
});
|
|
602
|
+
it('a live result cancels the watchdog', async () => {
|
|
457
603
|
vi.useFakeTimers();
|
|
458
604
|
const p = makeProjector();
|
|
459
605
|
p.handle({
|
|
@@ -26,12 +26,14 @@ import {
|
|
|
26
26
|
mapActionToItem,
|
|
27
27
|
mapUsage,
|
|
28
28
|
setSessionStatus,
|
|
29
|
+
updateItem,
|
|
29
30
|
} from './action-mapping.ts';
|
|
30
31
|
import type {
|
|
31
32
|
AgentRuntimeMessage,
|
|
32
33
|
AssistantMessage,
|
|
33
34
|
ResultMessage,
|
|
34
35
|
StreamEventMessage,
|
|
36
|
+
SubagentMessage,
|
|
35
37
|
UserMessage,
|
|
36
38
|
} from './agent-runtime-client.ts';
|
|
37
39
|
|
|
@@ -89,6 +91,9 @@ export class TurnProjector {
|
|
|
89
91
|
case 'user':
|
|
90
92
|
this.handleUser(message);
|
|
91
93
|
break;
|
|
94
|
+
case 'subagent':
|
|
95
|
+
this.handleSubagent(message);
|
|
96
|
+
break;
|
|
92
97
|
case 'result':
|
|
93
98
|
this.handleResult(message);
|
|
94
99
|
break;
|
|
@@ -124,10 +129,36 @@ export class TurnProjector {
|
|
|
124
129
|
finalPlain: null,
|
|
125
130
|
};
|
|
126
131
|
this.rounds.set(inputId, info);
|
|
132
|
+
this.recordRuntimeInput(inputId);
|
|
127
133
|
}
|
|
128
134
|
return info;
|
|
129
135
|
}
|
|
130
136
|
|
|
137
|
+
/** Append a runtime input id to the turn row's runtime_input_ids column —
|
|
138
|
+
* the rewind anchor mapping (anchor = LAST id of the target turn). */
|
|
139
|
+
private recordRuntimeInput(inputId: string): void {
|
|
140
|
+
try {
|
|
141
|
+
const db = getDb();
|
|
142
|
+
const row = db.prepare('SELECT runtime_input_ids FROM turns WHERE id = ?').get(this.deps.turnId) as
|
|
143
|
+
| { runtime_input_ids: string | null }
|
|
144
|
+
| undefined;
|
|
145
|
+
if (!row) return; // turn row gone (deleted session) — skip silently
|
|
146
|
+
let ids: string[] = [];
|
|
147
|
+
if (row.runtime_input_ids) {
|
|
148
|
+
const parsed = JSON.parse(row.runtime_input_ids);
|
|
149
|
+
if (Array.isArray(parsed)) ids = parsed;
|
|
150
|
+
}
|
|
151
|
+
if (ids.includes(inputId)) return;
|
|
152
|
+
ids.push(inputId);
|
|
153
|
+
db.prepare('UPDATE turns SET runtime_input_ids = ? WHERE id = ?').run(
|
|
154
|
+
JSON.stringify(ids),
|
|
155
|
+
this.deps.turnId,
|
|
156
|
+
);
|
|
157
|
+
} catch {
|
|
158
|
+
// Best-effort recording — rewind only loses anchor precision.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
131
162
|
private handleStreamEvent(msg: StreamEventMessage): void {
|
|
132
163
|
const db = getDb();
|
|
133
164
|
const info = this.round(msg.inputId);
|
|
@@ -271,7 +302,72 @@ export class TurnProjector {
|
|
|
271
302
|
this.touch();
|
|
272
303
|
}
|
|
273
304
|
|
|
274
|
-
|
|
305
|
+
/** Sub-agent lifecycle → background_task item (Claude data shape),
|
|
306
|
+
* idempotent upsert keyed `<inputId>-action-<actionId>` — the same
|
|
307
|
+
* external id the invoke_subagent tool_result/reconcile uses, so all
|
|
308
|
+
* paths converge on ONE card. */
|
|
309
|
+
private handleSubagent(msg: SubagentMessage): void {
|
|
310
|
+
const db = getDb();
|
|
311
|
+
const externalId = `${msg.input_id}-action-${msg.action_id}`;
|
|
312
|
+
const terminal = msg.event === 'completed' || msg.event === 'failed';
|
|
313
|
+
const itemStatus: Item['status'] = terminal
|
|
314
|
+
? msg.event === 'completed'
|
|
315
|
+
? 'completed'
|
|
316
|
+
: 'failed'
|
|
317
|
+
: 'in_progress';
|
|
318
|
+
|
|
319
|
+
const row = db
|
|
320
|
+
.prepare('SELECT * FROM items WHERE turn_id = ? AND external_item_id = ?')
|
|
321
|
+
.get(this.deps.turnId, externalId) as
|
|
322
|
+
| (Omit<Item, 'data'> & { data: string | null })
|
|
323
|
+
| undefined;
|
|
324
|
+
|
|
325
|
+
if (row) {
|
|
326
|
+
const prev = row.data ? (JSON.parse(row.data) as Record<string, unknown>) : {};
|
|
327
|
+
const item: Item = { ...row, data: prev } as unknown as Item;
|
|
328
|
+
updateItem(
|
|
329
|
+
db,
|
|
330
|
+
this.deps.agentPaneSessionId,
|
|
331
|
+
this.deps.turnId,
|
|
332
|
+
item,
|
|
333
|
+
{
|
|
334
|
+
...prev,
|
|
335
|
+
description: msg.description,
|
|
336
|
+
subagent_type: msg.agent_name,
|
|
337
|
+
status: terminal ? msg.event : 'running',
|
|
338
|
+
last_tool_name: msg.last_tool_name ?? prev.last_tool_name ?? null,
|
|
339
|
+
token_count: msg.token_count ?? prev.token_count ?? null,
|
|
340
|
+
...(terminal ? { summary: msg.summary ?? '' } : {}),
|
|
341
|
+
},
|
|
342
|
+
itemStatus,
|
|
343
|
+
);
|
|
344
|
+
} else {
|
|
345
|
+
insertItem(
|
|
346
|
+
db,
|
|
347
|
+
this.deps.agentPaneSessionId,
|
|
348
|
+
this.deps.turnId,
|
|
349
|
+
externalId,
|
|
350
|
+
'background_task',
|
|
351
|
+
{
|
|
352
|
+
task_id: msg.task_id,
|
|
353
|
+
description: msg.description,
|
|
354
|
+
task_type: 'subagent',
|
|
355
|
+
subagent_type: msg.agent_name,
|
|
356
|
+
prompt: null,
|
|
357
|
+
status: terminal ? msg.event : 'running',
|
|
358
|
+
summary: terminal ? msg.summary ?? '' : null,
|
|
359
|
+
output_file: null,
|
|
360
|
+
output: '',
|
|
361
|
+
token_count: msg.token_count ?? null,
|
|
362
|
+
last_tool_name: msg.last_tool_name ?? null,
|
|
363
|
+
},
|
|
364
|
+
itemStatus,
|
|
365
|
+
terminal ? 'item:completed' : 'item:started',
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
this.touch();
|
|
369
|
+
}
|
|
370
|
+
private handleResult(msg: ResultMessage): void {
|
|
275
371
|
switch (msg.subtype) {
|
|
276
372
|
case 'success':
|
|
277
373
|
this.finishSuccess(msg);
|
|
@@ -315,6 +411,9 @@ export class TurnProjector {
|
|
|
315
411
|
turnId: this.deps.turnId,
|
|
316
412
|
...(usage ? { usage } : {}),
|
|
317
413
|
hasUnreviewed: true,
|
|
414
|
+
// Final text — the FE renders it when the turn has no agent_message
|
|
415
|
+
// item (final-action rounds).
|
|
416
|
+
response: responseText || null,
|
|
318
417
|
});
|
|
319
418
|
void this.syncTitle();
|
|
320
419
|
this.finalize();
|
|
@@ -464,6 +563,7 @@ export class TurnProjector {
|
|
|
464
563
|
turnId: this.deps.turnId,
|
|
465
564
|
...(usage ? { usage } : {}),
|
|
466
565
|
hasUnreviewed: true,
|
|
566
|
+
response: responseText || null,
|
|
467
567
|
});
|
|
468
568
|
void this.syncTitle();
|
|
469
569
|
this.finalize();
|