@shipfox/api-logs 10.1.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 +13 -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 +127 -13
  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 +172 -9
  40. package/src/core/session/claude-parser.test.ts +453 -4
  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,4 +1,14 @@
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) => ({
@@ -56,6 +66,36 @@ const namedSystemEvents = [
56
66
  tone: 'default',
57
67
  },
58
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
+ },
59
99
  {
60
100
  subtype: 'model_refusal_fallback',
61
101
  data: {},
@@ -149,6 +189,91 @@ const namedSystemEvents = [
149
189
  ] as const;
150
190
 
151
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
+
152
277
  it('returns a raw row for malformed JSON', () => {
153
278
  const rows = parseClaudeSessionRecord(record('{not json'));
154
279
 
@@ -206,6 +331,166 @@ describe('parseClaudeSessionRecord', () => {
206
331
  expect(rows).toEqual([]);
207
332
  });
208
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
+
209
494
  it.each(namedSystemEvents)('names and tones $subtype system events', ({
210
495
  subtype,
211
496
  data,
@@ -332,6 +617,7 @@ describe('parseClaudeSessionRecord', () => {
332
617
  });
333
618
 
334
619
  it('expands assistant text, thinking, and tool-use blocks in order', () => {
620
+ const context = createClaudeParseContext();
335
621
  const rows = parseClaudeSessionRecord(
336
622
  record({
337
623
  type: 'assistant',
@@ -344,9 +630,10 @@ describe('parseClaudeSessionRecord', () => {
344
630
  ],
345
631
  },
346
632
  }),
633
+ context,
347
634
  );
348
635
 
349
- expect(rows).toEqual([
636
+ expect([...rows, ...flushPendingToolRows(context)]).toEqual([
350
637
  {
351
638
  kind: 'message',
352
639
  timestamp: 1,
@@ -367,6 +654,168 @@ describe('parseClaudeSessionRecord', () => {
367
654
  ]);
368
655
  });
369
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
+
370
819
  it('maps user tool results to tool-result rows', () => {
371
820
  const rows = parseClaudeSessionRecord(
372
821
  record({
@@ -390,7 +839,7 @@ describe('parseClaudeSessionRecord', () => {
390
839
  kind: 'tool-result',
391
840
  timestamp: 1,
392
841
  toolCallId: 'tool-1',
393
- toolName: 'tool',
842
+ toolName: expect.any(String),
394
843
  output: 'file contents',
395
844
  isError: true,
396
845
  },
@@ -430,7 +879,7 @@ describe('parseClaudeSessionRecord', () => {
430
879
  kind: 'tool-result',
431
880
  timestamp: 1,
432
881
  toolCallId: 'tool-1',
433
- toolName: 'tool',
882
+ toolName: expect.any(String),
434
883
  output: 'tool output',
435
884
  isError: false,
436
885
  },
@@ -1,10 +1,14 @@
1
- import type {SessionViewRow} from '@shipfox/api-logs-dto';
1
+ import type {SessionViewRow, SessionViewToolCallRow} from '@shipfox/api-logs-dto';
2
2
  import {z} from 'zod';
3
3
  import {
4
4
  assistantRows,
5
+ authStatusRow,
6
+ flushPendingToolRows,
5
7
  PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES,
8
+ rateLimitRow,
6
9
  resultRow,
7
10
  systemRow,
11
+ toolUseSummary,
8
12
  userRows,
9
13
  } from './claude/rows.js';
10
14
  import {stringField} from './object.js';
@@ -21,10 +25,25 @@ export interface ClaudeParseContext {
21
25
  hasInit: boolean;
22
26
  sessionId: string | null;
23
27
  turn: number;
28
+ pendingToolRows: SessionViewRow[];
29
+ toolCallRows: Map<string, SessionViewToolCallRow>;
24
30
  }
25
31
 
26
- export function createClaudeParseContext(): ClaudeParseContext {
27
- return {hasInit: false, sessionId: null, turn: 0};
32
+ export function createClaudeParseContext(
33
+ pendingToolRows: readonly SessionViewRow[] = [],
34
+ state: Pick<ClaudeParseContext, 'hasInit' | 'sessionId' | 'turn'> = {
35
+ hasInit: false,
36
+ sessionId: null,
37
+ turn: 0,
38
+ },
39
+ ): ClaudeParseContext {
40
+ const rows = [...pendingToolRows];
41
+ const toolCallRows = new Map<string, SessionViewToolCallRow>();
42
+ for (const row of rows) {
43
+ if (row.kind === 'tool-call' && row.id !== null) toolCallRows.set(row.id, row);
44
+ }
45
+
46
+ return {...state, pendingToolRows: rows, toolCallRows};
28
47
  }
29
48
 
30
49
  export function claudeInitSessionId(record: AgentSessionRecord): string | undefined {
@@ -58,11 +77,13 @@ export function parseClaudeSessionRecord(
58
77
  try {
59
78
  json = JSON.parse(record.data);
60
79
  } catch {
61
- return [rawRecordRow(record, 'Malformed session entry')];
80
+ return [...flushPendingToolRows(context), rawRecordRow(record, 'Malformed session entry')];
62
81
  }
63
82
 
64
83
  const parsed = claudeMessageSchema.safeParse(json);
65
- if (!parsed.success) return [rawRecordRow(record, 'Unsupported Claude message')];
84
+ if (!parsed.success) {
85
+ return [...flushPendingToolRows(context), rawRecordRow(record, 'Unsupported Claude message')];
86
+ }
66
87
 
67
88
  const message = parsed.data;
68
89
  if (
@@ -75,15 +96,43 @@ export function parseClaudeSessionRecord(
75
96
 
76
97
  switch (message.type) {
77
98
  case 'system':
99
+ if (stringField(message, 'subtype') === 'init') {
100
+ return [...flushPendingToolRows(context), systemRow(record.ts, message, context)];
101
+ }
102
+ return deferRow(context, systemRow(record.ts, message, context));
78
103
  case 'init':
79
- return [systemRow(record.ts, message, context)];
104
+ return [...flushPendingToolRows(context), systemRow(record.ts, message, context)];
105
+ case 'tool_progress':
106
+ case 'prompt_suggestion':
107
+ // These messages describe an already-emitted tool call or an interactive client state.
108
+ // They have no standalone row representation and must not look like parser failures.
109
+ return [];
110
+ case 'tool_use_summary':
111
+ return toolUseSummary(message, context) ? flushPendingToolRows(context) : [];
112
+ case 'auth_status':
113
+ return deferRow(context, authStatusRow(record.ts, message));
114
+ case 'rate_limit_event':
115
+ return deferRow(context, rateLimitRow(record.ts, message));
80
116
  case 'assistant':
81
- return assistantRows(record.ts, message);
117
+ return [...flushPendingToolRows(context), ...assistantRows(record.ts, message, context)];
82
118
  case 'user':
83
- return userRows(record.ts, message);
119
+ return userRows(record.ts, message, context);
84
120
  case 'result':
85
- return [resultRow(record.ts, message, context.turn, isFinalResult)];
121
+ return [
122
+ ...flushPendingToolRows(context),
123
+ resultRow(record.ts, message, context.turn, isFinalResult),
124
+ ];
86
125
  default:
87
- return [rawRecordRow(record, `Unknown Claude message: ${message.type}`)];
126
+ return [
127
+ ...flushPendingToolRows(context),
128
+ rawRecordRow(record, `Unknown Claude message: ${message.type}`),
129
+ ];
88
130
  }
89
131
  }
132
+
133
+ function deferRow(context: ClaudeParseContext, row: SessionViewRow): readonly SessionViewRow[] {
134
+ if (context.toolCallRows.size === 0) return [row];
135
+
136
+ context.pendingToolRows.push(row);
137
+ return [];
138
+ }
@@ -1,4 +1,4 @@
1
- import type {SessionViewLifecycleRow} from '@shipfox/api-logs-dto';
1
+ import type {SessionViewLifecycleRow, SessionViewRow} from '@shipfox/api-logs-dto';
2
2
  import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
3
3
  import {sql} from 'drizzle-orm';
4
4
  import {
@@ -55,6 +55,7 @@ export const attemptStreams = pgTable(
55
55
  claudeSessionId: text('claude_session_id'),
56
56
  claudeTurn: integer('claude_turn').notNull().default(0),
57
57
  claudePendingResult: jsonb('claude_pending_result').$type<SessionViewLifecycleRow>(),
58
+ claudePendingToolRows: jsonb('claude_pending_tool_rows').$type<SessionViewRow[]>(),
58
59
  truncated: boolean('truncated').notNull().default(false),
59
60
  objectKey: text('object_key'),
60
61
  createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
@@ -107,6 +108,7 @@ export function toAttemptStream(row: AttemptStreamDb): AttemptStream {
107
108
  claudeSessionId: row.claudeSessionId,
108
109
  claudeTurn: row.claudeTurn,
109
110
  claudePendingResult: row.claudePendingResult ?? null,
111
+ claudePendingToolRows: row.claudePendingToolRows ?? [],
110
112
  truncated: row.truncated,
111
113
  objectKey: row.objectKey,
112
114
  createdAt: row.createdAt,
package/src/db/streams.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type {SessionViewLifecycleRow} from '@shipfox/api-logs-dto';
1
+ import type {SessionViewLifecycleRow, SessionViewRow} from '@shipfox/api-logs-dto';
2
2
  import {and, asc, eq, getTableColumns, isNull, lt, notInArray, sql} from 'drizzle-orm';
3
3
  import type {AttemptStream, StreamCloseReason} from '#core/entities/attempt-stream.js';
4
4
  import {LeaseStreamMismatchError} from '#core/errors.js';
@@ -169,6 +169,7 @@ export async function setClaudeParseContext(
169
169
  sessionId: string | null;
170
170
  turn: number;
171
171
  pendingResult: SessionViewLifecycleRow | null;
172
+ pendingToolRows: readonly SessionViewRow[];
172
173
  },
173
174
  ): Promise<void> {
174
175
  await tx
@@ -178,6 +179,7 @@ export async function setClaudeParseContext(
178
179
  claudeSessionId: params.sessionId,
179
180
  claudeTurn: params.turn,
180
181
  claudePendingResult: params.pendingResult,
182
+ claudePendingToolRows: [...params.pendingToolRows],
181
183
  updatedAt: sql`now()`,
182
184
  })
183
185
  .where(eq(attemptStreams.id, params.streamId));
@@ -193,7 +195,11 @@ export async function setClaudeParseContext(
193
195
  export async function markStreamClosed(
194
196
  tx: Transaction,
195
197
  params: {streamId: string; reason: StreamCloseReason; markTruncated: boolean},
196
- ): Promise<{stream: AttemptStream; pendingClaudeResult: SessionViewLifecycleRow | null} | null> {
198
+ ): Promise<{
199
+ stream: AttemptStream;
200
+ pendingClaudeResult: SessionViewLifecycleRow | null;
201
+ pendingClaudeToolRows: SessionViewRow[];
202
+ } | null> {
197
203
  const [open] = await tx
198
204
  .select()
199
205
  .from(attemptStreams)
@@ -209,6 +215,7 @@ export async function markStreamClosed(
209
215
  closedAt: sql`now()`,
210
216
  updatedAt: sql`now()`,
211
217
  claudePendingResult: null,
218
+ claudePendingToolRows: null,
212
219
  ...(params.markTruncated ? {truncated: true} : {}),
213
220
  })
214
221
  .where(and(eq(attemptStreams.id, params.streamId), eq(attemptStreams.state, 'open')))
@@ -218,6 +225,7 @@ export async function markStreamClosed(
218
225
  ? {
219
226
  stream: toAttemptStream(row),
220
227
  pendingClaudeResult: open.claudePendingResult ?? null,
228
+ pendingClaudeToolRows: open.claudePendingToolRows ?? [],
221
229
  }
222
230
  : null;
223
231
  }