@shipfox/api-logs 10.1.0 → 11.0.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.
- package/.turbo/turbo-build.log +4 -4
- package/CHANGELOG.md +26 -0
- package/dist/core/append-logs.d.ts.map +1 -1
- package/dist/core/append-logs.js +13 -5
- package/dist/core/append-logs.js.map +1 -1
- package/dist/core/close-stream.d.ts.map +1 -1
- package/dist/core/close-stream.js +16 -1
- package/dist/core/close-stream.js.map +1 -1
- package/dist/core/entities/attempt-stream.d.ts +2 -1
- package/dist/core/entities/attempt-stream.d.ts.map +1 -1
- package/dist/core/entities/attempt-stream.js.map +1 -1
- package/dist/core/session/claude/rows.d.ts +15 -3
- package/dist/core/session/claude/rows.d.ts.map +1 -1
- package/dist/core/session/claude/rows.js +127 -13
- package/dist/core/session/claude/rows.js.map +1 -1
- package/dist/core/session/claude-parser.d.ts +4 -2
- package/dist/core/session/claude-parser.d.ts.map +1 -1
- package/dist/core/session/claude-parser.js +56 -10
- package/dist/core/session/claude-parser.js.map +1 -1
- package/dist/db/db.d.ts +113 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/schema/attempt-streams.d.ts +113 -0
- package/dist/db/schema/attempt-streams.d.ts.map +1 -1
- package/dist/db/schema/attempt-streams.js +2 -0
- package/dist/db/schema/attempt-streams.js.map +1 -1
- package/dist/db/streams.d.ts +3 -1
- package/dist/db/streams.d.ts.map +1 -1
- package/dist/db/streams.js +6 -1
- package/dist/db/streams.js.map +1 -1
- package/dist/presentation/routes/read-logs.d.ts.map +1 -1
- package/dist/presentation/routes/read-logs.js +11 -5
- package/dist/presentation/routes/read-logs.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0002_wise_dorian_gray.sql +1 -0
- package/drizzle/meta/0002_snapshot.json +561 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +3 -3
- package/src/core/append-logs.test.ts +57 -0
- package/src/core/append-logs.ts +15 -3
- package/src/core/close-stream.ts +17 -1
- package/src/core/entities/attempt-stream.ts +2 -1
- package/src/core/session/claude/rows.ts +172 -9
- package/src/core/session/claude-parser.test.ts +453 -4
- package/src/core/session/claude-parser.ts +59 -10
- package/src/db/schema/attempt-streams.ts +3 -1
- package/src/db/streams.ts +10 -2
- package/src/presentation/routes/read-logs.test.ts +18 -4
- package/src/presentation/routes/read-logs.ts +9 -5
- package/test/fixtures/claude-session/agent-step.jsonl +12 -0
- package/test/fixtures/claude-session/sdk-system-subtypes.json +32 -0
- package/test/fixtures/claude-session.ts +45 -0
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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:
|
|
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(
|
|
27
|
-
|
|
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)
|
|
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 [
|
|
121
|
+
return [
|
|
122
|
+
...flushPendingToolRows(context),
|
|
123
|
+
resultRow(record.ts, message, context.turn, isFinalResult),
|
|
124
|
+
];
|
|
86
125
|
default:
|
|
87
|
-
return [
|
|
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<{
|
|
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
|
}
|