repoburg 1.3.199 → 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.
Files changed (136) hide show
  1. package/agent-pane/backend/data.db +0 -0
  2. package/agent-pane/backend/data.db-shm +0 -0
  3. package/agent-pane/backend/data.db-wal +0 -0
  4. package/agent-pane/backend/dist/index.js +1658 -277
  5. package/agent-pane/backend/src/claude/claude-provider.test.ts +393 -3
  6. package/agent-pane/backend/src/claude/claude-provider.ts +53 -50
  7. package/agent-pane/backend/src/db/index.ts +12 -0
  8. package/agent-pane/backend/src/index.ts +5 -2
  9. package/agent-pane/backend/src/integration/threads-claude-fork.test.ts +241 -0
  10. package/agent-pane/backend/src/providers/index.ts +3 -1
  11. package/agent-pane/backend/src/providers/runtime-provider.ts +6 -0
  12. package/agent-pane/backend/src/repoburg/action-mapping.test.ts +122 -0
  13. package/agent-pane/backend/src/repoburg/action-mapping.ts +294 -0
  14. package/agent-pane/backend/src/repoburg/agent-runtime-client.test.ts +271 -0
  15. package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +285 -0
  16. package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +356 -0
  17. package/agent-pane/backend/src/repoburg/backend-resolver.ts +261 -0
  18. package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +180 -0
  19. package/agent-pane/backend/src/repoburg/repoburg-client.ts +194 -0
  20. package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +780 -0
  21. package/agent-pane/backend/src/repoburg/repoburg-provider.ts +471 -0
  22. package/agent-pane/backend/src/repoburg/turn-projector.test.ts +621 -0
  23. package/agent-pane/backend/src/repoburg/turn-projector.ts +637 -0
  24. package/agent-pane/backend/src/routes/providers.test.ts +18 -1
  25. package/agent-pane/backend/src/routes/providers.ts +19 -1
  26. package/agent-pane/backend/src/routes/sessions.test.ts +215 -1
  27. package/agent-pane/backend/src/routes/sessions.ts +82 -9
  28. package/agent-pane/backend/src/routes/threads.ts +21 -6
  29. package/agent-pane/backend/src/routes/turns.test.ts +45 -1
  30. package/agent-pane/frontend/dist/assets/index-CJxD4YmJ.js +370 -0
  31. package/agent-pane/frontend/dist/assets/{index-Dwwhx7YN.css → index-r-xwfOKQ.css} +1 -1
  32. package/agent-pane/frontend/dist/index.html +2 -2
  33. package/agent-pane/frontend/src/api/sessions.test.ts +1 -0
  34. package/agent-pane/frontend/src/api/websocket.test.ts +2 -2
  35. package/agent-pane/frontend/src/api/websocket.ts +3 -1
  36. package/agent-pane/frontend/src/components/AskUserItem.test.tsx +143 -0
  37. package/agent-pane/frontend/src/components/AskUserItem.tsx +207 -0
  38. package/agent-pane/frontend/src/components/CommentableText.test.tsx +196 -0
  39. package/agent-pane/frontend/src/components/CommentableText.tsx +109 -21
  40. package/agent-pane/frontend/src/components/IdeWorkspace.test.tsx +39 -0
  41. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +28 -2
  42. package/agent-pane/frontend/src/components/ResizeHandle.tsx +29 -0
  43. package/agent-pane/frontend/src/components/TurnContent.test.tsx +79 -0
  44. package/agent-pane/frontend/src/components/TurnContent.tsx +49 -3
  45. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +35 -3
  46. package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +48 -0
  47. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +7 -2
  48. package/agent-pane/frontend/src/constants/models.ts +37 -0
  49. package/agent-pane/frontend/src/hooks/useResizable.test.ts +97 -0
  50. package/agent-pane/frontend/src/hooks/useResizable.ts +75 -0
  51. package/agent-pane/frontend/src/store/store.test.ts +54 -0
  52. package/agent-pane/frontend/src/store/uiSlice.ts +50 -0
  53. package/agent-pane/frontend/src/store/wsSlice.ts +10 -3
  54. package/agent-pane/frontend/src/styles/app.css +26 -1
  55. package/agent-pane/frontend/src/test/component-helpers.ts +2 -0
  56. package/agent-pane/frontend/src/test/fixtures.ts +1 -0
  57. package/agent-pane/frontend/src/types/index.ts +2 -0
  58. package/agent-pane/frontend/src/utils/duration.test.ts +1 -0
  59. package/agent-pane/shared/types.ts +17 -0
  60. package/agent-pane-bug-repro-no-agent-reply.png +0 -0
  61. package/askuser-after-step5-postgres-selected.png +0 -0
  62. package/askuser-after-step7-answer-sent.png +0 -0
  63. package/askuser-card-visible.png +0 -0
  64. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +36 -0
  65. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +199 -0
  66. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js.map +1 -0
  67. package/backend/dist/src/agent-runtime/agent-runtime.controller.d.ts +11 -0
  68. package/backend/dist/src/agent-runtime/agent-runtime.controller.js +91 -0
  69. package/backend/dist/src/agent-runtime/agent-runtime.controller.js.map +1 -0
  70. package/backend/dist/src/agent-runtime/agent-runtime.module.d.ts +2 -0
  71. package/backend/dist/src/agent-runtime/agent-runtime.module.js +26 -0
  72. package/backend/dist/src/agent-runtime/agent-runtime.module.js.map +1 -0
  73. package/backend/dist/src/agent-runtime/agent-runtime.types.d.ts +92 -0
  74. package/backend/dist/src/agent-runtime/agent-runtime.types.js +3 -0
  75. package/backend/dist/src/agent-runtime/agent-runtime.types.js.map +1 -0
  76. package/backend/dist/src/app.controller.d.ts +1 -0
  77. package/backend/dist/src/app.module.js +4 -0
  78. package/backend/dist/src/app.module.js.map +1 -1
  79. package/backend/dist/src/app.service.d.ts +1 -0
  80. package/backend/dist/src/app.service.js +4 -1
  81. package/backend/dist/src/app.service.js.map +1 -1
  82. package/backend/dist/src/interactive-chat/chat.module.js +4 -0
  83. package/backend/dist/src/interactive-chat/chat.module.js.map +1 -1
  84. package/backend/dist/src/interactive-chat/chat.service.d.ts +7 -1
  85. package/backend/dist/src/interactive-chat/chat.service.js +54 -7
  86. package/backend/dist/src/interactive-chat/chat.service.js.map +1 -1
  87. package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js +12 -1
  88. package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js.map +1 -1
  89. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.d.ts +3 -1
  90. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js +20 -2
  91. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js.map +1 -1
  92. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.d.ts +1 -0
  93. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js +1 -0
  94. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js.map +1 -1
  95. package/backend/dist/src/llm-orchestration/llm-orchestration.module.js +2 -0
  96. package/backend/dist/src/llm-orchestration/llm-orchestration.module.js.map +1 -1
  97. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.d.ts +4 -1
  98. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js +37 -2
  99. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js.map +1 -1
  100. package/backend/dist/src/llm-provider/ollama-llm.provider.d.ts +1 -0
  101. package/backend/dist/src/llm-provider/ollama-llm.provider.js +19 -1
  102. package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
  103. package/backend/dist/src/llm-responses/llm-responses.module.js +4 -0
  104. package/backend/dist/src/llm-responses/llm-responses.module.js.map +1 -1
  105. package/backend/dist/src/llm-responses/llm-responses.service.d.ts +4 -1
  106. package/backend/dist/src/llm-responses/llm-responses.service.js +52 -2
  107. package/backend/dist/src/llm-responses/llm-responses.service.js.map +1 -1
  108. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  109. package/daemon/dist/api/services.js +4 -0
  110. package/daemon/dist/index.js +0 -0
  111. package/daemon/dist/serviceManager.js +30 -11
  112. package/daemon/dist/serviceManager.spec.js +26 -0
  113. package/daemon/dist/workspace-validation.js +63 -0
  114. package/daemon/dist/workspace-validation.spec.js +63 -0
  115. package/package.json +1 -1
  116. package/r2-step4-apple-banana.png +0 -0
  117. package/r2-step5-after-rewind-apple-last.png +0 -0
  118. package/r2-step7-apple-cherry.png +0 -0
  119. package/r2-step8-cherry-menu-no-restore.png +0 -0
  120. package/r3-step3-live-task-card-running.png +0 -0
  121. package/r3-step4-after-completion.png +0 -0
  122. package/r3-step5-persistence-after-reload.png +0 -0
  123. package/r3-step6-tasks-tab-card.png +0 -0
  124. package/reverify-answered-state-answer-sent.png +0 -0
  125. package/reverify-card-visible-after-completion.png +0 -0
  126. package/rewind-step3-apple-banana-turns.png +0 -0
  127. package/rewind-step5-ui-unchanged-after-confirmed-rewind.png +0 -0
  128. package/rewind-step6-apple-menu-no-restore.png +0 -0
  129. package/rewind-step7-final-turnlist-unchanged.png +0 -0
  130. package/rewind-step7-negative-error-toast.png +0 -0
  131. package/rewind-step7-negative-error-toast2.png +0 -0
  132. package/subagent-r2-step5-turn-after-reload-no-card.png +0 -0
  133. package/subagent-r2-step6-tasks-panel-card.png +0 -0
  134. package/subagent-step3-tasks-tab-done-card.png +0 -0
  135. package/subagent-step4-turn-stuck-no-cards.png +0 -0
  136. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +0 -362
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Integration test: discussion threads — Claude fork isolation (BUG 1 + BUG 2).
3
+ *
4
+ * Exercises the REAL claudeProvider + REAL thread routes with a mocked Claude
5
+ * SDK (`query` + `forkSession`). Verifies that creating a Discuss thread forks
6
+ * the main session, stores the fork id on the thread row, and never corrupts
7
+ * the main session's `external_thread_id`; and that a failed fork starts a
8
+ * fresh isolated session instead of resuming (and contaminating) the main
9
+ * session.
10
+ */
11
+
12
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
13
+ import type Database from 'better-sqlite3';
14
+ import { clearBroadcasts, capturedBroadcasts } from './helpers/ws-capture.ts';
15
+ import type { TestApp } from './helpers/test-app.ts';
16
+
17
+ // Mock the Claude SDK — both the query factory and forkSession.
18
+ vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
19
+ query: vi.fn(),
20
+ forkSession: vi.fn(),
21
+ }));
22
+
23
+ vi.mock('../ws/index.ts', async (importOriginal) => {
24
+ const actual = (await importOriginal()) as Record<string, unknown>;
25
+ return {
26
+ ...actual,
27
+ broadcast: vi.fn((event: string, data: unknown) => {
28
+ capturedBroadcasts.push({ event, data });
29
+ }),
30
+ };
31
+ });
32
+
33
+ // Import after mocks are set up.
34
+ import { query, forkSession } from '@anthropic-ai/claude-agent-sdk';
35
+ import { clearAllThreads } from '../claude/claude-provider.ts';
36
+ const { getDb } = await import('../db/index.ts');
37
+ const { createTestApp, insertTestSession, waitForTurn } = await import('./helpers/test-app.ts');
38
+
39
+ // ---------------------------------------------------------------------------
40
+ // Mock helpers
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** Build a mock `Query` whose async iterator yields the given SDK messages in
44
+ * order, then ends. getContextUsage resolves an empty snapshot (0/0) so the
45
+ * result/success handler completes the turn without emitting contextUsage. */
46
+ function createClaudeQueryMock(messages: unknown[]): unknown {
47
+ return {
48
+ getContextUsage: vi.fn().mockResolvedValue({
49
+ totalTokens: 0, maxTokens: 0, rawMaxTokens: 0, percentage: 0,
50
+ categories: [], gridRows: [], model: '', memoryFiles: [], mcpTools: [],
51
+ }),
52
+ interrupt: vi.fn(),
53
+ setModel: vi.fn(),
54
+ close: vi.fn(),
55
+ stopTask: vi.fn(),
56
+ initializationResult: vi.fn(),
57
+ [Symbol.asyncIterator]: async function* () {
58
+ for (const m of messages) yield m;
59
+ },
60
+ };
61
+ }
62
+
63
+ /** A `system/init` SDK message carrying the given Claude session id. */
64
+ function initMsg(claudeSessionId: string): unknown {
65
+ return { type: 'system', subtype: 'init', session_id: claudeSessionId };
66
+ }
67
+
68
+ /** A `result/success` SDK message that completes a turn. */
69
+ function resultSuccessMsg(claudeSessionId: string): unknown {
70
+ return {
71
+ type: 'result', subtype: 'success',
72
+ usage: { input_tokens: 10, cache_read_input_tokens: 0, cache_creation_input_tokens: 0, output_tokens: 5 },
73
+ modelUsage: {}, result: 'done', is_error: false, num_turns: 1,
74
+ stop_reason: 'end_turn', duration_ms: 1, duration_api_ms: 1, total_cost_usd: 0,
75
+ permission_denials: [], uuid: 'u1', session_id: claudeSessionId,
76
+ };
77
+ }
78
+
79
+ describe('Integration: discussion threads — Claude fork isolation', () => {
80
+ let testApp: TestApp;
81
+
82
+ beforeEach(async () => {
83
+ vi.clearAllMocks();
84
+ clearAllThreads();
85
+ clearBroadcasts();
86
+ testApp = await createTestApp();
87
+ });
88
+
89
+ afterEach(async () => {
90
+ clearAllThreads();
91
+ await testApp.cleanup();
92
+ });
93
+
94
+ /** Seed a Claude session + anchor turn/item, ready for a thread creation. */
95
+ function seedSession(db: Database.Database): void {
96
+ insertTestSession(db, 's1', { runtime_type: 'claude', external_thread_id: 'main-1', title: 'Test' });
97
+ const now = '2025-01-01T00:00:00Z';
98
+ db.prepare(
99
+ `INSERT INTO turns (id, session_id, user_prompt, agent_response, status, external_turn_id, created_at, updated_at)
100
+ VALUES ('t1', 's1', 'prompt', 'response', 'completed', 'msg-uuid-1', ?, ?)`,
101
+ ).run(now, now);
102
+ db.prepare(
103
+ `INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at)
104
+ VALUES ('i1', 't1', 'ext1', 'agent_message', ?, 'completed', ?, ?)`,
105
+ ).run(JSON.stringify({ text: 'Some text to discuss' }), now, now);
106
+ }
107
+
108
+ /** The shared thread-creation POST payload. */
109
+ const threadPayload = {
110
+ itemId: 'i1',
111
+ turnId: 't1',
112
+ startOffset: 0,
113
+ endOffset: 10,
114
+ selectedText: 'Some text ',
115
+ message: 'What does this mean?',
116
+ };
117
+
118
+ it('successful fork: stores fork id, resumes the fork, does NOT corrupt the main session (BUG 1 + BUG 2)', async () => {
119
+ const db: Database.Database = getDb();
120
+ seedSession(db);
121
+
122
+ // forkSession returns a new forked session id.
123
+ vi.mocked(forkSession).mockResolvedValue({ sessionId: 'fork-9' } as never);
124
+ // The forked query emits init with the FORK's id, then completes the turn.
125
+ vi.mocked(query).mockReturnValue(createClaudeQueryMock([initMsg('fork-9'), resultSuccessMsg('fork-9')]) as never);
126
+
127
+ const res = await testApp.app.inject({
128
+ method: 'POST',
129
+ url: '/api/sessions/s1/threads',
130
+ payload: threadPayload,
131
+ });
132
+
133
+ expect(res.statusCode).toBe(200);
134
+ const body = res.json();
135
+ const threadId = body.thread.id as string;
136
+ const turnId = body.turn.id as string;
137
+
138
+ // forkSession was called with the MAIN session id + the anchor turn's message UUID.
139
+ expect(vi.mocked(forkSession).mock.calls[0][0]).toBe('main-1');
140
+ expect((vi.mocked(forkSession).mock.calls[0][1] as { upToMessageId?: string }).upToMessageId).toBe('msg-uuid-1');
141
+
142
+ // The fork id is stored on the THREAD row, not the main session.
143
+ const threadRow = db.prepare('SELECT external_thread_id FROM discussion_threads WHERE id = ?').get(threadId) as
144
+ | { external_thread_id: string | null }
145
+ | undefined;
146
+ expect(threadRow?.external_thread_id).toBe('fork-9');
147
+
148
+ // Wait for the thread turn to complete, then assert the main session is intact.
149
+ await waitForTurn(db, turnId);
150
+
151
+ // BUG 2: the thread turn resumed its OWN fork ('fork-9'), not the main session.
152
+ const opts = (vi.mocked(query).mock.calls[0][0] as { options: { resume?: string } }).options;
153
+ expect(opts.resume).toBe('fork-9');
154
+
155
+ // BUG 1: the main session's external_thread_id was NOT overwritten with the fork's id.
156
+ const sessionRow = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
157
+ | { external_thread_id: string | null }
158
+ | undefined;
159
+ expect(sessionRow?.external_thread_id).toBe('main-1');
160
+
161
+ // thread:created was broadcast.
162
+ expect(capturedBroadcasts.find((b) => b.event === 'thread:created')).toBeDefined();
163
+ });
164
+
165
+ it('failed fork (null): thread still created, starts a FRESH session (resume undefined), main session untouched (BUG 2)', async () => {
166
+ const db: Database.Database = getDb();
167
+ seedSession(db);
168
+
169
+ // forkSession returns null (e.g. no main session to fork from, or SDK refused).
170
+ vi.mocked(forkSession).mockResolvedValue(null as never);
171
+ // The fresh isolated session emits its own init id, then completes.
172
+ vi.mocked(query).mockReturnValue(createClaudeQueryMock([initMsg('fresh-1'), resultSuccessMsg('fresh-1')]) as never);
173
+
174
+ const res = await testApp.app.inject({
175
+ method: 'POST',
176
+ url: '/api/sessions/s1/threads',
177
+ payload: threadPayload,
178
+ });
179
+
180
+ expect(res.statusCode).toBe(200);
181
+ const body = res.json();
182
+ const threadId = body.thread.id as string;
183
+ const turnId = body.turn.id as string;
184
+
185
+ // The thread row has NO fork id.
186
+ const threadRow = db.prepare('SELECT external_thread_id FROM discussion_threads WHERE id = ?').get(threadId) as
187
+ | { external_thread_id: string | null }
188
+ | undefined;
189
+ expect(threadRow?.external_thread_id).toBeNull();
190
+
191
+ await waitForTurn(db, turnId);
192
+
193
+ // BUG 2: the thread started a FRESH session — no `resume` option — and did
194
+ // NOT fall back to the main session's 'main-1'.
195
+ const opts = (vi.mocked(query).mock.calls[0][0] as { options: { resume?: string } }).options;
196
+ expect(opts.resume).toBeUndefined();
197
+
198
+ // The main session is untouched.
199
+ const sessionRow = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
200
+ | { external_thread_id: string | null }
201
+ | undefined;
202
+ expect(sessionRow?.external_thread_id).toBe('main-1');
203
+ });
204
+
205
+ it('failed fork (throws): caught, thread still created, main session untouched (BUG 2)', async () => {
206
+ const db: Database.Database = getDb();
207
+ seedSession(db);
208
+
209
+ // forkSession throws — the route must catch it and still create the thread.
210
+ vi.mocked(forkSession).mockRejectedValue(new Error('boom') as never);
211
+ vi.mocked(query).mockReturnValue(createClaudeQueryMock([initMsg('fresh-2'), resultSuccessMsg('fresh-2')]) as never);
212
+
213
+ const res = await testApp.app.inject({
214
+ method: 'POST',
215
+ url: '/api/sessions/s1/threads',
216
+ payload: threadPayload,
217
+ });
218
+
219
+ expect(res.statusCode).toBe(200);
220
+ const body = res.json();
221
+ const threadId = body.thread.id as string;
222
+ const turnId = body.turn.id as string;
223
+
224
+ // The thread row has NO fork id (fork threw before storing anything).
225
+ const threadRow = db.prepare('SELECT external_thread_id FROM discussion_threads WHERE id = ?').get(threadId) as
226
+ | { external_thread_id: string | null }
227
+ | undefined;
228
+ expect(threadRow?.external_thread_id).toBeNull();
229
+
230
+ await waitForTurn(db, turnId);
231
+
232
+ // The thread started a fresh isolated session (no resume), not the main session.
233
+ const opts = (vi.mocked(query).mock.calls[0][0] as { options: { resume?: string } }).options;
234
+ expect(opts.resume).toBeUndefined();
235
+
236
+ const sessionRow = db.prepare('SELECT external_thread_id FROM sessions WHERE id = ?').get('s1') as
237
+ | { external_thread_id: string | null }
238
+ | undefined;
239
+ expect(sessionRow?.external_thread_id).toBe('main-1');
240
+ });
241
+ });
@@ -7,8 +7,10 @@
7
7
 
