@shipfox/api-logs 10.0.0 → 10.2.0

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 (47) hide show
  1. package/.turbo/turbo-build.log +4 -4
  2. package/CHANGELOG.md +23 -0
  3. package/dist/core/append-logs.d.ts.map +1 -1
  4. package/dist/core/append-logs.js +13 -5
  5. package/dist/core/append-logs.js.map +1 -1
  6. package/dist/core/close-stream.d.ts.map +1 -1
  7. package/dist/core/close-stream.js +16 -1
  8. package/dist/core/close-stream.js.map +1 -1
  9. package/dist/core/entities/attempt-stream.d.ts +2 -1
  10. package/dist/core/entities/attempt-stream.d.ts.map +1 -1
  11. package/dist/core/entities/attempt-stream.js.map +1 -1
  12. package/dist/core/session/claude/rows.d.ts +15 -3
  13. package/dist/core/session/claude/rows.d.ts.map +1 -1
  14. package/dist/core/session/claude/rows.js +221 -15
  15. package/dist/core/session/claude/rows.js.map +1 -1
  16. package/dist/core/session/claude-parser.d.ts +4 -2
  17. package/dist/core/session/claude-parser.d.ts.map +1 -1
  18. package/dist/core/session/claude-parser.js +56 -10
  19. package/dist/core/session/claude-parser.js.map +1 -1
  20. package/dist/db/db.d.ts +113 -0
  21. package/dist/db/db.d.ts.map +1 -1
  22. package/dist/db/schema/attempt-streams.d.ts +113 -0
  23. package/dist/db/schema/attempt-streams.d.ts.map +1 -1
  24. package/dist/db/schema/attempt-streams.js +2 -0
  25. package/dist/db/schema/attempt-streams.js.map +1 -1
  26. package/dist/db/streams.d.ts +3 -1
  27. package/dist/db/streams.d.ts.map +1 -1
  28. package/dist/db/streams.js +6 -1
  29. package/dist/db/streams.js.map +1 -1
  30. package/dist/tsconfig.test.tsbuildinfo +1 -1
  31. package/drizzle/0002_wise_dorian_gray.sql +1 -0
  32. package/drizzle/meta/0002_snapshot.json +561 -0
  33. package/drizzle/meta/_journal.json +7 -0
  34. package/package.json +4 -4
  35. package/src/core/append-logs.test.ts +57 -0
  36. package/src/core/append-logs.ts +15 -3
  37. package/src/core/close-stream.ts +17 -1
  38. package/src/core/entities/attempt-stream.ts +2 -1
  39. package/src/core/session/claude/rows.ts +295 -13
  40. package/src/core/session/claude-parser.test.ts +620 -11
  41. package/src/core/session/claude-parser.ts +59 -10
  42. package/src/db/schema/attempt-streams.ts +3 -1
  43. package/src/db/streams.ts +10 -2
  44. package/test/fixtures/claude-session/agent-step.jsonl +12 -0
  45. package/test/fixtures/claude-session/sdk-system-subtypes.json +32 -0
  46. package/test/fixtures/claude-session.ts +45 -0
  47. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,12 +1,279 @@
1
- import {PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES} from './claude/rows.js';
1
+ import {
2
+ readClaudeSessionFixture,
3
+ readClaudeSystemSubtypeFixture,
4
+ readInstalledClaudeSdkVersion,
5
+ readInstalledClaudeSystemSubtypes,
6
+ } from '#test/fixtures/claude-session.js';
7
+ import {
8
+ flushPendingToolRows,
9
+ PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES,
10
+ SYSTEM_EVENT_MAPPINGS,
11
+ } from './claude/rows.js';
2
12
  import {createClaudeParseContext, parseClaudeSessionRecord} from './claude-parser.js';
3
13
 
4
14
  const record = (data: unknown, ts = 1) => ({
5
15
  ts,
6
16
  data: typeof data === 'string' ? data : JSON.stringify(data),
7
17
  });
