repoburg 1.3.196 → 1.3.197

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 (71) hide show
  1. package/agent-pane/backend/data.db-shm +0 -0
  2. package/agent-pane/backend/data.db-wal +0 -0
  3. package/agent-pane/backend/dist/index.js +13399 -10390
  4. package/agent-pane/backend/package-lock.json +162 -92
  5. package/agent-pane/backend/scripts/backfill-from-jsonl.ts +206 -0
  6. package/agent-pane/backend/scripts/import-session-from-jsonl.ts +264 -0
  7. package/agent-pane/backend/src/claude/claude-message-mapper.test.ts +116 -8
  8. package/agent-pane/backend/src/claude/claude-message-mapper.ts +114 -9
  9. package/agent-pane/backend/src/claude/claude-provider.ts +918 -84
  10. package/agent-pane/backend/src/claude/claude-types.ts +16 -4
  11. package/agent-pane/backend/src/codex/app-server-client.ts +22 -0
  12. package/agent-pane/backend/src/db/index.ts +102 -0
  13. package/agent-pane/backend/src/db/schema.ts +4 -0
  14. package/agent-pane/backend/src/index.ts +3 -1
  15. package/agent-pane/backend/src/integration/helpers/test-app.ts +2 -0
  16. package/agent-pane/backend/src/integration/session-operations.test.ts +18 -11
  17. package/agent-pane/backend/src/integration/threads.test.ts +309 -0
  18. package/agent-pane/backend/src/providers/runtime-provider.ts +19 -1
  19. package/agent-pane/backend/src/routes/providers.ts +11 -0
  20. package/agent-pane/backend/src/routes/sessions.test.ts +143 -9
  21. package/agent-pane/backend/src/routes/sessions.ts +133 -28
  22. package/agent-pane/backend/src/routes/threads.ts +308 -0
  23. package/agent-pane/backend/src/routes/turns.ts +32 -9
  24. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +362 -0
  25. package/agent-pane/frontend/dist/assets/index-Dwwhx7YN.css +1 -0
  26. package/agent-pane/frontend/dist/index.html +2 -2
  27. package/agent-pane/frontend/package-lock.json +51 -9
  28. package/agent-pane/frontend/src/__tests__/ThreadsPanel.test.tsx +192 -0
  29. package/agent-pane/frontend/src/api/providers.ts +6 -1
  30. package/agent-pane/frontend/src/api/sessions.test.ts +4 -0
  31. package/agent-pane/frontend/src/api/sessions.ts +13 -5
  32. package/agent-pane/frontend/src/api/websocket.ts +65 -1
  33. package/agent-pane/frontend/src/components/CollapsibleToolCalls.test.tsx +4 -4
  34. package/agent-pane/frontend/src/components/CollapsibleToolCalls.tsx +13 -4
  35. package/agent-pane/frontend/src/components/CommandInputBar.test.tsx +2 -2
  36. package/agent-pane/frontend/src/components/CommandInputBar.tsx +292 -29
  37. package/agent-pane/frontend/src/components/CommandPalette.test.tsx +0 -4
  38. package/agent-pane/frontend/src/components/CommandPalette.tsx +1 -17
  39. package/agent-pane/frontend/src/components/CommentableText.tsx +291 -0
  40. package/agent-pane/frontend/src/components/RenderArea.tsx +9 -2
  41. package/agent-pane/frontend/src/components/RightPanel.test.tsx +5 -5
  42. package/agent-pane/frontend/src/components/RightPanel.tsx +20 -14
  43. package/agent-pane/frontend/src/components/SessionTabs.tsx +7 -2
  44. package/agent-pane/frontend/src/components/StatusBar.tsx +68 -1
  45. package/agent-pane/frontend/src/components/TurnContent.test.tsx +29 -18
  46. package/agent-pane/frontend/src/components/TurnContent.tsx +199 -28
  47. package/agent-pane/frontend/src/components/input/SlashPopup.tsx +24 -5
  48. package/agent-pane/frontend/src/components/panels/TasksPanel.test.tsx +97 -0
  49. package/agent-pane/frontend/src/components/panels/TasksPanel.tsx +208 -0
  50. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +350 -0
  51. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +47 -25
  52. package/agent-pane/frontend/src/hooks/useSessionStatus.test.ts +2 -0
  53. package/agent-pane/frontend/src/store/sessionSlice.ts +29 -6
  54. package/agent-pane/frontend/src/store/store.test.ts +5 -5
  55. package/agent-pane/frontend/src/store/store.ts +4 -1
  56. package/agent-pane/frontend/src/store/threadSlice.ts +136 -0
  57. package/agent-pane/frontend/src/store/wsSlice.ts +72 -3
  58. package/agent-pane/frontend/src/styles/app.css +150 -19
  59. package/agent-pane/frontend/src/test/fixtures.ts +4 -0
  60. package/agent-pane/frontend/src/types/index.ts +5 -2
  61. package/agent-pane/frontend/src/utils/duration.test.ts +4 -0
  62. package/agent-pane/frontend/vite.config.ts +2 -2
  63. package/agent-pane/shared/provider-types.ts +14 -0
  64. package/agent-pane/shared/types.ts +60 -0
  65. package/package.json +1 -1
  66. package/agent-pane/frontend/dist/assets/index-Bg0PF2aV.css +0 -1
  67. package/agent-pane/frontend/dist/assets/index-BlybeX5e.js +0 -281
  68. package/agent-pane/frontend/src/components/panels/LogsPanel.test.tsx +0 -58
  69. package/agent-pane/frontend/src/components/panels/LogsPanel.tsx +0 -25
  70. package/agent-pane/frontend/src/components/panels/MetricsPanel.test.tsx +0 -71
  71. package/agent-pane/frontend/src/components/panels/MetricsPanel.tsx +0 -114