8
8
  import { codexProvider } from '../codex/codex-provider.ts';
9
9
  import { claudeProvider } from '../claude/claude-provider.ts';
10
+ import { repoburgProvider } from '../repoburg/repoburg-provider.ts';
10
11
  import { registerProvider } from './registry.ts';
11
12
 
12
13
  // Register built-in providers
13
14
  registerProvider('codex', codexProvider);
14
- registerProvider('claude', claudeProvider);
15
+ registerProvider('claude', claudeProvider);
16
+ registerProvider('repoburg', repoburgProvider);
@@ -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
+ });
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Shared mapping helpers for the repoburg provider: repoburg AIAction →
3
+ * agent-pane item shapes, DB item helpers, and usage mapping.
4
+ * Used by both the provider (turn orchestration) and the turn projector
5
+ * (message-stream projection).
6
+ */
7
+
8
+ import { v4 as uuidv4 } from 'uuid';
9
+ import type Database from 'better-sqlite3';
10
+ import { broadcast } from '../ws/index.ts';
11
+ import type { Item, ItemStatus } from '../db/schema.ts';
12
+ import type { AskUserSelectionGroup, AskUserItemData } from '@agentpane/shared';
13
+ import type { RepoburgAction } from './repoburg-client.ts';
14
+
15
+ /** Safely parse a JSON string field from an action row. */
16
+ export function parseJsonArgs(raw: string | null | undefined): Record<string, unknown> | null {
17
+ if (!raw) return null;
18
+ try {
19
+ const parsed = JSON.parse(raw);
20
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
21
+ } catch {
22
+ return null;
23
+ }
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
+
62
+ /** Map a repoburg AIAction (+ its first execution log) to an agent-pane item
63
+ * shape. Returns null for actions that carry no displayable content
64
+ * (final/html_final — their text is part of the LLM response). */
65
+ export function mapActionToItem(
66
+ action: RepoburgAction,
67
+ ): { type: Item['type']; data: Record<string, unknown>; status: ItemStatus } | null {
68
+ const failed = action.status === 'EXECUTION_FAILED';
69
+ const status: ItemStatus = failed ? 'failed' : 'completed';
70
+ const log = action.executionLogs?.[0];
71
+ const output = log?.output ?? '';
72
+ const errorMsg = log?.error_message ?? '';
73
+
74
+ switch (action.action_type) {
75
+ case 'create_file':
76
+ case 'overwrite_file':
77
+ case 'patch':
78
+ case 'quick_edit':
79
+ case 'apply_diff':
80
+ case 'write_todo':
81
+ case 'delete_file': {
82
+ const kind =
83
+ action.action_type === 'create_file'
84
+ ? 'add'
85
+ : action.action_type === 'delete_file'
86
+ ? 'delete'
87
+ : 'modify';
88
+ return {
89
+ type: 'file_change',
90
+ data: { changes: [{ path: action.file_path ?? '', kind }], status },
91
+ status,
92
+ };
93
+ }
94
+
95
+ case 'run_command':
96
+ case 'execute_code':
97
+ return {
98
+ type: 'command_execution',
99
+ data: {
100
+ command: action.command_string ?? action.action_type,
101
+ aggregated_output: output,
102
+ exit_code: failed ? 1 : 0,
103
+ status,
104
+ },
105
+ status,
106
+ };
107
+
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>`.
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) ?? '';
115
+ return {
116
+ type: 'background_task',
117
+ data: {
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,
129
+ },
130
+ status,
131
+ };
132
+ }
133
+
134
+ case 'web_search': {
135
+ const args = parseJsonArgs(action.arguments);
136
+ return { type: 'web_search', data: { query: (args?.query as string) ?? '' }, status };
137
+ }
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
+
154
+ case 'final':
155
+ case 'html_final':
156
+ // Their text is part of the LLM response — no separate item.
157
+ return null;
158
+
159
+ default: {
160
+ // MCP tools (serverName__toolName) and remaining built-ins → generic tool call.
161
+ const isMcp = !!(action.server_name && action.tool_name);
162
+ return {
163
+ type: 'mcp_tool_call',
164
+ data: {
165
+ server: isMcp ? action.server_name : null,
166
+ tool: isMcp ? action.tool_name : action.action_type,
167
+ arguments: parseJsonArgs(action.arguments),
168
+ result: output ? [{ type: 'text', text: output }] : null,
169
+ error: failed ? errorMsg || 'Action failed' : null,
170
+ status,
171
+ },
172
+ status,
173
+ };
174
+ }
175
+ }
176
+ }
177
+
178
+ /** Map repoburg token counts to the agent-pane TokenUsage shape. */
179
+ export function mapUsage(u: {
180
+ input: number;
181
+ cached: number;
182
+ output: number;
183
+ }): {
184
+ input_tokens: number;
185
+ cached_input_tokens: number;
186
+ cache_write_input_tokens: number;
187
+ output_tokens: number;
188
+ reasoning_output_tokens: number;
189
+ } {
190
+ return {
191
+ input_tokens: u.input,
192
+ cached_input_tokens: u.cached,
193
+ cache_write_input_tokens: 0,
194
+ output_tokens: u.output,
195
+ reasoning_output_tokens: 0,
196
+ };
197
+ }
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
+
219
+ /** Update the running state and status of a session. */
220
+ export function setSessionStatus(
221
+ db: Database.Database,
222
+ sessionId: string,
223
+ isRunning: 0 | 1,
224
+ status: string,
225
+ now: string,
226
+ hasUnreviewed = false,
227
+ ): void {
228
+ if (hasUnreviewed) {
229
+ db.prepare('UPDATE sessions SET is_running = ?, status = ?, has_unreviewed = 1, updated_at = ? WHERE id = ?').run(
230
+ isRunning,
231
+ status,
232
+ now,
233
+ sessionId,
234
+ );
235
+ } else {
236
+ db.prepare('UPDATE sessions SET is_running = ?, status = ?, updated_at = ? WHERE id = ?').run(
237
+ isRunning,
238
+ status,
239
+ now,
240
+ sessionId,
241
+ );
242
+ }
243
+ }
244
+
245
+ /** Insert an item row and broadcast it. Returns the created item. */
246
+ export function insertItem(
247
+ db: Database.Database,
248
+ sessionId: string,
249
+ turnId: string,
250
+ externalId: string,
251
+ type: Item['type'],
252
+ data: Record<string, unknown>,
253
+ status: ItemStatus,
254
+ broadcastEvent: 'item:started' | 'item:completed',
255
+ ): Item {
256
+ const now = new Date().toISOString();
257
+ const itemId = uuidv4();
258
+ db.prepare(
259
+ 'INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
260
+ ).run(itemId, turnId, externalId, type, JSON.stringify(data), status, now, now);
261
+
262
+ const item: Item = {
263
+ id: itemId,
264
+ turn_id: turnId,
265
+ external_item_id: externalId,
266
+ type,
267
+ data,
268
+ status,
269
+ created_at: now,
270
+ updated_at: now,
271
+ };
272
+ broadcast(broadcastEvent, { sessionId, turnId, item });
273
+ return item;
274
+ }
275
+
276
+ /** Update an item's data + status and broadcast the completed item. */
277
+ export function completeItem(
278
+ db: Database.Database,
279
+ sessionId: string,
280
+ turnId: string,
281
+ item: Item,
282
+ data: Record<string, unknown>,
283
+ status: ItemStatus,
284
+ ): void {
285
+ const now = new Date().toISOString();
286
+ db.prepare('UPDATE items SET data = ?, status = ?, updated_at = ? WHERE id = ?').run(
287
+ JSON.stringify(data),
288
+ status,
289
+ now,
290
+ item.id,
291
+ );
292
+ const updated: Item = { ...item, data, status, updated_at: now };
293
+ broadcast('item:completed', { sessionId, turnId, item: updated });
294
+ }