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
|
@@ -193,6 +193,12 @@ function createTables(database: Database.Database): void {
|
|
|
193
193
|
database.exec('ALTER TABLE turns ADD COLUMN attachments TEXT');
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
// Migration: add 'runtime_input_ids' column to turns table (repoburg rewind
|
|
197
|
+
// anchor mapping — JSON array of the runtime input ids composing the turn)
|
|
198
|
+
if (!turnColumns.some((c) => c.name === 'runtime_input_ids')) {
|
|
199
|
+
database.exec('ALTER TABLE turns ADD COLUMN runtime_input_ids TEXT');
|
|
200
|
+
}
|
|
201
|
+
|
|
196
202
|
// Migration: add 'external_thread_id' column to discussion_threads (thread branching)
|
|
197
203
|
const threadColumns = database.prepare('PRAGMA table_info(discussion_threads)').all() as { name: string }[];
|
|
198
204
|
if (!threadColumns.some((c) => c.name === 'external_thread_id')) {
|
|
@@ -29,6 +29,12 @@ export interface RuntimeProvider {
|
|
|
29
29
|
* Returns the new provider-native session ID, or null if there is no thread to fork.
|
|
30
30
|
* Optional — providers without fork support omit it. */
|
|
31
31
|
forkSession?(session: Session, targetTurn: Turn): Promise<string | null>;
|
|
32
|
+
/** Truncate the runtime session's history IN PLACE after the target turn
|
|
33
|
+
* (platform-parity rewind: hard delete). Throws on failure — the caller
|
|
34
|
+
* must NOT apply any local rewind when this fails.
|
|
35
|
+
* Optional — providers without in-place truncation omit it (e.g. Claude
|
|
36
|
+
* uses forkSession instead). */
|
|
37
|
+
rewindRuntime?(session: Session, targetTurn: Turn): Promise<void>;
|
|
32
38
|
/** Change the model for a running session without restarting the CLI process.
|
|
33
39
|
* Only supported in streaming-input mode (Claude). If the session hasn't
|
|
34
40
|
* started yet, the new model is read from the DB on next startTurn.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mapActionToItem, parseSelectionGroups } from './action-mapping.ts';
|
|
3
|
+
import type { RepoburgAction } from './repoburg-client.ts';
|
|
4
|
+
|
|
5
|
+
function askUserAction(selections: string | null): RepoburgAction {
|
|
6
|
+
return {
|
|
7
|
+
id: 'a1',
|
|
8
|
+
action_type: 'ask_user',
|
|
9
|
+
status: 'CONFIRMED_KEPT',
|
|
10
|
+
selections,
|
|
11
|
+
order_of_execution: 0,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function subagentAction(status: string, output = ''): RepoburgAction {
|
|
16
|
+
return {
|
|
17
|
+
id: 'act-1',
|
|
18
|
+
action_type: 'invoke_subagent',
|
|
19
|
+
status,
|
|
20
|
+
arguments: JSON.stringify({ agent_name: 'explore-codebase', prompt: 'Find auth files' }),
|
|
21
|
+
order_of_execution: 0,
|
|
22
|
+
executionLogs: [{ output }],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('mapActionToItem — invoke_subagent', () => {
|
|
27
|
+
it('maps to a completed background_task card (snapshot/reload shape)', () => {
|
|
28
|
+
const mapped = mapActionToItem(subagentAction('CONFIRMED_KEPT', 'auth files found'));
|
|
29
|
+
expect(mapped).not.toBeNull();
|
|
30
|
+
expect(mapped!.type).toBe('background_task');
|
|
31
|
+
expect(mapped!.status).toBe('completed');
|
|
32
|
+
expect(mapped!.data).toMatchObject({
|
|
33
|
+
task_type: 'subagent',
|
|
34
|
+
subagent_type: 'explore-codebase',
|
|
35
|
+
description: 'Find auth files',
|
|
36
|
+
status: 'completed',
|
|
37
|
+
summary: 'auth files found',
|
|
38
|
+
last_tool_name: null,
|
|
39
|
+
token_count: null,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('maps a failed invoke_subagent to a failed background_task with the error as summary', () => {
|
|
44
|
+
const action = {
|
|
45
|
+
...subagentAction('EXECUTION_FAILED'),
|
|
46
|
+
executionLogs: [{ error_message: 'boom' }],
|
|
47
|
+
};
|
|
48
|
+
const mapped = mapActionToItem(action);
|
|
49
|
+
expect(mapped!.status).toBe('failed');
|
|
50
|
+
expect(mapped!.data).toMatchObject({ status: 'failed', summary: 'boom' });
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
describe('parseSelectionGroups', () => {
|
|
54
|
+
it('parses canonical [{question, selections[]}] groups', () => {
|
|
55
|
+
const raw = JSON.stringify([
|
|
56
|
+
{ question: 'Which database?', selections: ['Postgres', 'SQLite'] },
|
|
57
|
+
{ question: 'Proceed?', selections: ['yes', 'no'] },
|
|
58
|
+
]);
|
|
59
|
+
expect(parseSelectionGroups(raw)).toEqual([
|
|
60
|
+
{ question: 'Which database?', selections: ['Postgres', 'SQLite'] },
|
|
61
|
+
{ question: 'Proceed?', selections: ['yes', 'no'] },
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('coerces flat string arrays into single-option groups', () => {
|
|
66
|
+
expect(parseSelectionGroups(JSON.stringify(['Yes', 'No']))).toEqual([
|
|
67
|
+
{ question: '', selections: ['Yes'] },
|
|
68
|
+
{ question: '', selections: ['No'] },
|
|
69
|
+
]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('drops invalid entries and non-string options', () => {
|
|
73
|
+
const raw = JSON.stringify([
|
|
74
|
+
{ question: 'Q', selections: ['ok', 42] },
|
|
75
|
+
null,
|
|
76
|
+
{ question: 'No options', selections: [] },
|
|
77
|
+
]);
|
|
78
|
+
expect(parseSelectionGroups(raw)).toEqual([{ question: 'Q', selections: ['ok'] }]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns [] for malformed JSON, null, and non-array payloads', () => {
|
|
82
|
+
expect(parseSelectionGroups('not json')).toEqual([]);
|
|
83
|
+
expect(parseSelectionGroups(null)).toEqual([]);
|
|
84
|
+
expect(parseSelectionGroups('{"question":"q"}')).toEqual([]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('mapActionToItem — ask_user', () => {
|
|
89
|
+
it('maps to an ask_user item with parsed groups', () => {
|
|
90
|
+
const raw = JSON.stringify([
|
|
91
|
+
{ question: 'Which database?', selections: ['Postgres', 'SQLite'] },
|
|
92
|
+
]);
|
|
93
|
+
const mapped = mapActionToItem(askUserAction(raw));
|
|
94
|
+
expect(mapped).not.toBeNull();
|
|
95
|
+
expect(mapped!.type).toBe('ask_user');
|
|
96
|
+
expect(mapped!.status).toBe('completed');
|
|
97
|
+
expect(mapped!.data).toEqual({
|
|
98
|
+
question: '',
|
|
99
|
+
groups: [{ question: 'Which database?', selections: ['Postgres', 'SQLite'] }],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('falls back to a plain-text question card on malformed selections', () => {
|
|
104
|
+
const mapped = mapActionToItem(askUserAction('Plain question?'));
|
|
105
|
+
expect(mapped).not.toBeNull();
|
|
106
|
+
expect(mapped!.type).toBe('ask_user');
|
|
107
|
+
expect(mapped!.data).toEqual({ question: 'Plain question?', groups: [] });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('maps without selections to a question card with the raw payload as text', () => {
|
|
111
|
+
const mapped = mapActionToItem(askUserAction('Which one?\n["A","B"]'));
|
|
112
|
+
expect(mapped).not.toBeNull();
|
|
113
|
+
expect(mapped!.data).toEqual({ question: 'Which one?\n["A","B"]', groups: [] });
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('maps a failed ask_user action to a failed item', () => {
|
|
117
|
+
const raw = JSON.stringify([{ question: 'Q?', selections: ['a', 'b'] }]);
|
|
118
|
+
const action = { ...askUserAction(raw), status: 'EXECUTION_FAILED' };
|
|
119
|
+
const mapped = mapActionToItem(action);
|
|
120
|
+
expect(mapped!.status).toBe('failed');
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -9,6 +9,7 @@ import { v4 as uuidv4 } from 'uuid';
|
|
|
9
9
|
import type Database from 'better-sqlite3';
|
|
10
10
|
import { broadcast } from '../ws/index.ts';
|
|
11
11
|
import type { Item, ItemStatus } from '../db/schema.ts';
|
|
12
|
+
import type { AskUserSelectionGroup, AskUserItemData } from '@agentpane/shared';
|
|
12
13
|
import type { RepoburgAction } from './repoburg-client.ts';
|
|
13
14
|
|
|
14
15
|
/** Safely parse a JSON string field from an action row. */
|
|
@@ -22,6 +23,42 @@ export function parseJsonArgs(raw: string | null | undefined): Record<string, un
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
/** Parse a raw selections JSON string into validated groups (platform parity:
|
|
27
|
+
* ActionItemContent.parseSelectionGroups). Never throws; flat-string arrays
|
|
28
|
+
* coerce into single-option groups; invalid entries are dropped. */
|
|
29
|
+
export function parseSelectionGroups(raw: string | null | undefined): AskUserSelectionGroup[] {
|
|
30
|
+
if (!raw) return [];
|
|
31
|
+
let parsed: unknown;
|
|
32
|
+
try {
|
|
33
|
+
parsed = JSON.parse(raw);
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
if (!Array.isArray(parsed)) return [];
|
|
38
|
+
return parsed
|
|
39
|
+
.map((item): AskUserSelectionGroup | null => {
|
|
40
|
+
if (item && typeof item === 'object' && !Array.isArray(item)) {
|
|
41
|
+
const obj = item as Record<string, unknown>;
|
|
42
|
+
if (Array.isArray(obj.selections)) {
|
|
43
|
+
const opts = obj.selections.filter((s): s is string => typeof s === 'string');
|
|
44
|
+
if (opts.length > 0) {
|
|
45
|
+
return {
|
|
46
|
+
question: typeof obj.question === 'string' ? obj.question : '',
|
|
47
|
+
selections: opts,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
// Graceful coercion: flat string → single-option group.
|
|
54
|
+
if (typeof item === 'string') {
|
|
55
|
+
return { question: '', selections: [item] };
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
})
|
|
59
|
+
.filter((g): g is AskUserSelectionGroup => g !== null);
|
|
60
|
+
}
|
|
61
|
+
|
|
25
62
|
/** Map a repoburg AIAction (+ its first execution log) to an agent-pane item
|
|
26
63
|
* shape. Returns null for actions that carry no displayable content
|
|
27
64
|
* (final/html_final — their text is part of the LLM response). */
|
|
@@ -69,19 +106,26 @@ export function mapActionToItem(
|
|
|
69
106
|
};
|
|
70
107
|
|
|
71
108
|
case 'invoke_subagent': {
|
|
109
|
+
// background_task completed form — Claude-provider data shape. Serves
|
|
110
|
+
// snapshot reconcile + reload; the live projector finalizes the same
|
|
111
|
+
// card through the identical external id `<inputId>-action-<actionId>`.
|
|
72
112
|
const args = parseJsonArgs(action.arguments);
|
|
113
|
+
const agentName = (args?.agent_name as string) ?? (args?.agentName as string) ?? 'Sub-agent';
|
|
114
|
+
const prompt = (args?.prompt as string) ?? '';
|
|
73
115
|
return {
|
|
74
|
-
type: '
|
|
116
|
+
type: 'background_task',
|
|
75
117
|
data: {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
118
|
+
task_id: (args?.childSessionId as string) ?? '',
|
|
119
|
+
description: prompt || agentName,
|
|
120
|
+
task_type: 'subagent',
|
|
121
|
+
subagent_type: agentName,
|
|
122
|
+
prompt: prompt || null,
|
|
123
|
+
status: failed ? 'failed' : 'completed',
|
|
124
|
+
summary: output || (failed ? errorMsg || 'Sub-agent failed' : ''),
|
|
125
|
+
output_file: null,
|
|
126
|
+
output: '',
|
|
127
|
+
token_count: null,
|
|
128
|
+
last_tool_name: null,
|
|
85
129
|
},
|
|
86
130
|
status,
|
|
87
131
|
};
|
|
@@ -92,9 +136,23 @@ export function mapActionToItem(
|
|
|
92
136
|
return { type: 'web_search', data: { query: (args?.query as string) ?? '' }, status };
|
|
93
137
|
}
|
|
94
138
|
|
|
139
|
+
case 'ask_user': {
|
|
140
|
+
// Rendered as clickable selection buttons in the frontend.
|
|
141
|
+
// Malformed payload → fallback plain-text question card.
|
|
142
|
+
const groups = parseSelectionGroups(action.selections);
|
|
143
|
+
const data: AskUserItemData = {
|
|
144
|
+
question: groups.length ? '' : (action.selections ?? ''),
|
|
145
|
+
groups,
|
|
146
|
+
};
|
|
147
|
+
return {
|
|
148
|
+
type: 'ask_user',
|
|
149
|
+
data: { question: data.question, groups: data.groups },
|
|
150
|
+
status,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
95
154
|
case 'final':
|
|
96
155
|
case 'html_final':
|
|
97
|
-
case 'ask_user':
|
|
98
156
|
// Their text is part of the LLM response — no separate item.
|
|
99
157
|
return null;
|
|
100
158
|
|
|
@@ -138,6 +196,26 @@ export function mapUsage(u: {
|
|
|
138
196
|
};
|
|
139
197
|
}
|
|
140
198
|
|
|
199
|
+
/** Update an item's data + status and broadcast the updated item. */
|
|
200
|
+
export function updateItem(
|
|
201
|
+
db: Database.Database,
|
|
202
|
+
sessionId: string,
|
|
203
|
+
turnId: string,
|
|
204
|
+
item: Item,
|
|
205
|
+
data: Record<string, unknown>,
|
|
206
|
+
status: ItemStatus,
|
|
207
|
+
): void {
|
|
208
|
+
const now = new Date().toISOString();
|
|
209
|
+
db.prepare('UPDATE items SET data = ?, status = ?, updated_at = ? WHERE id = ?').run(
|
|
210
|
+
JSON.stringify(data),
|
|
211
|
+
status,
|
|
212
|
+
now,
|
|
213
|
+
item.id,
|
|
214
|
+
);
|
|
215
|
+
const updated: Item = { ...item, data, status, updated_at: now };
|
|
216
|
+
broadcast('item:updated', { sessionId, turnId, item: updated });
|
|
217
|
+
}
|
|
218
|
+
|
|
141
219
|
/** Update the running state and status of a session. */
|
|
142
220
|
export function setSessionStatus(
|
|
143
221
|
db: Database.Database,
|
|
@@ -30,6 +30,8 @@ export interface AgentRuntimeActionInfo {
|
|
|
30
30
|
tool_name: string | null;
|
|
31
31
|
arguments: string | null;
|
|
32
32
|
plain: string | null;
|
|
33
|
+
/** Raw selections JSON string (ask_user/final) — parsed by the client. */
|
|
34
|
+
selections: string | null;
|
|
33
35
|
order_of_execution: number;
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -80,6 +82,26 @@ export interface UserMessage {
|
|
|
80
82
|
content: AgentRuntimeToolResultBlock[];
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/** Sub-agent lifecycle event on the PARENT session channel (Claude task
|
|
86
|
+
* parity) — compact; child content never crosses the wire. */
|
|
87
|
+
export interface SubagentMessage {
|
|
88
|
+
type: 'subagent';
|
|
89
|
+
/** PARENT session id (routing). */
|
|
90
|
+
sessionId: string;
|
|
91
|
+
event: 'started' | 'progress' | 'completed' | 'failed';
|
|
92
|
+
/** Child session id — the task identity. */
|
|
93
|
+
task_id: string;
|
|
94
|
+
agent_name: string;
|
|
95
|
+
/** Task prompt on `started`; the child's latest action on `progress`. */
|
|
96
|
+
description: string;
|
|
97
|
+
/** Parent input + invoke_subagent action — the card key. */
|
|
98
|
+
input_id: string;
|
|
99
|
+
action_id: string;
|
|
100
|
+
last_tool_name?: string | null;
|
|
101
|
+
token_count?: number;
|
|
102
|
+
summary?: string;
|
|
103
|
+
usage?: AgentRuntimeUsage;
|
|
104
|
+
}
|
|
83
105
|
export interface ResultMessage {
|
|
84
106
|
type: 'result';
|
|
85
107
|
subtype: 'success' | 'error_during_execution' | 'aborted';
|
|
@@ -96,6 +118,7 @@ export type AgentRuntimeMessage =
|
|
|
96
118
|
| StreamEventMessage
|
|
97
119
|
| AssistantMessage
|
|
98
120
|
| UserMessage
|
|
121
|
+
| SubagentMessage
|
|
99
122
|
| ResultMessage;
|
|
100
123
|
|
|
101
124
|
// ---------------------------------------------------------------------------
|
|
@@ -268,4 +268,89 @@ describe('backend-resolver', () => {
|
|
|
268
268
|
await vi.advanceTimersByTimeAsync(31_000);
|
|
269
269
|
await assertion;
|
|
270
270
|
});
|
|
271
|
+
it('accepts a healthy running backend whose health projectPath matches the workspace', async () => {
|
|
272
|
+
const abs = path.resolve('/tmp/proj');
|
|
273
|
+
mockFetchRoutes([
|
|
274
|
+
{ match: (u) => u === `${DAEMON}/health`, respond: () => jsonResponse({ status: 'ok' }) },
|
|
275
|
+
{
|
|
276
|
+
match: (u) => u === `${DAEMON}/services`,
|
|
277
|
+
respond: () => jsonResponse([service({ path: abs, port: 3402, name: 'proj' })]),
|
|
278
|
+
},
|
|
279
|
+
{ match: (u) => u === 'http://localhost:3402/health', respond: () => jsonResponse({ status: 'ok', projectPath: abs }) },
|
|
280
|
+
]);
|
|
281
|
+
|
|
282
|
+
const resolved = await resolveBackendUrl('/tmp/proj');
|
|
283
|
+
expect(resolved?.baseUrl).toBe('http://localhost:3402');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('treats a projectPath mismatch as unhealthy (autoStart=false → null, no restart)', async () => {
|
|
287
|
+
const abs = path.resolve('/tmp/proj');
|
|
288
|
+
const fetchMock = mockFetchRoutes([
|
|
289
|
+
{ match: (u) => u === `${DAEMON}/health`, respond: () => jsonResponse({ status: 'ok' }) },
|
|
290
|
+
{
|
|
291
|
+
match: (u) => u === `${DAEMON}/services`,
|
|
292
|
+
respond: () => jsonResponse([service({ path: abs, port: 3402, name: 'proj' })]),
|
|
293
|
+
},
|
|
294
|
+
{ match: (u) => u === 'http://localhost:3402/health', respond: () => jsonResponse({ status: 'ok', projectPath: '/tmp/other-workspace' }) },
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
expect(await resolveBackendUrl('/tmp/proj', { autoStart: false })).toBeNull();
|
|
298
|
+
expect(fetchMock.mock.calls.some(([u]) => String(u).includes('restart'))).toBe(false);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('restarts a running backend that serves a different workspace, then resolves it', async () => {
|
|
302
|
+
const abs = path.resolve('/tmp/proj');
|
|
303
|
+
let servingWrongWorkspace = true;
|
|
304
|
+
const restartMock = vi.fn();
|
|
305
|
+
mockFetchRoutes([
|
|
306
|
+
{ match: (u) => u === `${DAEMON}/health`, respond: () => jsonResponse({ status: 'ok' }) },
|
|
307
|
+
{
|
|
308
|
+
match: (u) => u === `${DAEMON}/services`,
|
|
309
|
+
respond: () => jsonResponse([service({ path: abs, port: 3402, name: 'proj' })]),
|
|
310
|
+
},
|
|
311
|
+
{
|
|
312
|
+
match: (u) => u === 'http://localhost:3402/health',
|
|
313
|
+
respond: () =>
|
|
314
|
+
servingWrongWorkspace
|
|
315
|
+
? jsonResponse({ status: 'ok', projectPath: '/tmp/other-workspace' })
|
|
316
|
+
: jsonResponse({ status: 'ok', projectPath: abs }),
|
|
317
|
+
},
|
|
318
|
+
{
|
|
319
|
+
match: (u, m) => u === `${DAEMON}/services/proj/restart` && m === 'POST',
|
|
320
|
+
respond: () => {
|
|
321
|
+
restartMock();
|
|
322
|
+
servingWrongWorkspace = false;
|
|
323
|
+
return jsonResponse(service({ path: abs, port: 3402, name: 'proj' }));
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
]);
|
|
327
|
+
|
|
328
|
+
const resolved = await resolveBackendUrl('/tmp/proj');
|
|
329
|
+
expect(restartMock).toHaveBeenCalledTimes(1);
|
|
330
|
+
expect(resolved?.baseUrl).toBe('http://localhost:3402');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it('re-consults the daemon when the cached backend starts serving a different workspace', async () => {
|
|
334
|
+
const abs = path.resolve('/tmp/proj');
|
|
335
|
+
let projectPath = abs;
|
|
336
|
+
const fetchMock = mockFetchRoutes([
|
|
337
|
+
{ match: (u) => u === `${DAEMON}/health`, respond: () => jsonResponse({ status: 'ok' }) },
|
|
338
|
+
{
|
|
339
|
+
match: (u) => u === `${DAEMON}/services`,
|
|
340
|
+
respond: () => jsonResponse([service({ path: abs, port: 3402, name: 'proj' })]),
|
|
341
|
+
},
|
|
342
|
+
{ match: (u) => u === 'http://localhost:3402/health', respond: () => jsonResponse({ status: 'ok', projectPath }) },
|
|
343
|
+
]);
|
|
344
|
+
|
|
345
|
+
// First resolve caches the backend (projectPath matches).
|
|
346
|
+
await resolveBackendUrl('/tmp/proj');
|
|
347
|
+
|
|
348
|
+
// The row got mis-mapped — the backend now serves another workspace.
|
|
349
|
+
projectPath = '/tmp/other-workspace';
|
|
350
|
+
fetchMock.mockClear();
|
|
351
|
+
await resolveBackendUrl('/tmp/proj', { autoStart: false });
|
|
352
|
+
// The cache was rejected and the daemon re-consulted (health probe rejected).
|
|
353
|
+
expect(fetchMock.mock.calls.some(([u]) => String(u) === 'http://localhost:3402/health')).toBe(true);
|
|
354
|
+
expect(fetchMock.mock.calls.some(([u]) => String(u) === `${DAEMON}/services`)).toBe(true);
|
|
355
|
+
});
|
|
271
356
|
});
|
|
@@ -32,6 +32,12 @@ export interface DaemonService {
|
|
|
32
32
|
pid: number | null;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/** Root backend /health payload (projectPath is additive — absent on pre-v2 builds). */
|
|
36
|
+
interface HealthPayload {
|
|
37
|
+
status: string;
|
|
38
|
+
projectPath?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
/** A resolved backend instance to connect to. */
|
|
36
42
|
export interface ResolvedBackend {
|
|
37
43
|
baseUrl: string;
|
|
@@ -124,21 +130,29 @@ export function invalidateBackend(baseUrl: string): void {
|
|
|
124
130
|
}
|
|
125
131
|
}
|
|
126
132
|
|
|
127
|
-
/**
|
|
128
|
-
|
|
133
|
+
/**
|
|
134
|
+
* Probe a backend's /health endpoint (short timeout). With expectedPath, a
|
|
135
|
+
* projectPath mismatch means unhealthy: the backend serves a different
|
|
136
|
+
* workspace than the daemon row claims (mis-mapped service).
|
|
137
|
+
*/
|
|
138
|
+
async function backendHealthy(baseUrl: string, expectedPath?: string): Promise<boolean> {
|
|
129
139
|
try {
|
|
130
|
-
await fetchJson(`${baseUrl}/health`, undefined, 2000);
|
|
140
|
+
const health = await fetchJson<HealthPayload>(`${baseUrl}/health`, undefined, 2000);
|
|
141
|
+
if (health.status !== 'ok') return false;
|
|
142
|
+
if (expectedPath && health.projectPath) {
|
|
143
|
+
return path.resolve(health.projectPath) === path.resolve(expectedPath);
|
|
144
|
+
}
|
|
131
145
|
return true;
|
|
132
146
|
} catch {
|
|
133
147
|
return false;
|
|
134
148
|
}
|
|
135
149
|
}
|
|
136
150
|
|
|
137
|
-
/** Wait until the backend on baseUrl reports healthy. */
|
|
138
|
-
async function waitForHealthy(baseUrl: string, name: string): Promise<void> {
|
|
151
|
+
/** Wait until the backend on baseUrl reports healthy (for expectedPath). */
|
|
152
|
+
async function waitForHealthy(baseUrl: string, name: string, expectedPath?: string): Promise<void> {
|
|
139
153
|
const deadline = Date.now() + HEALTH_TIMEOUT_MS;
|
|
140
154
|
while (Date.now() < deadline) {
|
|
141
|
-
if (await backendHealthy(baseUrl)) return;
|
|
155
|
+
if (await backendHealthy(baseUrl, expectedPath)) return;
|
|
142
156
|
await new Promise((resolve) => setTimeout(resolve, HEALTH_POLL_INTERVAL_MS));
|
|
143
157
|
}
|
|
144
158
|
throw new Error(
|
|
@@ -202,9 +216,9 @@ export async function resolveBackendUrl(
|
|
|
202
216
|
const absPath = path.resolve(projectPath);
|
|
203
217
|
const daemon = daemonBaseUrl();
|
|
204
218
|
|
|
205
|
-
// 2. Cached resolution, re-verified healthy.
|
|
219
|
+
// 2. Cached resolution, re-verified healthy (and serving this workspace).
|
|
206
220
|
const cached = resolvedCache.get(absPath);
|
|
207
|
-
if (cached && (await backendHealthy(cached.baseUrl))) {
|
|
221
|
+
if (cached && (await backendHealthy(cached.baseUrl, absPath))) {
|
|
208
222
|
return cached;
|
|
209
223
|
}
|
|
210
224
|
|
|
@@ -221,13 +235,14 @@ export async function resolveBackendUrl(
|
|
|
221
235
|
const match = services.find((s) => s.path === absPath);
|
|
222
236
|
|
|
223
237
|
if (match && match.status === 'running') {
|
|
224
|
-
if (await backendHealthy(serviceBaseUrl(match))) {
|
|
238
|
+
if (await backendHealthy(serviceBaseUrl(match), absPath)) {
|
|
225
239
|
return cache(absPath, match);
|
|
226
240
|
}
|
|
227
241
|
if (!autoStart) return null;
|
|
228
|
-
//
|
|
242
|
+
// Unhealthy or serving a different workspace — the process is wrong
|
|
243
|
+
// for this path. Restart via the daemon.
|
|
229
244
|
await restartService(daemon, match.name);
|
|
230
|
-
await waitForHealthy(serviceBaseUrl(match), match.name);
|
|
245
|
+
await waitForHealthy(serviceBaseUrl(match), match.name, absPath);
|
|
231
246
|
return cache(absPath, match);
|
|
232
247
|
}
|
|
233
248
|
|
|
@@ -235,12 +250,12 @@ export async function resolveBackendUrl(
|
|
|
235
250
|
|
|
236
251
|
if (match) {
|
|
237
252
|
await restartService(daemon, match.name);
|
|
238
|
-
await waitForHealthy(serviceBaseUrl(match), match.name);
|
|
253
|
+
await waitForHealthy(serviceBaseUrl(match), match.name, absPath);
|
|
239
254
|
return cache(absPath, match);
|
|
240
255
|
}
|
|
241
256
|
|
|
242
257
|
// No service for this path yet — start one.
|
|
243
258
|
const service = await startService(daemon, absPath);
|
|
244
|
-
await waitForHealthy(serviceBaseUrl(service), service.name);
|
|
259
|
+
await waitForHealthy(serviceBaseUrl(service), service.name, absPath);
|
|
245
260
|
return cache(absPath, service);
|
|
246
261
|
}
|
|
@@ -130,6 +130,27 @@ describe('RepoburgHttpClient (HTTP control surface)', () => {
|
|
|
130
130
|
|
|
131
131
|
expect(models).toEqual([]);
|
|
132
132
|
});
|
|
133
|
+
it('deleteInputsUntilHere DELETEs the anchor until-here endpoint (204 empty body)', async () => {
|
|
134
|
+
// The root endpoint replies 204 No Content — must resolve, not throw.
|
|
135
|
+
fetchMock.mockResolvedValue({ ok: true, status: 204, text: () => Promise.resolve('') } as never);
|
|
136
|
+
|
|
137
|
+
await client.deleteInputsUntilHere('rs-1', 'in-9');
|
|
138
|
+
|
|
139
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
140
|
+
'http://repoburg.test/sessions/rs-1/inputs/in-9/until-here',
|
|
141
|
+
expect.objectContaining({ method: 'DELETE', body: undefined }),
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('deleteInputsUntilHere surfaces the 400 summary-in-range error', async () => {
|
|
146
|
+
fetchMock.mockResolvedValue(
|
|
147
|
+
error(400, { message: 'Summaries can only be discarded, not deleted.' }),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await expect(client.deleteInputsUntilHere('rs-1', 'in-9')).rejects.toThrow(
|
|
151
|
+
'DELETE /sessions/rs-1/inputs/in-9/until-here failed: 400 {"message":"Summaries can only be discarded, not deleted."}',
|
|
152
|
+
);
|
|
153
|
+
});
|
|
133
154
|
|
|
134
155
|
it('throws with status + body text on non-OK responses', async () => {
|
|
135
156
|
fetchMock.mockResolvedValue(error(400, 'Bad request'));
|
|
@@ -60,6 +60,8 @@ export interface RepoburgAction {
|
|
|
60
60
|
tool_name?: string | null;
|
|
61
61
|
arguments?: string | null;
|
|
62
62
|
plain?: string | null;
|
|
63
|
+
/** Raw selections JSON string (ask_user/final) — parsed by the client. */
|
|
64
|
+
selections?: string | null;
|
|
63
65
|
reason?: string | null;
|
|
64
66
|
order_of_execution?: number;
|
|
65
67
|
executionLogs?: RepoburgExecutionLog[];
|
|
@@ -88,6 +90,8 @@ export interface RepoburgRuntimeClient {
|
|
|
88
90
|
getInputWithActions(sessionId: string, inputId: string): Promise<RepoburgSessionInput>;
|
|
89
91
|
/** Full snapshot: all inputs of the session with actions + logs (ASC). */
|
|
90
92
|
getSessionInputs(sessionId: string): Promise<RepoburgSessionInput[]>;
|
|
93
|
+
/** Hard-delete every input AFTER the anchor (platform rewind parity). */
|
|
94
|
+
deleteInputsUntilHere(sessionId: string, anchorInputId: string): Promise<void>;
|
|
91
95
|
cancel(sessionId: string): Promise<{ success: boolean; cancelled: boolean }>;
|
|
92
96
|
getModels(): Promise<RepoburgModel[]>;
|
|
93
97
|
}
|
|
@@ -131,6 +135,8 @@ export class RepoburgHttpClient implements RepoburgRuntimeClient {
|
|
|
131
135
|
}
|
|
132
136
|
throw new Error(`${method} ${path} failed: ${response.status} ${text}`);
|
|
133
137
|
}
|
|
138
|
+
// Success with no payload — e.g. 204 No Content from the until-here delete.
|
|
139
|
+
if (text.length === 0) return undefined as unknown as T;
|
|
134
140
|
try {
|
|
135
141
|
return JSON.parse(text) as T;
|
|
136
142
|
} catch {
|
|
@@ -167,6 +173,13 @@ export class RepoburgHttpClient implements RepoburgRuntimeClient {
|
|
|
167
173
|
return this.request<RepoburgSessionInput[]>('GET', `/sessions/${sessionId}/inputs`);
|
|
168
174
|
}
|
|
169
175
|
|
|
176
|
+
async deleteInputsUntilHere(sessionId: string, anchorInputId: string): Promise<void> {
|
|
177
|
+
await this.request<unknown>(
|
|
178
|
+
'DELETE',
|
|
179
|
+
`/sessions/${sessionId}/inputs/${anchorInputId}/until-here`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
170
183
|
async cancel(sessionId: string): Promise<{ success: boolean; cancelled: boolean }> {
|
|
171
184
|
return this.request<{ success: boolean; cancelled: boolean }>(
|
|
172
185
|
'POST',
|