@@ -0,0 +1,206 @@
1
+ /**
2
+ * One-off backfill script — reconstruct Claude tool-call item data from the
3
+ * Claude Code JSONL transcripts.
4
+ *
5
+ * The early Claude provider did not capture tool results (stdout/MCP results)
6
+ * and stored `file_change.changes` as the raw tool input object (crashing the
7
+ * frontend). This script re-derives the correct `items.data` for existing
8
+ * `command_execution`, `file_change`, and `mcp_tool_call` rows by replaying
9
+ * each session's transcript (`~/.claude/projects/<encoded-cwd>/<external_thread_id>.jsonl`)
10
+ * through the same pure mapping functions used by the live provider.
11
+ *
12
+ * Idempotent: re-running only re-derives the same data. Backs up `data.db`
13
+ * to `data.db.bak` before writing. Reasoning items are NOT backfilled (the
14
+ * transcript stores signed-empty thinking blocks, not the streamed text).
15
+ *
16
+ * Usage: npx tsx scripts/backfill-from-jsonl.ts [path/to/data.db]
17
+ */
18
+
19
+ import Database from 'better-sqlite3';
20
+ import { readFileSync, copyFileSync, existsSync } from 'node:fs';
21
+ import { homedir } from 'node:os';
22
+ import { join, resolve } from 'node:path';
23
+ import { deriveFileChangeData, applyToolResult, type ToolResultLike } from '../src/claude/claude-message-mapper.ts';
24
+
25
+ interface SessionRow {
26
+ id: string;
27
+ cwd: string | null;
28
+ external_thread_id: string;
29
+ }
30
+
31
+ interface ItemRow {
32
+ id: string;
33
+ external_item_id: string;
34
+ type: string;
35
+ data: string | null;
36
+ external_thread_id: string;
37
+ }
38
+
39
+ interface TranscriptEntry {
40
+ name: string;
41
+ input: Record<string, unknown>;
42
+ toolResult: ToolResultLike | null;
43
+ toolUseResult: unknown;
44
+ isError: boolean;
45
+ }
46
+
47
+ /** Encode a cwd the way Claude Code names project transcript dirs: '/' → '-'. */
48
+ function encodeCwd(cwd: string): string {
49
+ return cwd.replace(/\//g, '-');
50
+ }
51
+
52
+ /** Locate the JSONL transcript for a session. Returns null if not found. */
53
+ function transcriptPath(cwd: string | null, externalThreadId: string): string | null {
54
+ if (!cwd) return null;
55
+ const dir = join(homedir(), '.claude', 'projects', encodeCwd(cwd));
56
+ const file = join(dir, `${externalThreadId}.jsonl`);
57
+ return existsSync(file) ? file : null;
58
+ }
59
+
60
+ /** Parse a transcript JSONL and index tool calls by tool_use_id.
61
+ * Pairs each assistant `tool_use` block with the later `user` `tool_result`. */
62
+ function buildTranscriptMap(filePath: string): Map<string, TranscriptEntry> {
63
+ const map = new Map<string, TranscriptEntry>();
64
+ const lines = readFileSync(filePath, 'utf-8').split('\n');
65
+
66
+ for (const line of lines) {
67
+ if (!line.trim()) continue;
68
+ let obj: Record<string, unknown>;
69
+ try {
70
+ obj = JSON.parse(line) as Record<string, unknown>;
71
+ } catch {
72
+ continue; // skip malformed lines
73
+ }
74
+
75
+ const type = obj.type;
76
+ const message = obj.message as { content?: unknown } | undefined;
77
+ const content = Array.isArray(message?.content) ? (message!.content as Array<Record<string, unknown>>) : [];
78
+
79
+ if (type === 'assistant') {
80
+ for (const block of content) {
81
+ if (block && block.type === 'tool_use' && typeof block.id === 'string') {
82
+ map.set(block.id, {
83
+ name: String(block.name ?? ''),
84
+ input: (block.input ?? {}) as Record<string, unknown>,
85
+ toolResult: null,
86
+ toolUseResult: null,
87
+ isError: false,
88
+ });
89
+ }
90
+ }
91
+ } else if (type === 'user') {
92
+ const toolUseResult = obj.tool_use_result;
93
+ for (const block of content) {
94
+ if (block && block.type === 'tool_result' && typeof block.tool_use_id === 'string') {
95
+ const entry = map.get(block.tool_use_id);
96
+ if (entry) {
97
+ entry.toolResult = block as unknown as ToolResultLike;
98
+ entry.toolUseResult = toolUseResult;
99
+ entry.isError = !!block.is_error;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ return map;
107
+ }
108
+
109
+ function main(): void {
110
+ const dbPath = resolve(process.argv[2] ?? 'data.db');
111
+ if (!existsSync(dbPath)) {
112
+ console.error(`Database not found: ${dbPath}`);
113
+ process.exit(1);
114
+ }
115
+
116
+ // Backup before writing
117
+ const backupPath = `${dbPath}.bak`;
118
+ copyFileSync(dbPath, backupPath);
119
+ console.log(`Backed up ${dbPath} → ${backupPath}`);
120
+
121
+ const db = new Database(dbPath);
122
+ db.pragma('journal_mode = WAL');
123
+
124
+ const sessions = db
125
+ .prepare("SELECT id, cwd, external_thread_id FROM sessions WHERE runtime_type = 'claude' AND external_thread_id IS NOT NULL")
126
+ .all() as SessionRow[];
127
+
128
+ console.log(`Claude sessions with external_thread_id: ${sessions.length}`);
129
+
130
+ // Cache transcript maps by external_thread_id.
131
+ const transcriptCache = new Map<string, Map<string, TranscriptEntry> | null>();
132
+ for (const s of sessions) {
133
+ const file = transcriptPath(s.cwd, s.external_thread_id);
134
+ if (!file) {
135
+ console.warn(` no transcript for session ${s.id} (cwd=${s.cwd ?? 'null'})`);
136
+ transcriptCache.set(s.external_thread_id, null);
137
+ continue;
138
+ }
139
+ console.log(` transcript: ${file}`);
140
+ transcriptCache.set(s.external_thread_id, buildTranscriptMap(file));
141
+ }
142
+
143
+ const items = db
144
+ .prepare(`
145
+ SELECT i.id, i.external_item_id, i.type, i.data, s.external_thread_id
146
+ FROM items i
147
+ JOIN turns t ON i.turn_id = t.id
148
+ JOIN sessions s ON t.session_id = s.id
149
+ WHERE s.runtime_type = 'claude'
150
+ AND i.type IN ('command_execution', 'file_change', 'mcp_tool_call')
151
+ AND i.external_item_id IS NOT NULL
152
+ `)
153
+ .all() as ItemRow[];
154
+
155
+ console.log(`Candidate items to patch: ${items.length}`);
156
+
157
+ const update = db.prepare('UPDATE items SET data = ?, status = ?, updated_at = ? WHERE id = ?');
158
+ const now = new Date().toISOString();
159
+
160
+ let patched = 0;
161
+ let skipped = 0;
162
+ const byType: Record<string, number> = { command_execution: 0, file_change: 0, mcp_tool_call: 0 };
163
+
164
+ const tx = db.transaction(() => {
165
+ for (const item of items) {
166
+ const tmap = transcriptCache.get(item.external_thread_id);
167
+ if (!tmap) {
168
+ skipped++;
169
+ continue;
170
+ }
171
+ const entry = tmap.get(item.external_item_id);
172
+ if (!entry) {
173
+ skipped++;
174
+ continue;
175
+ }
176
+
177
+ const existing = item.data ? (JSON.parse(item.data) as Record<string, unknown>) : {};
178
+
179
+ let updatedData: Record<string, unknown>;
180
+ if (item.type === 'file_change') {
181
+ // Re-derive the canonical changes array from the tool_use input.
182
+ updatedData = { ...deriveFileChangeData(entry.input, entry.name), status: entry.isError ? 'failed' : 'completed' };
183
+ } else {
184
+ updatedData = applyToolResult(item.type, existing, entry.toolResult, entry.toolUseResult, entry.isError);
185
+ }
186
+
187
+ const status = entry.isError ? 'failed' : 'completed';
188
+ update.run(JSON.stringify(updatedData), status, now, item.id);
189
+ patched++;
190
+ byType[item.type] = (byType[item.type] ?? 0) + 1;
191
+ }
192
+ });
193
+ tx();
194
+
195
+ console.log('---');
196
+ console.log(`Patched: ${patched}`);
197
+ console.log(` command_execution: ${byType.command_execution ?? 0}`);
198
+ console.log(` file_change: ${byType.file_change ?? 0}`);
199
+ console.log(` mcp_tool_call: ${byType.mcp_tool_call ?? 0}`);
200
+ console.log(`Skipped (no matching tool_use_id): ${skipped}`);
201
+ console.log(`Backup at: ${backupPath}`);
202
+
203
+ db.close();
204
+ }
205
+
206
+ main();
@@ -0,0 +1,264 @@
1
+ /**
2
+ * One-off import script — build a clean agent-pane session from a Claude Code
3
+ * JSONL transcript, replaying it through the (fixed) Claude mapper logic.
4
+ *
5
+ * Unlike `backfill-from-jsonl.ts` (which patches existing items by tool_use_id),
6
+ * this creates a FRESH session + turns + items from the transcript, so every
7
+ * tool_use/tool_result is captured with its output — no reliance on the broken
8
+ * historical provider data.
9
+ *
10
+ * Idempotent: the imported session uses a deterministic id (`imported-<sessionId>`);
11
+ * re-running skips if it already exists. Existing sessions are never touched.
12
+ *
13
+ * Reasoning: the transcript stores signed-empty `thinking` blocks (the real text
14
+ * was streamed via `thinking_delta`, which the transcript does not keep), so
15
+ * reasoning items are only created when a block has non-empty thinking text.
16
+ *
17
+ * Usage: npx tsx scripts/import-session-from-jsonl.ts [path/to/transcript.jsonl]
18
+ */
19
+
20
+ import Database from 'better-sqlite3';
21
+ import { readFileSync, existsSync } from 'node:fs';
22
+ import { randomUUID } from 'node:crypto';
23
+ import { resolve } from 'node:path';
24
+ import {
25
+ deriveFileChangeData,
26
+ applyToolResult,
27
+ type ToolResultLike,
28
+ } from '../src/claude/claude-message-mapper.ts';
29
+
30
+ const DB_PATH = resolve('data.db');
31
+ const DEFAULT_TRANSCRIPT =
32
+ '/Users/celal/.claude/projects/-Users-celal-inkind/3a572d87-bb45-41de-9fae-5a05b537f137.jsonl';
33
+ const DEFAULT_CWD = '/Users/celal/inkind';
34
+
35
+ interface ParsedItem {
36
+ type: string;
37
+ externalId: string;
38
+ data: Record<string, unknown>;
39
+ status: string;
40
+ createdAt: string;
41
+ }
42
+
43
+ interface Turn {
44
+ id: string;
45
+ userPrompt: string;
46
+ items: Map<string, ParsedItem>; // externalId -> item (tool items)
47
+ orderedItems: ParsedItem[];
48
+ agentResponse: string | null;
49
+ thoughts: string | null;
50
+ tokenUsage: unknown;
51
+ createdAt: string;
52
+ }
53
+
54
+ /** Extract a user prompt string from a user message's content (text-only, no tool_result). */
55
+ function userPromptText(content: unknown): string | null {
56
+ if (typeof content === 'string') {
57
+ return content.startsWith('<') || content.startsWith('[Request') ? null : content;
58
+ }
59
+ if (Array.isArray(content)) {
60
+ const texts: string[] = [];
61
+ let hasToolResult = false;
62
+ for (const b of content) {
63
+ if (typeof b !== 'object' || b === null) continue;
64
+ const block = b as Record<string, unknown>;
65
+ if (block.type === 'tool_result') hasToolResult = true;
66
+ if (block.type === 'text') texts.push(String(block.text ?? ''));
67
+ }
68
+ if (hasToolResult) return null; // tool-result message, not a new turn
69
+ return texts.join('\n').trim() || null;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ function main(): void {
75
+ const transcriptPath = resolve(process.argv[2] ?? DEFAULT_TRANSCRIPT);
76
+ const dbPath = resolve(process.argv[3] ?? DB_PATH);
77
+ if (!existsSync(transcriptPath)) {
78
+ console.error(`Transcript not found: ${transcriptPath}`);
79
+ process.exit(1);
80
+ }
81
+ if (!existsSync(dbPath)) {
82
+ console.error(`Database not found: ${dbPath}`);
83
+ process.exit(1);
84
+ }
85
+
86
+ const sessionId = `imported-${transcriptPath.split('/').pop()!.replace('.jsonl', '')}`;
87
+ const db = new Database(dbPath);
88
+ db.pragma('journal_mode = WAL');
89
+ db.pragma('foreign_keys = ON');
90
+
91
+ // Idempotency: skip if already imported.
92
+ const existing = db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId);
93
+ if (existing) {
94
+ console.log(`Session already imported (id=${sessionId}). Skipping. Delete it first to re-import.`);
95
+ db.close();
96
+ return;
97
+ }
98
+
99
+ const lines = readFileSync(transcriptPath, 'utf-8').split('\n');
100
+ let aiTitle = '';
101
+ const turns: Turn[] = [];
102
+ let current: Turn | null = null;
103
+ let firstTimestamp: string | null = null;
104
+
105
+ for (const line of lines) {
106
+ if (!line.trim()) continue;
107
+ let obj: Record<string, unknown>;
108
+ try {
109
+ obj = JSON.parse(line) as Record<string, unknown>;
110
+ } catch {
111
+ continue;
112
+ }
113
+
114
+ const ts = (obj.timestamp as string) ?? new Date().toISOString();
115
+ if (!firstTimestamp) firstTimestamp = ts;
116
+
117
+ // Metadata / summary line carries aiTitle.
118
+ if (typeof obj.aiTitle === 'string' && !aiTitle) aiTitle = obj.aiTitle as string;
119
+
120
+ const type = obj.type;
121
+ const message = obj.message as { content?: unknown } | undefined;
122
+ const content = message?.content;
123
+
124
+ if (type === 'user') {
125
+ const prompt = userPromptText(content);
126
+ if (prompt !== null) {
127
+ // New turn boundary.
128
+ if (current) turns.push(current);
129
+ current = { id: randomUUID(), userPrompt: prompt, items: new Map(), orderedItems: [], agentResponse: null, thoughts: null, tokenUsage: null, createdAt: ts };
130
+ continue;
131
+ }
132
+ // Tool-result message — apply to the current turn's items.
133
+ if (current && Array.isArray(content)) {
134
+ for (const b of content) {
135
+ if (typeof b !== 'object' || b === null) continue;
136
+ const block = b as Record<string, unknown>;
137
+ if (block.type !== 'tool_result' || typeof block.tool_use_id !== 'string') continue;
138
+ const item = current.items.get(block.tool_use_id);
139
+ if (!item) continue;
140
+ const isError = !!block.is_error;
141
+ item.data = applyToolResult(item.type, item.data, block as unknown as ToolResultLike, obj.tool_use_result, isError);
142
+ item.status = isError ? 'failed' : 'completed';
143
+ item.data.status = item.status;
144
+ }
145
+ }
146
+ continue;
147
+ }
148
+
149
+ if (type === 'assistant' && current && Array.isArray(content)) {
150
+ for (const b of content) {
151
+ if (typeof b !== 'object' || b === null) continue;
152
+ const block = b as Record<string, unknown>;
153
+ if (block.type === 'text') {
154
+ const text = String(block.text ?? '');
155
+ if (!text.trim()) continue;
156
+ const item: ParsedItem = { type: 'agent_message', externalId: randomUUID(), data: { text }, status: 'completed', createdAt: ts };
157
+ current.orderedItems.push(item);
158
+ current.agentResponse = text;
159
+ } else if (block.type === 'thinking') {
160
+ const text = String(block.thinking ?? '');
161
+ if (!text.trim()) continue; // signed-empty blocks: skip (no real text in transcript)
162
+ const item: ParsedItem = { type: 'reasoning', externalId: randomUUID(), data: { text }, status: 'completed', createdAt: ts };
163
+ current.orderedItems.push(item);
164
+ current.thoughts = text;
165
+ } else if (block.type === 'tool_use' && typeof block.id === 'string') {
166
+ const input = (block.input ?? {}) as Record<string, unknown>;
167
+ const name = String(block.name ?? '');
168
+ let type: string;
169
+ let data: Record<string, unknown>;
170
+ if (name === 'Bash') {
171
+ type = 'command_execution';
172
+ data = { command: input.command ?? '', aggregated_output: null, exit_code: null, status: 'in_progress' };
173
+ } else if (name === 'Edit' || name === 'Write' || name === 'MultiEdit' || name === 'NotebookEdit') {
174
+ type = 'file_change';
175
+ data = deriveFileChangeData(input, name);
176
+ } else {
177
+ type = 'mcp_tool_call';
178
+ data = { server: null, tool: name, arguments: input, result: null, error: null, status: 'in_progress' };
179
+ }
180
+ const item: ParsedItem = { type, externalId: block.id, data, status: 'in_progress', createdAt: ts };
181
+ current.orderedItems.push(item);
182
+ current.items.set(block.id, item);
183
+ }
184
+ }
185
+ continue;
186
+ }
187
+
188
+ if (type === 'result' && current) {
189
+ const usage = (obj as Record<string, unknown>).usage;
190
+ if (usage && typeof usage === 'object') current.tokenUsage = usage;
191
+ }
192
+ }
193
+ if (current) turns.push(current);
194
+
195
+ if (turns.length === 0) {
196
+ console.error('No turns found in transcript.');
197
+ db.close();
198
+ process.exit(1);
199
+ }
200
+
201
+ // Persist in a single transaction.
202
+ const insertSession = db.prepare(`
203
+ INSERT INTO sessions (id, title, runtime_type, model, cwd, external_thread_id, status, is_running, lifecycle, has_unreviewed, created_at, updated_at)
204
+ VALUES (?, ?, 'claude', ?, ?, ?, 'active', 0, 'finished', 0, ?, ?)
205
+ `);
206
+ const insertTurn = db.prepare(`
207
+ INSERT INTO turns (id, session_id, user_prompt, agent_response, thoughts, token_usage, external_turn_id, status, error, created_at, updated_at)
208
+ VALUES (?, ?, ?, ?, ?, ?, NULL, 'completed', NULL, ?, ?)
209
+ `);
210
+ const insertItem = db.prepare(`
211
+ INSERT INTO items (id, turn_id, external_item_id, type, data, status, created_at, updated_at)
212
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
213
+ `);
214
+
215
+ const externalThreadId = transcriptPath.split('/').pop()!.replace('.jsonl', '');
216
+ const sessionCreated = firstTimestamp ?? new Date().toISOString();
217
+
218
+ let totalItems = 0;
219
+ const counts: Record<string, number> = {};
220
+
221
+ const tx = db.transaction(() => {
222
+ insertSession.run(sessionId, aiTitle || null, 'default', DEFAULT_CWD, externalThreadId, sessionCreated, sessionCreated);
223
+ for (const turn of turns) {
224
+ insertTurn.run(
225
+ turn.id,
226
+ sessionId,
227
+ turn.userPrompt,
228
+ turn.agentResponse,
229
+ turn.thoughts,
230
+ turn.tokenUsage ? JSON.stringify(turn.tokenUsage) : null,
231
+ turn.createdAt,
232
+ turn.createdAt,
233
+ );
234
+ for (const item of turn.orderedItems) {
235
+ const itemId = randomUUID();
236
+ insertItem.run(
237
+ itemId,
238
+ turn.id,
239
+ item.externalId,
240
+ item.type,
241
+ JSON.stringify(item.data),
242
+ item.status,
243
+ item.createdAt,
244
+ item.createdAt,
245
+ );
246
+ totalItems++;
247
+ counts[item.type] = (counts[item.type] ?? 0) + 1;
248
+ }
249
+ }
250
+ });
251
+ tx();
252
+
253
+ console.log('--- import complete ---');
254
+ console.log(`session id: ${sessionId}`);
255
+ console.log(`title: ${aiTitle || '(none)'}`);
256
+ console.log(`external_thread_id: ${externalThreadId}`);
257
+ console.log(`turns: ${turns.length}`);
258
+ console.log(`items: ${totalItems}`);
259
+ for (const [t, n] of Object.entries(counts)) console.log(` ${t}: ${n}`);
260
+
261
+ db.close();
262
+ }
263
+
264
+ main();
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { mapAssistantContent, mapPartialDelta, mapUsage } from './claude-message-mapper.ts';
2
+ import { mapAssistantContent, mapPartialDelta, mapUsage, applyToolResult, deriveFileChangeData, extractToolResultText } from './claude-message-mapper.ts';
3
3
 
4
4
  describe('claude-message-mapper', () => {
5
5
  describe('mapAssistantContent', () => {
@@ -19,6 +19,22 @@ describe('claude-message-mapper', () => {
19
19
  expect(result[0].data).toEqual({ text: 'I should respond' });
20
20
  });
21
21
 
22
+ it('uses thinkingTexts fallback when thinking block is empty (signed-empty)', () => {
23
+ const content = [{ type: 'thinking', thinking: '', signature: 'sig' }] as never;
24
+ const thinkingTexts = new Map([[0, 'Accumulated thinking text']]);
25
+ const result = mapAssistantContent(content, 'msg-1', thinkingTexts);
26
+ expect(result).toHaveLength(1);
27
+ expect(result[0].type).toBe('reasoning');
28
+ expect(result[0].data).toEqual({ text: 'Accumulated thinking text' });
29
+ });
30
+
31
+ it('keeps empty text when thinkingTexts is not provided', () => {
32
+ const content = [{ type: 'thinking', thinking: '', signature: 'sig' }] as never;
33
+ const result = mapAssistantContent(content, 'msg-1');
34
+ expect(result).toHaveLength(1);
35
+ expect(result[0].data).toEqual({ text: '' });
36
+ });
37
+
22
38
  it('maps a Bash tool_use to command_execution', () => {
23
39
  const content = [{ type: 'tool_use', id: 'tu-1', name: 'Bash', input: { command: 'ls -la' } }] as never;
24
40
  const result = mapAssistantContent(content, 'msg-1');
@@ -33,22 +49,26 @@ describe('claude-message-mapper', () => {
33
49
  expect(result[0].externalId).toBe('tu-1');
34
50
  });
35
51
 
36
- it('maps an Edit tool_use to file_change', () => {
37
- const content = [{ type: 'tool_use', id: 'tu-2', name: 'Edit', input: { file_path: 'foo.ts' } }] as never;
52
+ it('maps an Edit tool_use to file_change with canonical changes array', () => {
53
+ const content = [{ type: 'tool_use', id: 'tu-2', name: 'Edit', input: { file_path: 'foo.ts', old_string: 'a', new_string: 'b' } }] as never;
38
54
  const result = mapAssistantContent(content, 'msg-1');
39
55
  expect(result).toHaveLength(1);
40
56
  expect(result[0].type).toBe('file_change');
41
57
  expect(result[0].data).toEqual({
42
- changes: { file_path: 'foo.ts' },
58
+ changes: [{ path: 'foo.ts', kind: 'modify' }],
43
59
  status: 'in_progress',
44
60
  });
45
61
  });
46
62
 
47
- it('maps a Write tool_use to file_change', () => {
48
- const content = [{ type: 'tool_use', id: 'tu-3', name: 'Write', input: { file_path: 'bar.ts' } }] as never;
63
+ it('maps a Write tool_use to file_change with kind "add"', () => {
64
+ const content = [{ type: 'tool_use', id: 'tu-3', name: 'Write', input: { file_path: 'bar.ts', content: 'x' } }] as never;
49
65
  const result = mapAssistantContent(content, 'msg-1');
50
66
  expect(result).toHaveLength(1);
51
67
  expect(result[0].type).toBe('file_change');
68
+ expect(result[0].data).toEqual({
69
+ changes: [{ path: 'bar.ts', kind: 'add' }],
70
+ status: 'in_progress',
71
+ });
52
72
  });
53
73
 
54
74
  it('maps an unknown tool_use to mcp_tool_call', () => {
@@ -92,6 +112,94 @@ describe('claude-message-mapper', () => {
92
112
  });
93
113
  });
94
114
 
115
+ describe('deriveFileChangeData', () => {
116
+ it('maps an Edit input to a single modify change', () => {
117
+ expect(deriveFileChangeData({ file_path: 'a.ts', old_string: 'x', new_string: 'y' }, 'Edit')).toEqual({
118
+ changes: [{ path: 'a.ts', kind: 'modify' }],
119
+ status: 'in_progress',
120
+ });
121
+ });
122
+
123
+ it('maps a Write input to an add change', () => {
124
+ expect(deriveFileChangeData({ file_path: 'b.ts', content: 'x' }, 'Write')).toEqual({
125
+ changes: [{ path: 'b.ts', kind: 'add' }],
126
+ status: 'in_progress',
127
+ });
128
+ });
129
+
130
+ it('falls back to notebook_path and defaults kind to modify', () => {
131
+ expect(deriveFileChangeData({ notebook_path: 'nb.ipynb' }, 'NotebookEdit')).toEqual({
132
+ changes: [{ path: 'nb.ipynb', kind: 'modify' }],
133
+ status: 'in_progress',
134
+ });
135
+ });
136
+
137
+ it('uses empty string when no path field present', () => {
138
+ expect(deriveFileChangeData({}, 'MultiEdit')).toEqual({
139
+ changes: [{ path: '', kind: 'modify' }],
140
+ status: 'in_progress',
141
+ });
142
+ });
143
+ });
144
+
145
+ describe('extractToolResultText', () => {
146
+ it('returns a string content as-is', () => {
147
+ expect(extractToolResultText('hello')).toBe('hello');
148
+ });
149
+ it('joins text blocks in an array', () => {
150
+ expect(extractToolResultText([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('a\nb');
151
+ });
152
+ it('ignores non-text blocks', () => {
153
+ expect(extractToolResultText([{ type: 'tool_reference', tool_name: 'x' }, { type: 'text', text: 'a' }])).toBe('a');
154
+ });
155
+ it('returns empty for null/undefined', () => {
156
+ expect(extractToolResultText(null)).toBe('');
157
+ expect(extractToolResultText(undefined)).toBe('');
158
+ });
159
+ });
160
+
161
+ describe('applyToolResult', () => {
162
+ it('command_execution: fills aggregated_output from structured stdout+stderr and exit_code from is_error', () => {
163
+ const existing = { command: 'ls', aggregated_output: null, exit_code: null, status: 'in_progress' };
164
+ const out = applyToolResult('command_execution', existing, { tool_use_id: 't1', content: 'file1\nfile2' }, { stdout: 'file1\nfile2', stderr: 'warn', interrupted: false, isImage: false, noOutputExpected: false }, false);
165
+ expect(out).toEqual({ command: 'ls', aggregated_output: 'file1\nfile2\nwarn', exit_code: 0, status: 'completed' });
166
+ });
167
+
168
+ it('command_execution: marks failed and exit_code 1 on error', () => {
169
+ const existing = { command: 'bad', aggregated_output: null, exit_code: null, status: 'in_progress' };
170
+ const out = applyToolResult('command_execution', existing, { tool_use_id: 't2', is_error: true, content: 'boom' }, { stdout: 'boom', stderr: '' }, true);
171
+ expect(out.exit_code).toBe(1);
172
+ expect(out.status).toBe('failed');
173
+ expect(out.aggregated_output).toBe('boom');
174
+ });
175
+
176
+ it('mcp_tool_call: fills result text and no error on success', () => {
177
+ const existing = { server: null, tool: 'Linear.get', arguments: { id: 'PAR-1' }, result: null, error: null, status: 'in_progress' };
178
+ const out = applyToolResult('mcp_tool_call', existing, { tool_use_id: 't3', content: [{ type: 'text', text: '{"id":"PAR-1"}' }] }, null, false);
179
+ expect(out.result).toEqual([{ type: 'text', text: '{"id":"PAR-1"}' }]);
180
+ expect(out.error).toBeNull();
181
+ expect(out.status).toBe('completed');
182
+ });
183
+
184
+ it('mcp_tool_call: sets error on is_error', () => {
185
+ const existing = { server: null, tool: 'X', arguments: {}, result: null, error: null, status: 'in_progress' };
186
+ const out = applyToolResult('mcp_tool_call', existing, { tool_use_id: 't4', is_error: true, content: 'denied' }, null, true);
187
+ expect(out.error).toBe('denied');
188
+ expect(out.status).toBe('failed');
189
+ });
190
+
191
+ it('file_change: keeps existing changes and flips status to completed', () => {
192
+ const existing = { changes: [{ path: 'a.ts', kind: 'modify' }], status: 'in_progress' };
193
+ const out = applyToolResult('file_change', existing, { tool_use_id: 't5', content: 'ok' }, null, false);
194
+ expect(out).toEqual({ changes: [{ path: 'a.ts', kind: 'modify' }], status: 'completed' });
195
+ });
196
+
197
+ it('unknown type: returns existing data unchanged', () => {
198
+ const existing = { foo: 1 };
199
+ expect(applyToolResult('agent_message', existing, null, null, false)).toEqual({ foo: 1 });
200
+ });
201
+ });
202
+
95
203
  describe('mapPartialDelta', () => {
96
204
  it('maps a text_delta event', () => {
97
205
  const event = {
@@ -100,7 +208,7 @@ describe('claude-message-mapper', () => {
100
208
  index: 0,
101
209
  };
102
210
  const result = mapPartialDelta(event);
103
- expect(result).toEqual({ kind: 'text', text: 'Hello' });
211
+ expect(result).toEqual({ kind: 'text', text: 'Hello', index: 0 });
104
212
  });
105
213
 
106
214
  it('maps a thinking_delta event', () => {
@@ -110,7 +218,7 @@ describe('claude-message-mapper', () => {
110
218
  index: 0,
111
219
  };
112
220
  const result = mapPartialDelta(event);
113
- expect(result).toEqual({ kind: 'thinking', text: 'Hmm' });
221
+ expect(result).toEqual({ kind: 'thinking', text: 'Hmm', index: 0 });
114
222
  });
115
223
 
116
224
  it('returns null for non-delta events', () => {