18
+ const meta = (label: string, value: string, inline?: boolean) =>
19
+ inline == null ? {label, value} : {label, value, inline};
20
+
21
+ const namedSystemEvents = [
22
+ {
23
+ subtype: 'permission_denied',
24
+ data: {tool_name: 'mcp__shipfox_outputs__set_output', decision_reason: 'Permission mode'},
25
+ expected: {
26
+ label: 'Permission denied',
27
+ detail: 'mcp__shipfox_outputs__set_output',
28
+ meta: [meta('reason', 'Permission mode')],
29
+ tone: 'error',
30
+ },
31
+ },
32
+ {
33
+ subtype: 'api_retry',
34
+ data: {attempt: 2, max_retries: 3, error_status: 429, retry_delay_ms: 1_500},
35
+ expected: {
36
+ label: 'API retry',
37
+ detail: 'attempt 2/3',
38
+ meta: [meta('status', '429'), meta('retry delay', '1,500 ms')],
39
+ tone: 'warning',
40
+ },
41
+ },
42
+ {
43
+ subtype: 'informational',
44
+ data: {level: 'warning', content: 'The hook blocked continuation.'},
45
+ expected: {
46
+ label: 'Warning',
47
+ detail: 'The hook blocked continuation.',
48
+ meta: [],
49
+ tone: 'warning',
50
+ },
51
+ },
52
+ {
53
+ subtype: 'compact_boundary',
54
+ data: {
55
+ compact_metadata: {trigger: 'auto', pre_tokens: 12_000, post_tokens: 4_000, duration_ms: 250},
56
+ },
57
+ expected: {
58
+ label: 'Context compacted',
59
+ detail: null,
60
+ meta: [
61
+ meta('trigger', 'auto'),
62
+ meta('tokens before', '12K tokens'),
63
+ meta('tokens after', '4,000 tokens'),
64
+ meta('duration', '250 ms'),
65
+ ],
66
+ tone: 'default',
67
+ },
68
+ },
69
+ {
70
+ subtype: 'hook_response',
71
+ data: {hook_name: 'PreToolUse', outcome: 'success', output: 'Hook completed', exit_code: 0},
72
+ expected: {
73
+ label: 'Hook completed',
74
+ detail: 'Hook completed',
75
+ meta: [meta('hook', 'PreToolUse'), meta('exit code', '0')],
76
+ tone: 'default',
77
+ },
78
+ },
79
+ {
80
+ subtype: 'hook_response',
81
+ data: {hook_name: 'PreToolUse', outcome: 'error', stderr: 'command not found', exit_code: 127},
82
+ expected: {
83
+ label: 'Hook failed',
84
+ detail: 'command not found',
85
+ meta: [meta('hook', 'PreToolUse'), meta('exit code', '127')],
86
+ tone: 'error',
87
+ },
88
+ },
89
+ {
90
+ subtype: 'hook_response',
91
+ data: {hook_name: 'PostToolUse', outcome: 'cancelled'},
92
+ expected: {
93
+ label: 'Hook cancelled',
94
+ detail: null,
95
+ meta: [meta('hook', 'PostToolUse')],
96
+ tone: 'warning',
97
+ },
98
+ },
99
+ {
100
+ subtype: 'model_refusal_fallback',
101
+ data: {},
102
+ expected: {
103
+ label: 'Model refused, fell back',
104
+ detail: null,
105
+ meta: [],
106
+ tone: 'warning',
107
+ },
108
+ },
109
+ {
110
+ subtype: 'model_refusal_no_fallback',
111
+ data: {},
112
+ expected: {
113
+ label: 'Model refused',
114
+ detail: null,
115
+ meta: [],
116
+ tone: 'error',
117
+ },
118
+ },
119
+ {
120
+ subtype: 'mirror_error',
121
+ data: {error: 'Session store unavailable'},
122
+ expected: {
123
+ label: 'Session mirror failed',
124
+ detail: 'Session store unavailable',
125
+ meta: [],
126
+ tone: 'error',
127
+ },
128
+ },
129
+ {
130
+ subtype: 'worker_shutting_down',
131
+ data: {},
132
+ expected: {
133
+ label: 'Worker shutting down',
134
+ detail: null,
135
+ meta: [],
136
+ tone: 'warning',
137
+ },
138
+ },
139
+ {
140
+ subtype: 'elicitation_complete',
141
+ data: {},
142
+ expected: {
143
+ label: 'Elicitation complete',
144
+ detail: null,
145
+ meta: [],
146
+ tone: 'default',
147
+ },
148
+ },
149
+ {
150
+ subtype: 'notification',
151
+ data: {},
152
+ expected: {
153
+ label: 'Notification',
154
+ detail: null,
155
+ meta: [],
156
+ tone: 'default',
157
+ },
158
+ },
159
+ {
160
+ subtype: 'task_started',
161
+ data: {},
162
+ expected: {
163
+ label: 'Task started',
164
+ detail: null,
165
+ meta: [],
166
+ tone: 'default',
167
+ },
168
+ },
169
+ {
170
+ subtype: 'task_updated',
171
+ data: {},
172
+ expected: {
173
+ label: 'Task updated',
174
+ detail: null,
175
+ meta: [],
176
+ tone: 'default',
177
+ },
178
+ },
179
+ {
180
+ subtype: 'task_notification',
181
+ data: {},
182
+ expected: {
183
+ label: 'Task notification',
184
+ detail: null,
185
+ meta: [],
186
+ tone: 'default',
187
+ },
188
+ },
189
+ ] as const;
8
190
 
