@shipfox/api-logs 9.3.0 → 10.1.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 +35 -0
- package/dist/core/append-logs.d.ts.map +1 -1
- package/dist/core/append-logs.js +100 -12
- package/dist/core/append-logs.js.map +1 -1
- package/dist/core/close-stream.d.ts +6 -5
- package/dist/core/close-stream.d.ts.map +1 -1
- package/dist/core/close-stream.js +25 -7
- package/dist/core/close-stream.js.map +1 -1
- package/dist/core/entities/attempt-stream.d.ts +5 -0
- package/dist/core/entities/attempt-stream.d.ts.map +1 -1
- package/dist/core/entities/attempt-stream.js +1 -1
- package/dist/core/entities/attempt-stream.js.map +1 -1
- package/dist/core/session/claude/rows.d.ts +4 -2
- package/dist/core/session/claude/rows.d.ts.map +1 -1
- package/dist/core/session/claude/rows.js +137 -10
- package/dist/core/session/claude/rows.js.map +1 -1
- package/dist/core/session/claude-parser.d.ts +8 -1
- package/dist/core/session/claude-parser.d.ts.map +1 -1
- package/dist/core/session/claude-parser.js +30 -4
- package/dist/core/session/claude-parser.js.map +1 -1
- package/dist/core/session/parse-session.d.ts +6 -1
- package/dist/core/session/parse-session.d.ts.map +1 -1
- package/dist/core/session/parse-session.js +2 -2
- package/dist/core/session/parse-session.js.map +1 -1
- package/dist/db/db.d.ts +94 -0
- package/dist/db/db.d.ts.map +1 -1
- package/dist/db/schema/attempt-streams.d.ts +94 -0
- package/dist/db/schema/attempt-streams.d.ts.map +1 -1
- package/dist/db/schema/attempt-streams.js +9 -1
- package/dist/db/schema/attempt-streams.js.map +1 -1
- package/dist/db/streams.d.ts +17 -6
- package/dist/db/streams.d.ts.map +1 -1
- package/dist/db/streams.js +21 -6
- package/dist/db/streams.js.map +1 -1
- package/dist/tsconfig.test.tsbuildinfo +1 -1
- package/drizzle/0001_strong_major_mapleleaf.sql +4 -0
- package/drizzle/meta/0001_snapshot.json +555 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +5 -5
- package/src/core/append-logs.test.ts +144 -4
- package/src/core/append-logs.ts +158 -6
- package/src/core/close-stream.ts +26 -7
- package/src/core/entities/attempt-stream.ts +6 -0
- package/src/core/finalize-attempt-stream.test.ts +57 -0
- package/src/core/session/claude/rows.ts +176 -7
- package/src/core/session/claude-parser.test.ts +279 -2
- package/src/core/session/claude-parser.ts +55 -4
- package/src/core/session/parse-session.ts +10 -1
- package/src/db/schema/attempt-streams.ts +10 -0
- package/src/db/streams.ts +43 -7
- package/tsconfig.build.tsbuildinfo +1 -1
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
SessionViewToolCallRow,
|
|
6
6
|
SessionViewToolResultRow,
|
|
7
7
|
} from '@shipfox/api-logs-dto';
|
|
8
|
+
import type {ClaudeParseContext} from '../claude-parser.js';
|
|
8
9
|
import {asLooseObject} from '../entry-schema.js';
|
|
9
10
|
import {
|
|
10
11
|
booleanField,
|
|
@@ -12,20 +13,132 @@ import {
|
|
|
12
13
|
formatNumber,
|
|
13
14
|
isMeta,
|
|
14
15
|
metaItem,
|
|
16
|
+
numberField,
|
|
15
17
|
stringField,
|
|
16
18
|
stringifyValue,
|
|
17
19
|
toJson,
|
|
18
20
|
} from '../object.js';
|
|
19
21
|
import {lifecycleRow, messageRow, thinkingRow} from '../rows.js';
|
|
20
22
|
|
|
23
|
+
export const PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES = new Set<string>([
|
|
24
|
+
'thinking_tokens',
|
|
25
|
+
'status',
|
|
26
|
+
'session_state_changed',
|
|
27
|
+
'task_progress',
|
|
28
|
+
'hook_progress',
|
|
29
|
+
'hook_started',
|
|
30
|
+
'commands_changed',
|
|
31
|
+
'files_persisted',
|
|
32
|
+
'memory_recall',
|
|
33
|
+
'local_command_output',
|
|
34
|
+
'plugin_install',
|
|
35
|
+
]);
|
|
36
|
+
const OUTPUT_REPROMPT_PREFIX = 'The previous turn ended without setting required workflow outputs:';
|
|
37
|
+
|
|
38
|
+
type SystemEventMapping = {
|
|
39
|
+
label: string | ((message: Record<string, unknown>) => string);
|
|
40
|
+
tone:
|
|
41
|
+
| SessionViewLifecycleRow['tone']
|
|
42
|
+
| ((message: Record<string, unknown>) => SessionViewLifecycleRow['tone']);
|
|
43
|
+
detail?: (message: Record<string, unknown>) => string | null;
|
|
44
|
+
meta?: (message: Record<string, unknown>) => readonly SessionViewRowMeta[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const SYSTEM_EVENT_MAPPINGS: Record<string, SystemEventMapping> = {
|
|
48
|
+
permission_denied: {
|
|
49
|
+
label: 'Permission denied',
|
|
50
|
+
tone: 'error',
|
|
51
|
+
detail: (message) => stringField(message, 'tool_name') ?? null,
|
|
52
|
+
meta: (message) =>
|
|
53
|
+
[
|
|
54
|
+
metaItem(
|
|
55
|
+
'reason',
|
|
56
|
+
stringField(message, 'decision_reason') ?? stringField(message, 'message'),
|
|
57
|
+
),
|
|
58
|
+
].filter(isMeta),
|
|
59
|
+
},
|
|
60
|
+
api_retry: {
|
|
61
|
+
label: 'API retry',
|
|
62
|
+
tone: 'warning',
|
|
63
|
+
detail: (message) => {
|
|
64
|
+
const attempt = numberField(message, 'attempt');
|
|
65
|
+
const maxRetries = numberField(message, 'max_retries');
|
|
66
|
+
return attempt == null || maxRetries == null
|
|
67
|
+
? null
|
|
68
|
+
: `attempt ${formatNumber(attempt)}/${formatNumber(maxRetries)}`;
|
|
69
|
+
},
|
|
70
|
+
meta: (message) =>
|
|
71
|
+
[
|
|
72
|
+
numberMeta(message, 'error_status', 'status'),
|
|
73
|
+
numberMeta(message, 'retry_delay_ms', 'retry delay', 'ms'),
|
|
74
|
+
].filter(isMeta),
|
|
75
|
+
},
|
|
76
|
+
informational: {
|
|
77
|
+
label: (message) => titleCase(stringField(message, 'level') ?? 'informational'),
|
|
78
|
+
tone: (message) => (stringField(message, 'level') === 'warning' ? 'warning' : 'default'),
|
|
79
|
+
detail: (message) => stringField(message, 'content') ?? null,
|
|
80
|
+
},
|
|
81
|
+
compact_boundary: {
|
|
82
|
+
label: 'Context compacted',
|
|
83
|
+
tone: 'default',
|
|
84
|
+
meta: (message) => {
|
|
85
|
+
const compactMetadata = asLooseObject(field(message, 'compact_metadata')) ?? {};
|
|
86
|
+
return [
|
|
87
|
+
metaItem('trigger', stringField(compactMetadata, 'trigger')),
|
|
88
|
+
numberMeta(compactMetadata, 'pre_tokens', 'tokens before', 'tokens'),
|
|
89
|
+
numberMeta(compactMetadata, 'post_tokens', 'tokens after', 'tokens'),
|
|
90
|
+
numberMeta(compactMetadata, 'duration_ms', 'duration', 'ms'),
|
|
91
|
+
].filter(isMeta);
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
model_refusal_fallback: {
|
|
95
|
+
label: 'Model refused, fell back',
|
|
96
|
+
tone: 'warning',
|
|
97
|
+
},
|
|
98
|
+
model_refusal_no_fallback: {
|
|
99
|
+
label: 'Model refused',
|
|
100
|
+
tone: 'error',
|
|
101
|
+
},
|
|
102
|
+
mirror_error: {
|
|
103
|
+
label: 'Session mirror failed',
|
|
104
|
+
tone: 'error',
|
|
105
|
+
detail: (message) => stringField(message, 'error') ?? null,
|
|
106
|
+
},
|
|
107
|
+
worker_shutting_down: {
|
|
108
|
+
label: 'Worker shutting down',
|
|
109
|
+
tone: 'warning',
|
|
110
|
+
},
|
|
111
|
+
elicitation_complete: {
|
|
112
|
+
label: 'Elicitation complete',
|
|
113
|
+
tone: 'default',
|
|
114
|
+
},
|
|
115
|
+
notification: {
|
|
116
|
+
label: 'Notification',
|
|
117
|
+
tone: 'default',
|
|
118
|
+
},
|
|
119
|
+
task_started: {
|
|
120
|
+
label: 'Task started',
|
|
121
|
+
tone: 'default',
|
|
122
|
+
},
|
|
123
|
+
task_updated: {
|
|
124
|
+
label: 'Task updated',
|
|
125
|
+
tone: 'default',
|
|
126
|
+
},
|
|
127
|
+
task_notification: {
|
|
128
|
+
label: 'Task notification',
|
|
129
|
+
tone: 'default',
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
21
133
|
export function systemRow(
|
|
22
134
|
timestamp: number,
|
|
23
135
|
message: Record<string, unknown>,
|
|
136
|
+
context: ClaudeParseContext,
|
|
24
137
|
): SessionViewLifecycleRow {
|
|
25
138
|
const subtype = stringField(message, 'subtype');
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
const
|
|
139
|
+
const sessionId = stringField(message, 'session_id') ?? stringField(message, 'sessionId');
|
|
140
|
+
const isInit = subtype === 'init' || message.type === 'init';
|
|
141
|
+
const baseMeta = [
|
|
29
142
|
metaItem('cwd', stringField(message, 'cwd'), false),
|
|
30
143
|
metaItem('model', stringField(message, 'model')),
|
|
31
144
|
metaItem(
|
|
@@ -34,7 +147,44 @@ export function systemRow(
|
|
|
34
147
|
),
|
|
35
148
|
].filter(isMeta);
|
|
36
149
|
|
|
37
|
-
|
|
150
|
+
if (!isInit) {
|
|
151
|
+
const mapping = subtype === undefined ? undefined : SYSTEM_EVENT_MAPPINGS[subtype];
|
|
152
|
+
const label =
|
|
153
|
+
mapping === undefined
|
|
154
|
+
? `Session event (${subtype ?? 'unknown'})`
|
|
155
|
+
: typeof mapping.label === 'function'
|
|
156
|
+
? mapping.label(message)
|
|
157
|
+
: mapping.label;
|
|
158
|
+
const tone =
|
|
159
|
+
mapping === undefined
|
|
160
|
+
? 'default'
|
|
161
|
+
: typeof mapping.tone === 'function'
|
|
162
|
+
? mapping.tone(message)
|
|
163
|
+
: mapping.tone;
|
|
164
|
+
const detail = mapping?.detail?.(message) ?? null;
|
|
165
|
+
const meta = [...baseMeta, ...(mapping?.meta?.(message) ?? [])];
|
|
166
|
+
|
|
167
|
+
return lifecycleRow(timestamp, label, detail, tone, false, meta);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const isNewSession =
|
|
171
|
+
!context.hasInit || sessionId === undefined || sessionId !== context.sessionId;
|
|
172
|
+
context.hasInit = true;
|
|
173
|
+
context.sessionId = sessionId ?? null;
|
|
174
|
+
context.turn = isNewSession ? 1 : context.turn + 1;
|
|
175
|
+
|
|
176
|
+
const label = isNewSession ? 'Session started' : `Turn ${context.turn} started`;
|
|
177
|
+
const meta = [
|
|
178
|
+
metaItem('session', isNewSession ? sessionId : null),
|
|
179
|
+
metaItem('turn', isNewSession ? null : String(context.turn)),
|
|
180
|
+
...baseMeta,
|
|
181
|
+
].filter(isMeta);
|
|
182
|
+
|
|
183
|
+
return lifecycleRow(timestamp, label, null, 'default', false, meta);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function titleCase(value: string): string {
|
|
187
|
+
return value.length === 0 ? value : `${value[0]?.toUpperCase()}${value.slice(1)}`;
|
|
38
188
|
}
|
|
39
189
|
|
|
40
190
|
export function assistantRows(
|
|
@@ -92,11 +242,12 @@ export function userRows(
|
|
|
92
242
|
message: Record<string, unknown>,
|
|
93
243
|
): readonly SessionViewRow[] {
|
|
94
244
|
const sdkMessage = asLooseObject(message.message) ?? message;
|
|
245
|
+
const role = isPlatformMessage(sdkMessage) ? 'platform' : 'user';
|
|
95
246
|
const rows: SessionViewRow[] = [];
|
|
96
247
|
const textParts: string[] = [];
|
|
97
248
|
const pushText = () => {
|
|
98
249
|
if (textParts.length === 0) return;
|
|
99
|
-
rows.push(messageRow(timestamp,
|
|
250
|
+
rows.push(messageRow(timestamp, role, role, textParts.join('\n\n'), false));
|
|
100
251
|
textParts.length = 0;
|
|
101
252
|
};
|
|
102
253
|
|
|
@@ -117,12 +268,14 @@ export function userRows(
|
|
|
117
268
|
if (rows.length > 0) return rows;
|
|
118
269
|
|
|
119
270
|
const content = stringField(sdkMessage, 'content');
|
|
120
|
-
return [messageRow(timestamp,
|
|
271
|
+
return [messageRow(timestamp, role, role, content ?? toJson(message), false)];
|
|
121
272
|
}
|
|
122
273
|
|
|
123
274
|
export function resultRow(
|
|
124
275
|
timestamp: number,
|
|
125
276
|
message: Record<string, unknown>,
|
|
277
|
+
turn: number,
|
|
278
|
+
isFinalResult: boolean,
|
|
126
279
|
): SessionViewLifecycleRow {
|
|
127
280
|
const isError = booleanField(message, 'is_error') || booleanField(message, 'isError');
|
|
128
281
|
const subtype = stringField(message, 'subtype');
|
|
@@ -133,6 +286,7 @@ export function resultRow(
|
|
|
133
286
|
stringField(message, 'message') ??
|
|
134
287
|
null;
|
|
135
288
|
const meta = [
|
|
289
|
+
metaItem('turn', !terminalFailure && !isFinalResult && turn > 0 ? String(turn) : null),
|
|
136
290
|
numberMeta(message, 'duration_ms', 'duration', 'ms'),
|
|
137
291
|
numberMeta(message, 'duration_api_ms', 'api duration', 'ms'),
|
|
138
292
|
numberMeta(message, 'num_turns', 'turns'),
|
|
@@ -141,7 +295,11 @@ export function resultRow(
|
|
|
141
295
|
|
|
142
296
|
return lifecycleRow(
|
|
143
297
|
timestamp,
|
|
144
|
-
terminalFailure
|
|
298
|
+
terminalFailure
|
|
299
|
+
? 'Session failed'
|
|
300
|
+
: isFinalResult || turn === 0
|
|
301
|
+
? 'Session completed'
|
|
302
|
+
: `Turn ${turn} completed`,
|
|
145
303
|
detail,
|
|
146
304
|
terminalFailure ? 'error' : 'default',
|
|
147
305
|
terminalFailure,
|
|
@@ -149,6 +307,17 @@ export function resultRow(
|
|
|
149
307
|
);
|
|
150
308
|
}
|
|
151
309
|
|
|
310
|
+
function isPlatformMessage(message: Record<string, unknown>): boolean {
|
|
311
|
+
const content = field(message, 'content');
|
|
312
|
+
if (typeof content === 'string') return content.startsWith(OUTPUT_REPROMPT_PREFIX);
|
|
313
|
+
if (!Array.isArray(content)) return false;
|
|
314
|
+
|
|
315
|
+
return content.some((block) => {
|
|
316
|
+
const object = asLooseObject(block);
|
|
317
|
+
return stringField(object, 'text')?.startsWith(OUTPUT_REPROMPT_PREFIX) === true;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
152
321
|
function contentBlocks(message: Record<string, unknown>): Record<string, unknown>[] {
|
|
153
322
|
const content = message.content;
|
|
154
323
|
if (!Array.isArray(content)) return [];
|
|
@@ -1,9 +1,152 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES} from './claude/rows.js';
|
|
2
|
+
import {createClaudeParseContext, parseClaudeSessionRecord} from './claude-parser.js';
|
|
2
3
|
|
|
3
4
|
const record = (data: unknown, ts = 1) => ({
|
|
4
5
|
ts,
|
|
5
6
|
data: typeof data === 'string' ? data : JSON.stringify(data),
|
|
6
7
|
});
|
|
8
|
+
const meta = (label: string, value: string, inline?: boolean) =>
|
|
9
|
+
inline == null ? {label, value} : {label, value, inline};
|
|
10
|
+
|
|
11
|
+
const namedSystemEvents = [
|
|
12
|
+
{
|
|
13
|
+
subtype: 'permission_denied',
|
|
14
|
+
data: {tool_name: 'mcp__shipfox_outputs__set_output', decision_reason: 'Permission mode'},
|
|
15
|
+
expected: {
|
|
16
|
+
label: 'Permission denied',
|
|
17
|
+
detail: 'mcp__shipfox_outputs__set_output',
|
|
18
|
+
meta: [meta('reason', 'Permission mode')],
|
|
19
|
+
tone: 'error',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
subtype: 'api_retry',
|
|
24
|
+
data: {attempt: 2, max_retries: 3, error_status: 429, retry_delay_ms: 1_500},
|
|
25
|
+
expected: {
|
|
26
|
+
label: 'API retry',
|
|
27
|
+
detail: 'attempt 2/3',
|
|
28
|
+
meta: [meta('status', '429'), meta('retry delay', '1,500 ms')],
|
|
29
|
+
tone: 'warning',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
subtype: 'informational',
|
|
34
|
+
data: {level: 'warning', content: 'The hook blocked continuation.'},
|
|
35
|
+
expected: {
|
|
36
|
+
label: 'Warning',
|
|
37
|
+
detail: 'The hook blocked continuation.',
|
|
38
|
+
meta: [],
|
|
39
|
+
tone: 'warning',
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
subtype: 'compact_boundary',
|
|
44
|
+
data: {
|
|
45
|
+
compact_metadata: {trigger: 'auto', pre_tokens: 12_000, post_tokens: 4_000, duration_ms: 250},
|
|
46
|
+
},
|
|
47
|
+
expected: {
|
|
48
|
+
label: 'Context compacted',
|
|
49
|
+
detail: null,
|
|
50
|
+
meta: [
|
|
51
|
+
meta('trigger', 'auto'),
|
|
52
|
+
meta('tokens before', '12K tokens'),
|
|
53
|
+
meta('tokens after', '4,000 tokens'),
|
|
54
|
+
meta('duration', '250 ms'),
|
|
55
|
+
],
|
|
56
|
+
tone: 'default',
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
subtype: 'model_refusal_fallback',
|
|
61
|
+
data: {},
|
|
62
|
+
expected: {
|
|
63
|
+
label: 'Model refused, fell back',
|
|
64
|
+
detail: null,
|
|
65
|
+
meta: [],
|
|
66
|
+
tone: 'warning',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
subtype: 'model_refusal_no_fallback',
|
|
71
|
+
data: {},
|
|
72
|
+
expected: {
|
|
73
|
+
label: 'Model refused',
|
|
74
|
+
detail: null,
|
|
75
|
+
meta: [],
|
|
76
|
+
tone: 'error',
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
subtype: 'mirror_error',
|
|
81
|
+
data: {error: 'Session store unavailable'},
|
|
82
|
+
expected: {
|
|
83
|
+
label: 'Session mirror failed',
|
|
84
|
+
detail: 'Session store unavailable',
|
|
85
|
+
meta: [],
|
|
86
|
+
tone: 'error',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
subtype: 'worker_shutting_down',
|
|
91
|
+
data: {},
|
|
92
|
+
expected: {
|
|
93
|
+
label: 'Worker shutting down',
|
|
94
|
+
detail: null,
|
|
95
|
+
meta: [],
|
|
96
|
+
tone: 'warning',
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
subtype: 'elicitation_complete',
|
|
101
|
+
data: {},
|
|
102
|
+
expected: {
|
|
103
|
+
label: 'Elicitation complete',
|
|
104
|
+
detail: null,
|
|
105
|
+
meta: [],
|
|
106
|
+
tone: 'default',
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
subtype: 'notification',
|
|
111
|
+
data: {},
|
|
112
|
+
expected: {
|
|
113
|
+
label: 'Notification',
|
|
114
|
+
detail: null,
|
|
115
|
+
meta: [],
|
|
116
|
+
tone: 'default',
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
subtype: 'task_started',
|
|
121
|
+
data: {},
|
|
122
|
+
expected: {
|
|
123
|
+
label: 'Task started',
|
|
124
|
+
detail: null,
|
|
125
|
+
meta: [],
|
|
126
|
+
tone: 'default',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
subtype: 'task_updated',
|
|
131
|
+
data: {},
|
|
132
|
+
expected: {
|
|
133
|
+
label: 'Task updated',
|
|
134
|
+
detail: null,
|
|
135
|
+
meta: [],
|
|
136
|
+
tone: 'default',
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
subtype: 'task_notification',
|
|
141
|
+
data: {},
|
|
142
|
+
expected: {
|
|
143
|
+
label: 'Task notification',
|
|
144
|
+
detail: null,
|
|
145
|
+
meta: [],
|
|
146
|
+
tone: 'default',
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
] as const;
|
|
7
150
|
|
|
8
151
|
describe('parseClaudeSessionRecord', () => {
|
|
9
152
|
it('returns a raw row for malformed JSON', () => {
|
|
@@ -43,8 +186,9 @@ describe('parseClaudeSessionRecord', () => {
|
|
|
43
186
|
kind: 'lifecycle',
|
|
44
187
|
timestamp: 1,
|
|
45
188
|
label: 'Session started',
|
|
46
|
-
detail:
|
|
189
|
+
detail: null,
|
|
47
190
|
meta: [
|
|
191
|
+
{label: 'session', value: 'session-1'},
|
|
48
192
|
{label: 'cwd', value: '/workspace', inline: false},
|
|
49
193
|
{label: 'model', value: 'claude-opus-4-8'},
|
|
50
194
|
],
|
|
@@ -54,6 +198,139 @@ describe('parseClaudeSessionRecord', () => {
|
|
|
54
198
|
]);
|
|
55
199
|
});
|
|
56
200
|
|
|
201
|
+
it.each([
|
|
202
|
+
...PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES,
|
|
203
|
+
])('drops pure-progress system subtype %s', (subtype) => {
|
|
204
|
+
const rows = parseClaudeSessionRecord(record({type: 'system', subtype}));
|
|
205
|
+
|
|
206
|
+
expect(rows).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it.each(namedSystemEvents)('names and tones $subtype system events', ({
|
|
210
|
+
subtype,
|
|
211
|
+
data,
|
|
212
|
+
expected,
|
|
213
|
+
}) => {
|
|
214
|
+
const rows = parseClaudeSessionRecord(record({type: 'system', subtype, ...data}));
|
|
215
|
+
|
|
216
|
+
expect(rows).toEqual([
|
|
217
|
+
{
|
|
218
|
+
kind: 'lifecycle',
|
|
219
|
+
timestamp: 1,
|
|
220
|
+
...expected,
|
|
221
|
+
terminalFailure: false,
|
|
222
|
+
},
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('reports repeated init and result records as turns within one session', () => {
|
|
227
|
+
const context = createClaudeParseContext();
|
|
228
|
+
const rows = [
|
|
229
|
+
...parseClaudeSessionRecord(
|
|
230
|
+
record({type: 'system', subtype: 'init', session_id: 'session-1'}),
|
|
231
|
+
context,
|
|
232
|
+
),
|
|
233
|
+
...parseClaudeSessionRecord(
|
|
234
|
+
record({type: 'result', subtype: 'success', result: 'first response'}),
|
|
235
|
+
context,
|
|
236
|
+
false,
|
|
237
|
+
),
|
|
238
|
+
...parseClaudeSessionRecord(
|
|
239
|
+
record({type: 'system', subtype: 'init', session_id: 'session-1'}),
|
|
240
|
+
context,
|
|
241
|
+
),
|
|
242
|
+
...parseClaudeSessionRecord(
|
|
243
|
+
record({type: 'result', subtype: 'success', result: 'final response'}),
|
|
244
|
+
context,
|
|
245
|
+
),
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
expect(rows).toEqual([
|
|
249
|
+
{
|
|
250
|
+
kind: 'lifecycle',
|
|
251
|
+
timestamp: 1,
|
|
252
|
+
label: 'Session started',
|
|
253
|
+
detail: null,
|
|
254
|
+
meta: [meta('session', 'session-1')],
|
|
255
|
+
tone: 'default',
|
|
256
|
+
terminalFailure: false,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
kind: 'lifecycle',
|
|
260
|
+
timestamp: 1,
|
|
261
|
+
label: 'Turn 1 completed',
|
|
262
|
+
detail: 'first response',
|
|
263
|
+
meta: [{label: 'turn', value: '1'}],
|
|
264
|
+
tone: 'default',
|
|
265
|
+
terminalFailure: false,
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
kind: 'lifecycle',
|
|
269
|
+
timestamp: 1,
|
|
270
|
+
label: 'Turn 2 started',
|
|
271
|
+
detail: null,
|
|
272
|
+
meta: [{label: 'turn', value: '2'}],
|
|
273
|
+
tone: 'default',
|
|
274
|
+
terminalFailure: false,
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
kind: 'lifecycle',
|
|
278
|
+
timestamp: 1,
|
|
279
|
+
label: 'Session completed',
|
|
280
|
+
detail: 'final response',
|
|
281
|
+
meta: [],
|
|
282
|
+
tone: 'default',
|
|
283
|
+
terminalFailure: false,
|
|
284
|
+
},
|
|
285
|
+
]);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('qualifies unmapped system events instead of using a bare generic label', () => {
|
|
289
|
+
const rows = parseClaudeSessionRecord(
|
|
290
|
+
record({type: 'system', subtype: 'custom_event', session_id: 'session-1'}),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expect(rows).toEqual([
|
|
294
|
+
{
|
|
295
|
+
kind: 'lifecycle',
|
|
296
|
+
timestamp: 1,
|
|
297
|
+
label: 'Session event (custom_event)',
|
|
298
|
+
detail: null,
|
|
299
|
+
meta: [],
|
|
300
|
+
tone: 'default',
|
|
301
|
+
terminalFailure: false,
|
|
302
|
+
},
|
|
303
|
+
]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('labels workflow-output re-prompts as platform messages', () => {
|
|
307
|
+
const rows = parseClaudeSessionRecord(
|
|
308
|
+
record({
|
|
309
|
+
type: 'user',
|
|
310
|
+
message: {
|
|
311
|
+
role: 'user',
|
|
312
|
+
content:
|
|
313
|
+
'The previous turn ended without setting required workflow outputs: answer. ' +
|
|
314
|
+
'Call set_output for each missing key, then provide your final response.',
|
|
315
|
+
},
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
expect(rows).toEqual([
|
|
320
|
+
{
|
|
321
|
+
kind: 'message',
|
|
322
|
+
timestamp: 1,
|
|
323
|
+
role: 'platform',
|
|
324
|
+
label: 'platform',
|
|
325
|
+
meta: [],
|
|
326
|
+
text:
|
|
327
|
+
'The previous turn ended without setting required workflow outputs: answer. ' +
|
|
328
|
+
'Call set_output for each missing key, then provide your final response.',
|
|
329
|
+
terminalFailure: false,
|
|
330
|
+
},
|
|
331
|
+
]);
|
|
332
|
+
});
|
|
333
|
+
|
|
57
334
|
it('expands assistant text, thinking, and tool-use blocks in order', () => {
|
|
58
335
|
const rows = parseClaudeSessionRecord(
|
|
59
336
|
record({
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import type {SessionViewRow} from '@shipfox/api-logs-dto';
|
|
2
2
|
import {z} from 'zod';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
assistantRows,
|
|
5
|
+
PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES,
|
|
6
|
+
resultRow,
|
|
7
|
+
systemRow,
|
|
8
|
+
userRows,
|
|
9
|
+
} from './claude/rows.js';
|
|
10
|
+
import {stringField} from './object.js';
|
|
4
11
|
import {rawRecordRow} from './rows.js';
|
|
5
12
|
import type {AgentSessionRecord} from './session-record.js';
|
|
6
13
|
|
|
@@ -10,7 +17,43 @@ const claudeMessageSchema = z
|
|
|
10
17
|
})
|
|
11
18
|
.catchall(z.unknown());
|
|
12
19
|
|
|
13
|
-
export
|
|
20
|
+
export interface ClaudeParseContext {
|
|
21
|
+
hasInit: boolean;
|
|
22
|
+
sessionId: string | null;
|
|
23
|
+
turn: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createClaudeParseContext(): ClaudeParseContext {
|
|
27
|
+
return {hasInit: false, sessionId: null, turn: 0};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function claudeInitSessionId(record: AgentSessionRecord): string | undefined {
|
|
31
|
+
let json: unknown;
|
|
32
|
+
try {
|
|
33
|
+
json = JSON.parse(record.data);
|
|
34
|
+
} catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const parsed = claudeMessageSchema.safeParse(json);
|
|
39
|
+
if (!parsed.success) return undefined;
|
|
40
|
+
|
|
41
|
+
const message = parsed.data;
|
|
42
|
+
if (
|
|
43
|
+
message.type !== 'init' &&
|
|
44
|
+
!(message.type === 'system' && stringField(message, 'subtype') === 'init')
|
|
45
|
+
) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return stringField(message, 'session_id') ?? stringField(message, 'sessionId');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function parseClaudeSessionRecord(
|
|
53
|
+
record: AgentSessionRecord,
|
|
54
|
+
context: ClaudeParseContext = createClaudeParseContext(),
|
|
55
|
+
isFinalResult = true,
|
|
56
|
+
): readonly SessionViewRow[] {
|
|
14
57
|
let json: unknown;
|
|
15
58
|
try {
|
|
16
59
|
json = JSON.parse(record.data);
|
|
@@ -22,16 +65,24 @@ export function parseClaudeSessionRecord(record: AgentSessionRecord): readonly S
|
|
|
22
65
|
if (!parsed.success) return [rawRecordRow(record, 'Unsupported Claude message')];
|
|
23
66
|
|
|
24
67
|
const message = parsed.data;
|
|
68
|
+
if (
|
|
69
|
+
message.type === 'system' &&
|
|
70
|
+
typeof message.subtype === 'string' &&
|
|
71
|
+
PURE_PROGRESS_CLAUDE_SYSTEM_SUBTYPES.has(message.subtype)
|
|
72
|
+
) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
25
76
|
switch (message.type) {
|
|
26
77
|
case 'system':
|
|
27
78
|
case 'init':
|
|
28
|
-
return [systemRow(record.ts, message)];
|
|
79
|
+
return [systemRow(record.ts, message, context)];
|
|
29
80
|
case 'assistant':
|
|
30
81
|
return assistantRows(record.ts, message);
|
|
31
82
|
case 'user':
|
|
32
83
|
return userRows(record.ts, message);
|
|
33
84
|
case 'result':
|
|
34
|
-
return [resultRow(record.ts, message)];
|
|
85
|
+
return [resultRow(record.ts, message, context.turn, isFinalResult)];
|
|
35
86
|
default:
|
|
36
87
|
return [rawRecordRow(record, `Unknown Claude message: ${message.type}`)];
|
|
37
88
|
}
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import type {SessionViewRow} from '@shipfox/api-logs-dto';
|
|
2
2
|
import type {Harness} from '@shipfox/workflow-document';
|
|
3
|
+
import type {ClaudeParseContext} from './claude-parser.js';
|
|
3
4
|
import {parseClaudeSessionRecord} from './claude-parser.js';
|
|
4
5
|
import {parsePiSessionRecord} from './pi-parser.js';
|
|
5
6
|
import type {AgentSessionRecord} from './session-record.js';
|
|
6
7
|
|
|
8
|
+
export interface SessionParseContext {
|
|
9
|
+
claude?: ClaudeParseContext;
|
|
10
|
+
isFinalResult?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
7
13
|
export function parseSessionRecord(
|
|
8
14
|
record: AgentSessionRecord,
|
|
9
15
|
harness: Harness,
|
|
16
|
+
context?: SessionParseContext,
|
|
10
17
|
): readonly SessionViewRow[] {
|
|
11
18
|
try {
|
|
12
|
-
return harness === 'claude'
|
|
19
|
+
return harness === 'claude'
|
|
20
|
+
? parseClaudeSessionRecord(record, context?.claude, context?.isFinalResult ?? true)
|
|
21
|
+
: parsePiSessionRecord(record);
|
|
13
22
|
} catch {
|
|
14
23
|
return [
|
|
15
24
|
{
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type {SessionViewLifecycleRow} from '@shipfox/api-logs-dto';
|
|
1
2
|
import {uuidv7PrimaryKey} from '@shipfox/node-drizzle';
|
|
2
3
|
import {sql} from 'drizzle-orm';
|
|
3
4
|
import {
|
|
@@ -5,6 +6,7 @@ import {
|
|
|
5
6
|
boolean,
|
|
6
7
|
index,
|
|
7
8
|
integer,
|
|
9
|
+
jsonb,
|
|
8
10
|
text,
|
|
9
11
|
timestamp,
|
|
10
12
|
uniqueIndex,
|
|
@@ -49,6 +51,10 @@ export const attemptStreams = pgTable(
|
|
|
49
51
|
.default('open'),
|
|
50
52
|
closeReason: text('close_reason', {enum: ['declared', 'timeout']}),
|
|
51
53
|
declaredTotalBytes: bigint('declared_total_bytes', {mode: 'number'}),
|
|
54
|
+
claudeHasInit: boolean('claude_has_init').notNull().default(false),
|
|
55
|
+
claudeSessionId: text('claude_session_id'),
|
|
56
|
+
claudeTurn: integer('claude_turn').notNull().default(0),
|
|
57
|
+
claudePendingResult: jsonb('claude_pending_result').$type<SessionViewLifecycleRow>(),
|
|
52
58
|
truncated: boolean('truncated').notNull().default(false),
|
|
53
59
|
objectKey: text('object_key'),
|
|
54
60
|
createdAt: timestamp('created_at', {withTimezone: true}).notNull().defaultNow(),
|
|
@@ -97,6 +103,10 @@ export function toAttemptStream(row: AttemptStreamDb): AttemptStream {
|
|
|
97
103
|
state: row.state,
|
|
98
104
|
closeReason: row.closeReason,
|
|
99
105
|
declaredTotalBytes: row.declaredTotalBytes,
|
|
106
|
+
claudeHasInit: row.claudeHasInit,
|
|
107
|
+
claudeSessionId: row.claudeSessionId,
|
|
108
|
+
claudeTurn: row.claudeTurn,
|
|
109
|
+
claudePendingResult: row.claudePendingResult ?? null,
|
|
100
110
|
truncated: row.truncated,
|
|
101
111
|
objectKey: row.objectKey,
|
|
102
112
|
createdAt: row.createdAt,
|