9
191
  describe('parseClaudeSessionRecord', () => {
192
+ it('covers every system subtype in the installed Claude SDK union', () => {
193
+ const fixture = readClaudeSystemSubtypeFixture();
194
+ const installedVersion = readInstalledClaudeSdkVersion();
195
+ const installedSubtypes = readInstalledClaudeSystemSubtypes();
196
+ const parserSubtypes = [
197
+ 'init',
198
+ ...PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES,
199
+ ...Object.keys(SYSTEM_EVENT_MAPPINGS),
200
+ ].sort();
201
+
202
+ expect(
203
+ fixture.version,
204
+ `The checked-in SDK subtype fixture targets ${fixture.version}, but the installed SDK is ${installedVersion}`,
205
+ ).toBe(installedVersion);
206
+ expect(
207
+ fixture.subtypes,
208
+ `The checked-in SDK subtype fixture is stale; update it for: ${installedSubtypes
209
+ .filter((subtype) => !fixture.subtypes.includes(subtype))
210
+ .join(', ')}`,
211
+ ).toEqual(installedSubtypes);
212
+ expect(
213
+ parserSubtypes,
214
+ `The parser does not classify these installed SDK system subtypes: ${installedSubtypes
215
+ .filter((subtype) => !parserSubtypes.includes(subtype))
216
+ .join(', ')}`,
217
+ ).toEqual(fixture.subtypes);
218
+ });
219
+
220
+ it('parses a captured Claude agent step with tools, signal events, and a re-prompt', () => {
221
+ const context = createClaudeParseContext();
222
+ const records = readClaudeSessionFixture('agent-step.jsonl');
223
+ let sawResult = false;
224
+ const rows = records.flatMap((sessionRecord) => {
225
+ const message = JSON.parse(sessionRecord.data) as {type?: string};
226
+ const isFinalResult = message.type !== 'result' || sawResult;
227
+ if (message.type === 'result') sawResult = true;
228
+ return parseClaudeSessionRecord(sessionRecord, context, isFinalResult);
229
+ });
230
+ const allRows = [...rows, ...flushPendingToolRows(context)];
231
+ const lifecycleRows = allRows.filter((row) => row.kind === 'lifecycle');
232
+ const toolCall = allRows.find((row) => row.kind === 'tool-call');
233
+ const toolResult = allRows.find((row) => row.kind === 'tool-result');
234
+
235
+ expect(lifecycleRows).toEqual(
236
+ expect.arrayContaining([
237
+ expect.objectContaining({label: 'Session started'}),
238
+ expect.objectContaining({
239
+ label: 'Permission denied',
240
+ detail: 'mcp__shipfox_outputs__set_output',
241
+ tone: 'error',
242
+ }),
243
+ expect.objectContaining({label: 'API retry', detail: 'attempt 2/3', tone: 'warning'}),
244
+ expect.objectContaining({label: 'Hook completed', detail: 'Hook completed'}),
245
+ expect.objectContaining({label: 'Context compacted'}),
246
+ expect.objectContaining({label: 'Turn 1 completed'}),
247
+ expect.objectContaining({label: 'Turn 2 started'}),
248
+ expect.objectContaining({label: 'Session completed'}),
249
+ ]),
250
+ );
251
+ expect(allRows).toEqual(
252
+ expect.arrayContaining([
253
+ expect.objectContaining({
254
+ kind: 'message',
255
+ role: 'platform',
256
+ text: expect.stringContaining('required workflow outputs'),
257
+ }),
258
+ ]),
259
+ );
260
+ expect(toolCall).toEqual(
261
+ expect.objectContaining({kind: 'tool-call', id: 'toolu-1', name: 'Read'}),
262
+ );
263
+ expect(toolResult).toEqual(
264
+ expect.objectContaining({
265
+ kind: 'tool-result',
266
+ toolCallId: 'toolu-1',
267
+ toolName: expect.any(String),
268
+ output: expect.stringContaining('# Shipfox'),
269
+ }),
270
+ );
271
+ if (toolCall?.kind !== 'tool-call' || toolResult?.kind !== 'tool-result') {
272
+ throw new Error('The captured tool call and result must both be parsed');
273
+ }
274
+ expect(toolResult.toolCallId).toBe(toolCall.id);
275
+ });
276
+
10
277
  it('returns a raw row for malformed JSON', () => {
11
278
  const rows = parseClaudeSessionRecord(record('{not json'));
12
279
 
@@ -44,8 +311,9 @@ describe('parseClaudeSessionRecord', () => {
44
311
  kind: 'lifecycle',
45
312
  timestamp: 1,
46
313
  label: 'Session started',
47
- detail: 'session-1',
314
+ detail: null,
48
315
  meta: [
316
+ {label: 'session', value: 'session-1'},
49
317
  {label: 'cwd', value: '/workspace', inline: false},
50
318
  {label: 'model', value: 'claude-opus-4-8'},
51
319
  ],
@@ -63,6 +331,183 @@ describe('parseClaudeSessionRecord', () => {
63
331
  expect(rows).toEqual([]);
64
332
  });
65
333
 
334
+ it.each([
335
+ ['tool_progress', {type: 'tool_progress'}],
336
+ ['prompt_suggestion', {type: 'prompt_suggestion'}],
337
+ [
338
+ 'tool_use_summary',
339
+ {
340
+ type: 'tool_use_summary',
341
+ tool_use_id: 'tool-1',
342
+ summary: 'The tool completed successfully.',
343
+ },
344
+ ],
345
+ ] as const)('does not store companion message type %s as a row', (_type, message) => {
346
+ const rows = parseClaudeSessionRecord(record(message));
347
+
348
+ expect(rows).toEqual([]);
349
+ });
350
+
351
+ it('folds a tool-use summary into the last matching tool-call row', () => {
352
+ const context = createClaudeParseContext();
353
+ expect(
354
+ parseClaudeSessionRecord(
355
+ record({
356
+ type: 'assistant',
357
+ message: {
358
+ role: 'assistant',
359
+ content: [
360
+ {type: 'tool_use', id: 'tool-1', name: 'Read', input: {file_path: 'src/a.ts'}},
361
+ {type: 'tool_use', id: 'tool-2', name: 'Read', input: {file_path: 'src/b.ts'}},
362
+ ],
363
+ },
364
+ }),
365
+ context,
366
+ ),
367
+ ).toEqual([]);
368
+
369
+ expect(
370
+ parseClaudeSessionRecord(
371
+ record({
372
+ type: 'tool_use_summary',
373
+ summary: 'Read both source files.',
374
+ preceding_tool_use_ids: ['tool-1', 'tool-2'],
375
+ }),
376
+ context,
377
+ ),
378
+ ).toEqual([
379
+ {
380
+ kind: 'tool-call',
381
+ timestamp: 1,
382
+ id: 'tool-1',
383
+ name: 'Read',
384
+ input: '{\n "file_path": "src/a.ts"\n}',
385
+ },
386
+ {
387
+ kind: 'tool-call',
388
+ timestamp: 1,
389
+ id: 'tool-2',
390
+ name: 'Read',
391
+ input: '{\n "file_path": "src/b.ts"\n}',
392
+ summary: 'Read both source files.',
393
+ },
394
+ ]);
395
+ });
396
+
397
+ it('supports the legacy single tool-use id summary shape', () => {
398
+ const context = createClaudeParseContext();
399
+ expect(
400
+ parseClaudeSessionRecord(
401
+ record({
402
+ type: 'assistant',
403
+ message: {
404
+ role: 'assistant',
405
+ content: [
406
+ {type: 'tool_use', id: 'tool-1', name: 'Read', input: {file_path: 'src/a.ts'}},
407
+ ],
408
+ },
409
+ }),
410
+ context,
411
+ ),
412
+ ).toEqual([]);
413
+
414
+ expect(
415
+ parseClaudeSessionRecord(
416
+ record({
417
+ type: 'tool_use_summary',
418
+ tool_use_id: 'tool-1',
419
+ summary: 'Read the source file.',
420
+ }),
421
+ context,
422
+ ),
423
+ ).toEqual([expect.objectContaining({summary: 'Read the source file.'})]);
424
+ });
425
+
426
+ it.each([
427
+ [
428
+ 'signed in',
429
+ {type: 'auth_status', isAuthenticating: false, output: ['Signed in']},
430
+ {label: 'Authentication complete', detail: 'Signed in', tone: 'default'},
431
+ ],
432
+ [
433
+ 'authenticating',
434
+ {type: 'auth_status', isAuthenticating: true, output: []},
435
+ {label: 'Authenticating', detail: null, tone: 'default'},
436
+ ],
437
+ [
438
+ 'failed',
439
+ {type: 'auth_status', isAuthenticating: false, output: [], error: 'Invalid credentials'},
440
+ {label: 'Authentication failed', detail: 'Invalid credentials', tone: 'error'},
441
+ ],
442
+ ] as const)('maps auth status messages to lifecycle rows (%s)', (_name, message, expected) => {
443
+ const rows = parseClaudeSessionRecord(record(message));
444
+
445
+ expect(rows).toEqual([
446
+ {kind: 'lifecycle', timestamp: 1, meta: [], terminalFailure: false, ...expected},
447
+ ]);
448
+ });
449
+
450
+ it.each([
451
+ [
452
+ 'warning',
453
+ {status: 'allowed_warning', rateLimitType: 'five_hour', utilization: 0.42},
454
+ {
455
+ label: 'Rate limit warning',
456
+ detail: 'Five hour',
457
+ meta: [{label: 'utilization', value: '42%'}],
458
+ tone: 'warning',
459
+ },
460
+ ],
461
+ [
462
+ 'rejected',
463
+ {status: 'rejected', rateLimitType: 'five_hour', utilization: 1},
464
+ {
465
+ label: 'Rate limit exceeded',
466
+ detail: 'Five hour',
467
+ meta: [{label: 'utilization', value: '100%'}],
468
+ tone: 'error',
469
+ },
470
+ ],
471
+ [
472
+ 'allowed',
473
+ {status: 'allowed'},
474
+ {label: 'Rate limit available', detail: null, meta: [], tone: 'default'},
475
+ ],
476
+ [
477
+ 'unrecognized',
478
+ {status: 'some_future_status'},
479
+ {
480
+ label: 'Rate limit updated',
481
+ detail: null,
482
+ meta: [{label: 'status', value: 'some_future_status'}],
483
+ tone: 'warning',
484
+ },
485
+ ],
486
+ ] as const)('maps rate-limit events to lifecycle rows (%s)', (_name, rateLimitInfo, expected) => {
487
+ const rows = parseClaudeSessionRecord(
488
+ record({type: 'rate_limit_event', rate_limit_info: rateLimitInfo}),
489
+ );
490
+
491
+ expect(rows).toEqual([{kind: 'lifecycle', timestamp: 1, terminalFailure: false, ...expected}]);
492
+ });
493
+
494
+ it.each(namedSystemEvents)('names and tones $subtype system events', ({
495
+ subtype,
496
+ data,
497
+ expected,
498
+ }) => {
499
+ const rows = parseClaudeSessionRecord(record({type: 'system', subtype, ...data}));
500
+
501
+ expect(rows).toEqual([
502
+ {
503
+ kind: 'lifecycle',
504
+ timestamp: 1,
505
+ ...expected,
506
+ terminalFailure: false,
507
+ },
508
+ ]);
509
+ });
510
+
66
511
  it('reports repeated init and result records as turns within one session', () => {
67
512
  const context = createClaudeParseContext();
68
513
  const rows = [
@@ -90,8 +535,8 @@ describe('parseClaudeSessionRecord', () => {
90
535
  kind: 'lifecycle',
91
536
  timestamp: 1,
92
537
  label: 'Session started',
93
- detail: 'session-1',
94
- meta: [],
538
+ detail: null,
539
+ meta: [meta('session', 'session-1')],
95
540
  tone: 'default',
96
541
  terminalFailure: false,
97
542
  },
@@ -108,7 +553,7 @@ describe('parseClaudeSessionRecord', () => {
108
553
  kind: 'lifecycle',
109
554
  timestamp: 1,
110
555
  label: 'Turn 2 started',
111
- detail: 'session-1',
556
+ detail: null,
112
557
  meta: [{label: 'turn', value: '2'}],
113
558
  tone: 'default',
114
559
  terminalFailure: false,
@@ -125,7 +570,7 @@ describe('parseClaudeSessionRecord', () => {
125
570
  ]);
126
571
  });
127
572
 
128
- it('keeps non-init system messages as session events', () => {
573
+ it('qualifies unmapped system events instead of using a bare generic label', () => {
129
574
  const rows = parseClaudeSessionRecord(
130
575
  record({type: 'system', subtype: 'custom_event', session_id: 'session-1'}),
131
576
  );
@@ -134,8 +579,8 @@ describe('parseClaudeSessionRecord', () => {
134
579
  {
135
580
  kind: 'lifecycle',
136
581
  timestamp: 1,
137
- label: 'Session event',
138
- detail: 'session-1',
582
+ label: 'Session event (custom_event)',
583
+ detail: null,
139
584
  meta: [],
140
585
  tone: 'default',
141
586
  terminalFailure: false,
@@ -172,6 +617,7 @@ describe('parseClaudeSessionRecord', () => {
172
617
  });
173
618
 
174
619
  it('expands assistant text, thinking, and tool-use blocks in order', () => {
620
+ const context = createClaudeParseContext();
175
621
  const rows = parseClaudeSessionRecord(
176
622
  record({
177
623
  type: 'assistant',
@@ -184,9 +630,10 @@ describe('parseClaudeSessionRecord', () => {
184
630
  ],
185
631
  },
186
632
  }),
633
+ context,
187
634
  );
188
635
 
189
- expect(rows).toEqual([
636
+ expect([...rows, ...flushPendingToolRows(context)]).toEqual([
190
637
  {
191
638
  kind: 'message',
192
639
  timestamp: 1,
@@ -207,6 +654,168 @@ describe('parseClaudeSessionRecord', () => {
207
654
  ]);
208
655
  });
209
656
 
657
+ it('keeps assistant content after an identified tool call in order', () => {
658
+ const context = createClaudeParseContext();
659
+ const rows = parseClaudeSessionRecord(
660
+ record({
661
+ type: 'assistant',
662
+ message: {
663
+ role: 'assistant',
664
+ content: [
665
+ {type: 'text', text: 'Before the tool.'},
666
+ {type: 'tool_use', id: 'tool-1', name: 'Read', input: {file_path: 'src/a.ts'}},
667
+ {type: 'text', text: 'After the tool.'},
668
+ ],
669
+ },
670
+ }),
671
+ context,
672
+ );
673
+
674
+ expect(rows).toEqual([
675
+ {
676
+ kind: 'message',
677
+ timestamp: 1,
678
+ role: 'assistant',
679
+ label: 'assistant',
680
+ meta: [],
681
+ text: 'Before the tool.',
682
+ terminalFailure: false,
683
+ },
684
+ ]);
685
+ expect(flushPendingToolRows(context)).toEqual([
686
+ {
687
+ kind: 'tool-call',
688
+ timestamp: 1,
689
+ id: 'tool-1',
690
+ name: 'Read',
691
+ input: '{\n "file_path": "src/a.ts"\n}',
692
+ },
693
+ {
694
+ kind: 'message',
695
+ timestamp: 1,
696
+ role: 'assistant',
697
+ label: 'assistant',
698
+ meta: [],
699
+ text: 'After the tool.',
700
+ terminalFailure: false,
701
+ },
702
+ ]);
703
+ });
704
+
705
+ it('keeps mixed user tool-result and text rows in order', () => {
706
+ const context = createClaudeParseContext();
707
+ expect(
708
+ parseClaudeSessionRecord(
709
+ record({
710
+ type: 'assistant',
711
+ message: {
712
+ role: 'assistant',
713
+ content: [
714
+ {type: 'tool_use', id: 'tool-1', name: 'Read', input: {file_path: 'src/a.ts'}},
715
+ ],
716
+ },
717
+ }),
718
+ context,
719
+ ),
720
+ ).toEqual([]);
721
+
722
+ expect(
723
+ parseClaudeSessionRecord(
724
+ record({
725
+ type: 'user',
726
+ message: {
727
+ role: 'user',
728
+ content: [
729
+ {type: 'tool_result', tool_use_id: 'tool-1', content: 'file contents'},
730
+ {type: 'text', text: 'The file was read.'},
731
+ ],
732
+ },
733
+ }),
734
+ context,
735
+ ),
736
+ ).toEqual([]);
737
+ expect(flushPendingToolRows(context)).toEqual([
738
+ {
739
+ kind: 'tool-call',
740
+ timestamp: 1,
741
+ id: 'tool-1',
742
+ name: 'Read',
743
+ input: '{\n "file_path": "src/a.ts"\n}',
744
+ },
745
+ {
746
+ kind: 'tool-result',
747
+ timestamp: 1,
748
+ toolCallId: 'tool-1',
749
+ toolName: expect.any(String),
750
+ output: 'file contents',
751
+ isError: false,
752
+ },
753
+ {
754
+ kind: 'message',
755
+ timestamp: 1,
756
+ role: 'user',
757
+ label: 'user',
758
+ meta: [],
759
+ text: 'The file was read.',
760
+ terminalFailure: false,
761
+ },
762
+ ]);
763
+ });
764
+
765
+ it('keeps lifecycle rows behind a pending tool call until its summary arrives', () => {
766
+ const context = createClaudeParseContext();
767
+ expect(
768
+ parseClaudeSessionRecord(
769
+ record({
770
+ type: 'assistant',
771
+ message: {
772
+ role: 'assistant',
773
+ content: [
774
+ {type: 'tool_use', id: 'tool-1', name: 'Read', input: {file_path: 'src/a.ts'}},
775
+ ],
776
+ },
777
+ }),
778
+ context,
779
+ ),
780
+ ).toEqual([]);
781
+
782
+ expect(
783
+ parseClaudeSessionRecord(
784
+ record({type: 'auth_status', isAuthenticating: true, output: []}),
785
+ context,
786
+ ),
787
+ ).toEqual([]);
788
+
789
+ expect(
790
+ parseClaudeSessionRecord(
791
+ record({
792
+ type: 'tool_use_summary',
793
+ preceding_tool_use_ids: ['tool-1'],
794
+ summary: 'Read the source file.',
795
+ }),
796
+ context,
797
+ ),
798
+ ).toEqual([
799
+ {
800
+ kind: 'tool-call',
801
+ timestamp: 1,
802
+ id: 'tool-1',
803
+ name: 'Read',
804
+ input: '{\n "file_path": "src/a.ts"\n}',
805
+ summary: 'Read the source file.',
806
+ },
807
+ {
808
+ kind: 'lifecycle',
809
+ timestamp: 1,
810
+ label: 'Authenticating',
811
+ detail: null,
812
+ meta: [],
813
+ tone: 'default',
814
+ terminalFailure: false,
815
+ },
816
+ ]);
817
+ });
818
+
210
819
  it('maps user tool results to tool-result rows', () => {
211
820
  const rows = parseClaudeSessionRecord(
212
821
  record({
@@ -230,7 +839,7 @@ describe('parseClaudeSessionRecord', () => {
230
839
  kind: 'tool-result',
231
840
  timestamp: 1,
232
841
  toolCallId: 'tool-1',
233
- toolName: 'tool',
842
+ toolName: expect.any(String),
234
843
  output: 'file contents',
235
844
  isError: true,
236
845
  },
@@ -270,7 +879,7 @@ describe('parseClaudeSessionRecord', () => {
270
879
  kind: 'tool-result',
271
880
  timestamp: 1,
272
881
  toolCallId: 'tool-1',
273
- toolName: 'tool',
882
+ toolName: expect.any(String),
274
883
  output: 'tool output',
275
884
  isError: false,
276
885
  },