@catheadowl/dsh-eval 0.2.0 → 0.3.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/src/trace.mjs CHANGED
@@ -1,218 +1,590 @@
1
- /**
2
- * Session-trace parsing for dsh agent eval. The evidence source is the JSONL
3
- * session artifact written by `@deepseek-ai/dsh-session-persistence-jsonl`
4
- * (configured `compression: none`, `packChunks: false` by the eval overlay):
5
- * one `type: 'session'` header line, then one JSON record per `SessionEvent`.
6
- * Event shapes follow `deepseek-harness/packages/core/session/src/types.ts`
7
- * (`SessionEventMap`); packed `*-chunks` storage rows are tolerated and
8
- * skipped — they only carry `assistant/chunk` deltas eval never asserts on.
9
- */
10
-
11
- import { readdirSync, readFileSync } from 'node:fs'
12
- import { join } from 'node:path'
13
-
14
- /** Storage row types that pack `assistant/chunk` delta runs (see chunk-rows.ts). */
15
- const CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
16
-
17
- /**
18
- * Parse one uncompressed JSONL session artifact.
19
- * @param {string} text - the artifact's full text (header line first).
20
- * @returns {{ header: object, events: object[] }} header plus event records in log order.
21
- */
22
- export function parseSessionLog(text) {
23
- const lines = text.split('\n').filter(line => line.trim() !== '')
24
- if (lines.length === 0) throw new Error('empty session log')
25
- const header = JSON.parse(lines[0])
26
- if (header.type !== 'session') throw new Error('first line is not a session header')
27
- const events = []
28
- for (const line of lines.slice(1)) {
29
- let record
30
- try {
31
- record = JSON.parse(line)
32
- } catch {
33
- continue // torn or partial tail line: keep the decodable prefix
34
- }
35
- if (record === null || typeof record !== 'object') continue
36
- if (CHUNK_ROW_TYPES.has(record.type)) continue
37
- events.push(record)
38
- }
39
- return { header, events }
40
- }
41
-
42
- /** Concatenate the text blocks of one assembled assistant message. */
43
- function messageText(message) {
44
- const content = message?.content
45
- if (!Array.isArray(content)) return ''
46
- return content
47
- .filter(block => block?.type === 'text' && typeof block.text === 'string')
48
- .map(block => block.text)
49
- .join('')
50
- }
51
-
52
- /**
53
- * Extract the visible text of one tool-result message. Real messages are
54
- * user-role with a single wrapping `tool-result` block whose `content` holds
55
- * the actual blocks (see `createToolResultMessage` in
56
- * `deepseek-harness/packages/llm/llm/src/message.ts`); a bare block list is
57
- * tolerated for hand-built fixtures.
58
- */
59
- function toolResultText(message) {
60
- const content = message?.content
61
- if (!Array.isArray(content)) return ''
62
- const wrapper = content.find(block => block?.type === 'tool-result')
63
- const inner = wrapper !== undefined ? wrapper.content : content
64
- if (!Array.isArray(inner)) return ''
65
- return inner
66
- .filter(block => block?.type === 'text' && typeof block.text === 'string')
67
- .map(block => block.text)
68
- .join('')
69
- }
70
-
71
- /**
72
- * Extract the `isError` flag from a tool-result message's wrapper block.
73
- * Returns `undefined` when the flag is absent (treated as success by
74
- * matchers — see `toolResultSucceeded`).
75
- */
76
- function toolResultIsError(message) {
77
- const content = message?.content
78
- if (!Array.isArray(content)) return undefined
79
- const wrapper = content.find(block => block?.type === 'tool-result')
80
- return wrapper?.isError
81
- }
82
-
83
- /** Best-effort parse of a tool call's raw JSON arguments string. */
84
- function parseArguments(raw) {
85
- try {
86
- return JSON.parse(raw)
87
- } catch {
88
- return undefined
89
- }
90
- }
91
-
92
- /**
93
- * Build one assertable trace from parsed session logs. Child sessions surface
94
- * only through the parent's tool events, so the MAIN log (no `origin:
95
- * 'subagent'` header) owns the tool/final-text projections; all logs stay
96
- * available under `sessions`.
97
- * @param {{ header: object, events: object[] }[]} logs - parsed session logs.
98
- * @returns {EvalTrace}
99
- */
100
- export function buildTrace(logs) {
101
- const mains = logs.filter(log => log.header.origin !== 'subagent')
102
- const main = [...mains].sort((a, b) => b.events.length - a.events.length)[0]
103
- const events = main?.events ?? []
104
-
105
- const toolCalls = []
106
- const toolResults = []
107
- const assistantTexts = []
108
- const userMessages = []
109
- const requestHeaders = []
110
- for (const event of events) {
111
- if (event.type === 'request/header') {
112
- // The assembled model request header: system prompt + mounted tool
113
- // schemas. What the model is told it can do and how — the "did my
114
- // plugin's section inject?" projection.
115
- requestHeaders.push({
116
- seq: event.seq,
117
- reason: event.data?.reason,
118
- system: event.data?.header?.system ?? '',
119
- toolNames: Array.isArray(event.data?.header?.tools)
120
- ? event.data.header.tools.map(tool => tool?.name).filter(name => typeof name === 'string')
121
- : [],
122
- })
123
- } else if (event.type === 'tool/call') {
124
- toolCalls.push({
125
- seq: event.seq,
126
- turn: event.data.turn,
127
- step: event.data.step,
128
- callId: event.data.callId,
129
- name: event.data.name,
130
- arguments: event.data.arguments,
131
- parsedArguments: parseArguments(event.data.arguments),
132
- })
133
- } else if (event.type === 'tool/result') {
134
- toolResults.push({
135
- seq: event.seq,
136
- turn: event.data.turn,
137
- step: event.data.step,
138
- callId: event.data.message?.source?.callId,
139
- text: toolResultText(event.data.message),
140
- error: event.data.error,
141
- isError: toolResultIsError(event.data.message),
142
- })
143
- } else if (event.type === 'assistant/message') {
144
- const text = messageText(event.data.message)
145
- if (text !== '') assistantTexts.push(text)
146
- } else if (event.type === 'user/message') {
147
- // The user-role model-visible surface: the task prompt (kind 'user'),
148
- // plugin steering, or injected context. `source` tells them apart
149
- // steer has no dedicated event type (the legacy `steering/message` was
150
- // migrated to `user/message`), so the matcher side filters by `source`.
151
- const text = messageText(event.data)
152
- if (text !== '') {
153
- userMessages.push({
154
- seq: event.seq,
155
- source: event.data?.source,
156
- text,
157
- })
158
- }
159
- }
160
- }
161
-
162
- return {
163
- sessions: logs,
164
- sessionId: main?.header.id,
165
- toolCalls,
166
- toolResults,
167
- assistantTexts,
168
- userMessages,
169
- requestHeaders,
170
- finalText: assistantTexts.at(-1) ?? '',
171
- }
172
- }
173
-
174
- /** Recursively collect files named `name` under `dir`. */
175
- function collectFiles(dir, name, out = []) {
176
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
177
- const path = join(dir, entry.name)
178
- if (entry.isDirectory()) collectFiles(path, name, out)
179
- else if (entry.name === name) out.push(path)
180
- }
181
- return out
182
- }
183
-
184
- /**
185
- * Load every session log under a persistence root and build one trace.
186
- * @param {string} sessionsRoot - the run's `session-persistence-jsonl` root.
187
- * @returns {EvalTrace | undefined} the trace, or `undefined` when no log materialized.
188
- */
189
- export function loadTraceDir(sessionsRoot) {
190
- let files
191
- try {
192
- files = collectFiles(sessionsRoot, 'session.jsonl')
193
- } catch {
194
- return undefined
195
- }
196
- if (files.length === 0) return undefined
197
- const logs = files
198
- .map(file => readFileSync(file, 'utf8'))
199
- .map(parseSessionLog)
200
- return buildTrace(logs)
201
- }
202
-
203
- /**
204
- * @typedef {object} EvalTrace
205
- * @property {{ header: object, events: object[] }[]} sessions - every parsed log.
206
- * @property {string | undefined} sessionId - the main session's id.
207
- * @property {{ seq: number, turn: number, step: number, callId: string, name: string, arguments: string, parsedArguments: unknown }[]} toolCalls
208
- * @property {{ seq: number, turn: number, step: number, callId: string, text: string, error: object | undefined, isError: boolean | undefined }[]} toolResults
209
- * @property {string[]} assistantTexts - non-empty assembled assistant messages, log order.
210
- * @property {{ seq: number, source: object, text: string }[]} userMessages
211
- * - non-empty `user/message` events (task prompt, plugin steer, injected
212
- * context) with their verbatim `source` (`kind` + plugin-specific fields),
213
- * in log order. Steer has no dedicated event type; matchers filter by
214
- * `source`.
215
- * @property {{ seq: number, reason: string, system: string, toolNames: string[] }[]} requestHeaders
216
- * - projected `request/header` events (assembled system prompt + mounted tools).
217
- * @property {string} finalText - the last assembled assistant text ('' when none).
218
- */
1
+ /**
2
+ * Session-trace parsing for dsh agent eval. The evidence source is the JSONL
3
+ * session artifact written by `@deepseek-ai/dsh-session-persistence-jsonl`
4
+ * (configured `compression: none`, `packChunks: false` by the eval overlay):
5
+ * one `type: 'session'` header line, then one JSON record per `SessionEvent`.
6
+ * Event shapes follow `deepseek-harness/packages/core/session/src/types.ts`
7
+ * (`SessionEventMap`); packed `*-chunks` storage rows are tolerated and
8
+ * skipped — they only carry `assistant/chunk` deltas eval never asserts on.
9
+ *
10
+ * Both seam directions carry an explicit boundary: an artifact whose header
11
+ * stamp is not a known generation is refused here (`parseSessionLog`), and a
12
+ * collection that finds no artifact yields a named diagnosis rather than an
13
+ * unexplained `undefined` (`collectSessionTrace`). See docs/host-wiring.md.
14
+ */
15
+
16
+ import { readdirSync, readFileSync } from 'node:fs'
17
+ import { basename, join } from 'node:path'
18
+
19
+ /** Storage row types that pack `assistant/chunk` delta runs (see chunk-rows.ts). */
20
+ const CHUNK_ROW_TYPES = new Set(['text-chunks', 'reasoning-chunks', 'tool-call-chunks'])
21
+
22
+ /** Projection field name per projected event type. */
23
+ const PROJECTED_FIELD_BY_EVENT_TYPE = new Map([
24
+ ['tool/call', 'toolCalls'],
25
+ ['tool/result', 'toolResults'],
26
+ ['assistant/message', 'assistantTexts'],
27
+ ['user/message', 'userMessages'],
28
+ ['request/header', 'requestHeaders'],
29
+ ])
30
+
31
+ /**
32
+ * Session format generations this parser accepts. Mirror of the generation
33
+ * chain the vendored host ships codecs for
34
+ * (`session-format-catalog/src/generated.ts`: codecs v0–v3,
35
+ * `currentVersion: 3` = `SESSION_FORMAT_VERSION` in
36
+ * `core/session/src/types.ts`). The projection is written and verified
37
+ * against the current generation; older ones parse tolerantly. A stamp
38
+ * outside this set means the host moved to a generation whose payload this
39
+ * projection was never verified against — fail at the seam instead of
40
+ * projecting empty fields, and bump this set only together with the
41
+ * re-verification the docs' maintenance trigger describes.
42
+ */
43
+ export const KNOWN_SESSION_FORMAT_VERSIONS = new Set([0, 1, 2, 3])
44
+
45
+ /** Known generations rendered for an error message: `v0, v1, v2, v3`. */
46
+ function knownGenerationsLabel() {
47
+ return [...KNOWN_SESSION_FORMAT_VERSIONS].sort((a, b) => a - b).map(version => `v${version}`).join(', ')
48
+ }
49
+
50
+ /** One header version stamp, rendered compactly for a diagnostic. */
51
+ function headerVersionLabel(version) {
52
+ if (typeof version === 'number') return `v${version}`
53
+ return `(${JSON.stringify(version ?? null)})`
54
+ }
55
+
56
+ /**
57
+ * Parse one uncompressed JSONL session artifact.
58
+ * @param {string} text - the artifact's full text (header line first).
59
+ * @returns {{ header: object, events: object[] }} header plus event records in log order.
60
+ */
61
+ export function parseSessionLog(text) {
62
+ const lines = text.split('\n').filter(line => line.trim() !== '')
63
+ if (lines.length === 0) throw new Error('empty session log')
64
+ const header = JSON.parse(lines[0])
65
+ if (header.type !== 'session') throw new Error('first line is not a session header')
66
+ // Generation gate: the header stamp is the host's own declaration of the
67
+ // artifact's logical layout. An unknown one is a seam drift, not a parse
68
+ // detail — say so here rather than degrade every projection to empty.
69
+ if (!KNOWN_SESSION_FORMAT_VERSIONS.has(header.version)) {
70
+ throw new Error(
71
+ `session header version ${headerVersionLabel(header.version)} is not a known generation`
72
+ + ` (known: ${knownGenerationsLabel()}); the host session format may have changed generation`,
73
+ )
74
+ }
75
+ const events = []
76
+ for (const line of lines.slice(1)) {
77
+ let record
78
+ try {
79
+ record = JSON.parse(line)
80
+ } catch {
81
+ continue // torn or partial tail line: keep the decodable prefix
82
+ }
83
+ if (record === null || typeof record !== 'object') continue
84
+ if (CHUNK_ROW_TYPES.has(record.type)) continue
85
+ events.push(record)
86
+ }
87
+ return { header, events }
88
+ }
89
+
90
+ /** Concatenate the text blocks of one assembled assistant message. */
91
+ function messageText(message) {
92
+ const content = message?.content
93
+ if (!Array.isArray(content)) return ''
94
+ return content
95
+ .filter(block => block?.type === 'text' && typeof block.text === 'string')
96
+ .map(block => block.text)
97
+ .join('')
98
+ }
99
+
100
+ /**
101
+ * Extract the visible text of one tool-result message. Real messages are
102
+ * user-role with a single wrapping `tool-result` block whose `content` holds
103
+ * the actual blocks (see `createToolResultMessage` in
104
+ * `deepseek-harness/packages/llm/llm/src/message.ts`); a bare block list is
105
+ * tolerated for hand-built fixtures.
106
+ */
107
+ function toolResultText(message) {
108
+ const content = message?.content
109
+ if (!Array.isArray(content)) return ''
110
+ const wrapper = content.find(block => block?.type === 'tool-result')
111
+ const inner = wrapper !== undefined ? wrapper.content : content
112
+ if (!Array.isArray(inner)) return ''
113
+ return inner
114
+ .filter(block => block?.type === 'text' && typeof block.text === 'string')
115
+ .map(block => block.text)
116
+ .join('')
117
+ }
118
+
119
+ /**
120
+ * Extract the `isError` flag from a tool-result message's wrapper block.
121
+ * Returns `undefined` when the flag is absent (treated as success by
122
+ * matchers — see `toolResultSucceeded`).
123
+ */
124
+ function toolResultIsError(message) {
125
+ const content = message?.content
126
+ if (!Array.isArray(content)) return undefined
127
+ const wrapper = content.find(block => block?.type === 'tool-result')
128
+ return wrapper?.isError
129
+ }
130
+
131
+ /** Best-effort parse of a tool call's raw JSON arguments string. */
132
+ function parseArguments(raw) {
133
+ try {
134
+ return JSON.parse(raw)
135
+ } catch {
136
+ return undefined
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Fold one child log's descriptor events exactly once: the identity
142
+ * `projectChild` asserts on, plus the counts the census reports. Single source
143
+ * on purpose `projectChild` and `censusForChild` must agree on which
144
+ * descriptor established the identity, and the "supported" predicate must match
145
+ * the fold (a log whose only descriptors are unsupported yields both an empty
146
+ * identity and `supportedDescriptors: 0`, which is the census signal).
147
+ * @param {{ events: object[] }} log - one parsed child log.
148
+ * @returns {{ label: string | undefined, mode: string | undefined, provider: string | undefined, descriptorEvents: number, supportedDescriptors: number }}
149
+ */
150
+ function foldChildDescriptor(log) {
151
+ let label
152
+ let mode
153
+ let provider
154
+ let descriptorEvents = 0
155
+ let supportedDescriptors = 0
156
+ for (const event of log.events) {
157
+ if (event.type !== 'subagent/descriptor') continue
158
+ descriptorEvents += 1
159
+ const data = event.data
160
+ if (data === null || typeof data !== 'object') continue
161
+ if (data.version === 3) supportedDescriptors += 1
162
+ if (label !== undefined || mode !== undefined || provider !== undefined) continue
163
+ if (data.version !== 3) continue
164
+ if (typeof data.label === 'string') label = data.label
165
+ if (typeof data.mode === 'string') mode = data.mode
166
+ if (typeof data.provider === 'string') provider = data.provider
167
+ }
168
+ return { label, mode, provider, descriptorEvents, supportedDescriptors }
169
+ }
170
+
171
+ /**
172
+ * Project one subagent child log into an assertable record. The durable
173
+ * identity (`label` / `mode` / `provider`) comes from the FIRST
174
+ * `subagent/descriptor` event whose payload carries the descriptor version
175
+ * this projection supports (3) — mirroring `foldSubagentDescriptor` in
176
+ * `deepseek-harness/packages/subagent/subagent/src/descriptor.ts`, where the
177
+ * establishing provider appends exactly one authoritative descriptor and
178
+ * later events cannot rewrite it. Completion is the child's own last
179
+ * assistant text a child that produced none may have been dispatched but
180
+ * never ran to an answer (turn/end reasons are not consulted).
181
+ */
182
+ function projectChild(log) {
183
+ const { label, mode, provider } = foldChildDescriptor(log)
184
+ const assistantTexts = log.events
185
+ .filter(event => event.type === 'assistant/message')
186
+ .map(event => messageText(event.data.message))
187
+ .filter(text => text !== '')
188
+ return {
189
+ sessionId: log.header.id,
190
+ parentSession: log.header.parentSession,
191
+ delegationDepth: log.header.delegationDepth,
192
+ label,
193
+ mode,
194
+ provider,
195
+ assistantTexts,
196
+ finalText: assistantTexts.at(-1) ?? '',
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Count events by type, plug-in event types included (the only live registry is
202
+ * `SessionEventMap`, so no closed list exists).
203
+ * @param {object[]} events - parsed event records.
204
+ * @returns {Record<string, number>} count per event type, insertion-ordered.
205
+ */
206
+ function countEventTypes(events) {
207
+ // Null-prototype accumulator: a plug-in event type may name an
208
+ // `Object.prototype` member (`constructor`, `toString`, `__proto__`), and
209
+ // `counts['constructor'] ?? 0` would otherwise read the inherited function
210
+ // and string-concatenate, while `__proto__` would be swallowed by its setter.
211
+ const counts = Object.create(null)
212
+ for (const event of events) {
213
+ if (typeof event?.type !== 'string') continue
214
+ counts[event.type] = (counts[event.type] ?? 0) + 1
215
+ }
216
+ return counts
217
+ }
218
+
219
+ /**
220
+ * Census for one candidate child log: whether its `subagent/descriptor`
221
+ * events exist, how many, and how many carry the supported descriptor
222
+ * version, plus the folded identity itself. `label` is the field the
223
+ * subagent-count matchers key on, so `label: undefined` with a non-zero
224
+ * `descriptorEvents` is exactly the shape whose `*Count(label, 0)` assertion
225
+ * is green only because there was nothing to match — the identity-loss
226
+ * degradation the census exists to make visible.
227
+ * @param {{ header: object, events: object[] }} log - one parsed child candidate.
228
+ * @returns {{ sessionId: string | undefined, parentSession: string | undefined, delegationDepth: number | undefined, descriptorEvents: number, supportedDescriptors: number, label: string | undefined, mode: string | undefined, provider: string | undefined }}
229
+ */
230
+ function censusForChild(log) {
231
+ return {
232
+ sessionId: log.header.id,
233
+ parentSession: log.header.parentSession,
234
+ delegationDepth: log.header.delegationDepth,
235
+ ...foldChildDescriptor(log),
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Projection census for one built trace (see {@link EvalTrace.census}).
241
+ * Numbers only: this reports what the raw logs contained against what the
242
+ * projections kept, and never decides whether the difference is a defect.
243
+ *
244
+ * Three signals, because one number cannot cover three shapes: length
245
+ * differences (`projectionSkipped.main`) see records the projection dropped,
246
+ * `projectionFieldGaps` sees records it kept while a field went missing
247
+ * (`tool/call` and friends project 1:1, so their count − length is structurally
248
+ * zero), and the subagent block sees the child-log data source, which no main-log
249
+ * count can reach.
250
+ *
251
+ * @param {object[]} events - the MAIN log's events (the projection input).
252
+ * @param {EvalTrace} trace - the built trace, read for projection lengths.
253
+ * @param {object[]} childLogs - candidate child logs entering `subagentChildren`.
254
+ * @param {Record<string, number>} projectionFieldGaps - gaps the projection loop
255
+ * recorded while reading fields, keyed by what was missing.
256
+ * @returns {object} the census record.
257
+ */
258
+ function buildCensus(events, trace, childLogs, projectionFieldGaps) {
259
+ const eventTypeCounts = countEventTypes(events)
260
+ const projectionLengths = {
261
+ toolCalls: trace.toolCalls.length,
262
+ toolResults: trace.toolResults.length,
263
+ assistantTexts: trace.assistantTexts.length,
264
+ userMessages: trace.userMessages.length,
265
+ requestHeaders: trace.requestHeaders.length,
266
+ }
267
+ const projectionSkipped = { main: {}, children: {} }
268
+ for (const [type, field] of PROJECTED_FIELD_BY_EVENT_TYPE) {
269
+ const missing = (eventTypeCounts[type] ?? 0) - projectionLengths[field]
270
+ if (missing > 0) projectionSkipped.main[field] = missing
271
+ }
272
+ const children = childLogs.map(censusForChild)
273
+ const supportedDescriptors = children.reduce((total, child) => total + child.supportedDescriptors, 0)
274
+ // Two degradation shapes, different signals: `withoutIdentity` is a child
275
+ // that folded no identity at all; `withoutLabel` is one that folded some
276
+ // identity but no label — the only field the `subagent*Count` matchers can
277
+ // match on, so its zero-count assertions are the vacuous ones.
278
+ //
279
+ // The child set is the parentSession heuristic (any log whose header carries
280
+ // `parentSession`, which the host also writes for fork/resume/seed logs), so a
281
+ // non-subagent fork log shows up here as an identity-less child. The census
282
+ // reports the set it was given; it cannot re-derive the host's agent-chain
283
+ // ownership check from a log alone.
284
+ const withoutIdentity = children.filter(
285
+ child => child.label === undefined && child.mode === undefined && child.provider === undefined,
286
+ ).length
287
+ const withoutLabel = children.filter(child => child.label === undefined).length
288
+ if (withoutIdentity > 0) projectionSkipped.children.withoutIdentity = withoutIdentity
289
+ if (withoutLabel > 0) projectionSkipped.children.withoutLabel = withoutLabel
290
+ return {
291
+ eventTypeCounts,
292
+ projectionLengths,
293
+ projectionSkipped,
294
+ projectionFieldGaps,
295
+ subagent: {
296
+ mainLogDescriptorEvents: eventTypeCounts['subagent/descriptor'] ?? 0,
297
+ supportedDescriptors,
298
+ children,
299
+ },
300
+ }
301
+ }
302
+
303
+ /**
304
+ * Build one assertable trace from parsed session logs. Child sessions surface
305
+ * only through the parent's tool events, so the MAIN log (no `origin:
306
+ * 'subagent'` header) owns the tool/final-text projections; subagent children
307
+ * project separately under `subagentChildren`; all logs stay available under
308
+ * `sessions`. A log carrying `parentSession` without `origin: 'subagent'`
309
+ * (non-subagent fork/resume shape) counts as a child record here but remains
310
+ * a main candidate too — the host-side ownership check walks the agent chain,
311
+ * which the log alone cannot reproduce.
312
+ * @param {{ header: object, events: object[] }[]} logs - parsed session logs.
313
+ * @returns {EvalTrace}
314
+ */
315
+ export function buildTrace(logs) {
316
+ const mains = logs.filter(log => log.header.origin !== 'subagent')
317
+ const main = [...mains].sort((a, b) => b.events.length - a.events.length)[0]
318
+ const events = main?.events ?? []
319
+ const childLogs = logs
320
+ .filter(log => log.header.origin === 'subagent' || log.header.parentSession !== undefined)
321
+ const subagentChildren = childLogs.map(projectChild)
322
+
323
+ const toolCalls = []
324
+ const toolResults = []
325
+ const assistantEntries = []
326
+ const userMessages = []
327
+ const requestHeaders = []
328
+ const gaps = {}
329
+ const recordGap = key => { gaps[key] = (gaps[key] ?? 0) + 1 }
330
+ for (const event of events) {
331
+ if (event.type === 'request/header') {
332
+ // The assembled model request header: system prompt + mounted tool
333
+ // schemas. What the model is told it can do and how — the "did my
334
+ // plugin's section inject?" projection.
335
+ if (typeof event.data?.header?.system !== 'string') recordGap('headerWithoutSystem')
336
+ if (Array.isArray(event.data?.header?.tools)
337
+ && !event.data.header.tools.some(tool => typeof tool?.name === 'string')) {
338
+ recordGap('headerWithoutToolNames')
339
+ }
340
+ requestHeaders.push({
341
+ seq: event.seq,
342
+ reason: event.data?.reason,
343
+ system: event.data?.header?.system ?? '',
344
+ toolNames: Array.isArray(event.data?.header?.tools)
345
+ ? event.data.header.tools.map(tool => tool?.name).filter(name => typeof name === 'string')
346
+ : [],
347
+ })
348
+ } else if (event.type === 'tool/call') {
349
+ if (typeof event.data?.name !== 'string') recordGap('toolCallWithoutName')
350
+ if (typeof event.data?.callId !== 'string') recordGap('toolCallWithoutCallId')
351
+ toolCalls.push({
352
+ seq: event.seq,
353
+ turn: event.data.turn,
354
+ step: event.data.step,
355
+ callId: event.data.callId,
356
+ name: event.data.name,
357
+ arguments: event.data.arguments,
358
+ parsedArguments: parseArguments(event.data.arguments),
359
+ })
360
+ } else if (event.type === 'tool/result') {
361
+ if (typeof event.data?.message?.source?.callId !== 'string') recordGap('toolResultWithoutCallId')
362
+ toolResults.push({
363
+ seq: event.seq,
364
+ turn: event.data.turn,
365
+ step: event.data.step,
366
+ callId: event.data.message?.source?.callId,
367
+ text: toolResultText(event.data.message),
368
+ error: event.data.error,
369
+ isError: toolResultIsError(event.data.message),
370
+ })
371
+ } else if (event.type === 'assistant/message') {
372
+ const text = messageText(event.data.message)
373
+ if (text !== '') assistantEntries.push({ seq: event.seq, text })
374
+ } else if (event.type === 'user/message') {
375
+ // The user-role model-visible surface: the task prompt (kind 'user'),
376
+ // plugin steering, or injected context. `source` tells them apart —
377
+ // steer has no dedicated event type (the legacy `steering/message` was
378
+ // migrated to `user/message`), so the matcher side filters by `source`.
379
+ const text = messageText(event.data)
380
+ if (text !== '') {
381
+ userMessages.push({
382
+ seq: event.seq,
383
+ source: event.data?.source,
384
+ text,
385
+ })
386
+ }
387
+ }
388
+ }
389
+
390
+ const assistantTexts = assistantEntries.map(entry => entry.text)
391
+ // The answer to the task, as opposed to the last message: once a
392
+ // plugin-sourced injection (kind 'plugin' — a turn-close gate splice, an
393
+ // infra complaint) enters the conversation, every assistant message after
394
+ // it responds to the injection, not to the task. The answer is therefore
395
+ // the last assistant text BEFORE the first plugin injection; without one
396
+ // it degenerates to finalText (the task was the reviewer's last business).
397
+ const firstInjectionSeq = userMessages.find(
398
+ message => message.source?.kind === 'plugin',
399
+ )?.seq
400
+ const answerEntries = firstInjectionSeq === undefined
401
+ ? assistantEntries
402
+ : assistantEntries.filter(entry => entry.seq < firstInjectionSeq)
403
+ const answerText = answerEntries.at(-1)?.text ?? ''
404
+
405
+ const result = {
406
+ sessions: logs,
407
+ sessionId: main?.header.id,
408
+ toolCalls,
409
+ toolResults,
410
+ assistantTexts,
411
+ answerText,
412
+ userMessages,
413
+ requestHeaders,
414
+ subagentChildren,
415
+ finalText: assistantTexts.at(-1) ?? '',
416
+ census: undefined,
417
+ }
418
+ result.census = buildCensus(events, result, childLogs, gaps)
419
+ return result
420
+ }
421
+
422
+ /**
423
+ * Session artifact basenames: format v0 keeps `session.jsonl`, every later
424
+ * generation carries a `vN` component (`session.v3.jsonl` — the host's
425
+ * `generationLogFilename`). Matching the v0 name alone finds no trace at all
426
+ * once the host bumps the format, which surfaces as "no session trace
427
+ * materialized" rather than as a parse error.
428
+ */
429
+ const SESSION_LOG_FILENAME = /^session(?:\.v\d+)?\.jsonl$/u
430
+
431
+ /**
432
+ * Whether one file basename is a session JSONL artifact of any format generation.
433
+ * @param {string} name - the file basename to test.
434
+ * @returns {boolean} true for `session.jsonl` and `session.vN.jsonl`.
435
+ */
436
+ export function isSessionLogFilename(name) {
437
+ return SESSION_LOG_FILENAME.test(name)
438
+ }
439
+
440
+ /** Recursively list files under `dir`; an unreadable directory contributes nothing. */
441
+ function listFiles(dir, out = []) {
442
+ let entries
443
+ try {
444
+ entries = readdirSync(dir, { withFileTypes: true })
445
+ } catch {
446
+ return out
447
+ }
448
+ for (const entry of entries) {
449
+ const path = join(dir, entry.name)
450
+ if (entry.isDirectory()) listFiles(path, out)
451
+ else out.push(path)
452
+ }
453
+ return out
454
+ }
455
+
456
+ /**
457
+ * Every session artifact under `sessionsRoot` (any generation, see
458
+ * `isSessionLogFilename`) as absolute paths — the collection half of the
459
+ * seam, shared by the trace builder and by the raw-log capture the behavior
460
+ * runner does before cleanup.
461
+ * @param {string} sessionsRoot - the run's `session-persistence-jsonl` root.
462
+ * @returns {string[]} artifact paths, in directory order.
463
+ */
464
+ export function listSessionLogFiles(sessionsRoot) {
465
+ return listFiles(sessionsRoot).filter(path => isSessionLogFilename(basename(path)))
466
+ }
467
+
468
+ /** Most candidate names one gap diagnostic lists before it truncates. */
469
+ const GAP_NAME_LIMIT = 10
470
+
471
+ /**
472
+ * Why a collection produced no artifact, phrased for a failure message: the
473
+ * candidate names actually seen (a renamed artifact is the likeliest host
474
+ * drift) plus the generation suspicion. Never returns an empty string — an
475
+ * empty root is itself the fact to report.
476
+ */
477
+ function traceGapMessage(sessionsRoot, files) {
478
+ const names = [...new Set(files.map(file => basename(file)))]
479
+ const lookalikes = names.filter(name => name.toLowerCase().startsWith('session'))
480
+ const pool = lookalikes.length > 0 ? lookalikes : names
481
+ const shown = pool.slice(0, GAP_NAME_LIMIT)
482
+ const rest = pool.length - shown.length
483
+ const scan = shown.length === 0
484
+ ? 'the root holds no files (missing or empty)'
485
+ : `${lookalikes.length > 0 ? 'session-like file(s)' : 'file(s)'} under it: `
486
+ + `${shown.join(', ')}${rest > 0 ? ` (+${rest} more)` : ''}`
487
+ return 'no session trace materialized: no session artifact'
488
+ + ` (session.jsonl / session.vN.jsonl) under '${sessionsRoot}' — ${scan}`
489
+ + '; the host artifact naming may have changed generation'
490
+ }
491
+
492
+ /**
493
+ * Collect one run's session trace and, when there is none, the seam
494
+ * diagnosis for it.
495
+ *
496
+ * The `gap` string exists so that "the host's artifact/session layout moved"
497
+ * surfaces as that sentence in the behavior runner's failure text and in the
498
+ * review adapter's accounting, instead of as a bare `undefined` the reader
499
+ * has to trace back through the parser (see docs/host-wiring.md).
500
+ *
501
+ * @param {string} sessionsRoot - the run's `session-persistence-jsonl` root.
502
+ * @returns {{ trace: EvalTrace | undefined, gap: string | undefined }} the
503
+ * trace, or `undefined` plus the reason no trace could be built.
504
+ */
505
+ export function collectSessionTrace(sessionsRoot) {
506
+ const files = listFiles(sessionsRoot)
507
+ const artifacts = files.filter(file => isSessionLogFilename(basename(file)))
508
+ if (artifacts.length === 0) {
509
+ return { trace: undefined, gap: traceGapMessage(sessionsRoot, files) }
510
+ }
511
+ const logs = []
512
+ const broken = []
513
+ for (const artifact of artifacts) {
514
+ try {
515
+ logs.push(parseSessionLog(readFileSync(artifact, 'utf8')))
516
+ } catch (error) {
517
+ broken.push(`${basename(artifact)}: ${error instanceof Error ? error.message : String(error)}`)
518
+ }
519
+ }
520
+ if (broken.length > 0) {
521
+ return {
522
+ trace: undefined,
523
+ gap: `session artifact(s) failed to parse — ${broken.join('; ')}`
524
+ + '; the host session format may have changed generation',
525
+ }
526
+ }
527
+ return { trace: buildTrace(logs), gap: undefined }
528
+ }
529
+
530
+ /**
531
+ * @typedef {object} EvalTrace
532
+ * @property {{ header: object, events: object[] }[]} sessions - every parsed log.
533
+ * @property {string | undefined} sessionId - the main session's id.
534
+ * @property {{ seq: number, turn: number, step: number, callId: string, name: string, arguments: string, parsedArguments: unknown }[]} toolCalls
535
+ * @property {{ seq: number, turn: number, step: number, callId: string, text: string, error: object | undefined, isError: boolean | undefined }[]} toolResults
536
+ * @property {string[]} assistantTexts - non-empty assembled assistant messages, log order.
537
+ * @property {string} answerText - the last assistant text BEFORE the first
538
+ * plugin-sourced user message (gate splice / injected complaint); equals
539
+ * finalText when no plugin injection intervened ('' when none at all).
540
+ * The "answer to the task", as opposed to the possibly-hijacked last message.
541
+ * @property {{ seq: number, source: object, text: string }[]} userMessages
542
+ * - non-empty `user/message` events (task prompt, plugin steer, injected
543
+ * context) with their verbatim `source` (`kind` + plugin-specific fields),
544
+ * in log order. Steer has no dedicated event type; matchers filter by
545
+ * `source`.
546
+ * @property {{ seq: number, reason: string, system: string, toolNames: string[] }[]} requestHeaders
547
+ * - projected `request/header` events (assembled system prompt + mounted tools).
548
+ * @property {{ sessionId: string | undefined, parentSession: string | undefined, delegationDepth: number | undefined, label: string | undefined, mode: string | undefined, provider: string | undefined, assistantTexts: string[], finalText: string }[]} subagentChildren
549
+ * - one record per subagent child log (`origin: 'subagent'` header, or a
550
+ * header carrying `parentSession`). Identity comes from the first
551
+ * version-3 `subagent/descriptor` event; `finalText` is the child's own
552
+ * last assistant text ('' when it produced none — dispatched but not
553
+ * answered).
554
+ * @property {string} finalText - the last assembled assistant text ('' when none).
555
+ * @property {object | undefined} census - what the raw logs contained against
556
+ * what the projections kept (numbers only, never a verdict). Two data
557
+ * sources: `eventTypeCounts` counts the MAIN log's events by type (any type,
558
+ * plug-in ones included), and `subagent` censuses the child logs that enter
559
+ * `subagentChildren` (their `subagent/descriptor` event counts and how many
560
+ * carry the supported `version === 3`). `projectionLengths` are the five
561
+ * main-log projections' lengths after empty-text drops; `projectionSkipped`
562
+ * records where count minus length is positive, per projection, plus the two
563
+ * child-identity counters. Undefined only on a hand-built trace; a nested log
564
+ * under `sessions` carries none because the parsers never add one.
565
+ * @property {Record<string, number>} census.projectionFieldGaps
566
+ * - events the projection kept while a field it reads went missing, keyed by
567
+ * what was missing (`toolCallWithoutName`, `toolCallWithoutCallId`,
568
+ * `toolResultWithoutCallId`, `headerWithoutSystem`, `headerWithoutToolNames`).
569
+ * This is the 1:1-projection signal: for `tool/call`, `tool/result` and
570
+ * `request/header`, count − length is structurally zero, so a moved field
571
+ * shows up only here. An absent `request/header.tools` array is NOT counted
572
+ * (it projects to the same empty list as an empty one).
573
+ * @property {{ mainLogDescriptorEvents: number, supportedDescriptors: number, children: object[] }} census.subagent
574
+ * - the child-log data source the main-log counts cannot reach.
575
+ * `mainLogDescriptorEvents` counts `subagent/descriptor` events in the MAIN
576
+ * log itself (the current host writes them into the child log, so this is
577
+ * usually 0); `supportedDescriptors` sums the per-child counts below, i.e.
578
+ * it counts DESCRIPTOR EVENTS, not child sessions — one child may fold its
579
+ * identity from a single descriptor while logging several.
580
+ * @property {{ sessionId: string | undefined, parentSession: string | undefined, delegationDepth: number | undefined, descriptorEvents: number, supportedDescriptors: number, label: string | undefined, mode: string | undefined, provider: string | undefined }[]} census.subagent.children
581
+ * - the accepted child logs, each with the identity `projectChild` folded
582
+ * from them; `descriptorEvents === 0` means the log states no identity at
583
+ * all, and a non-zero count with `supportedDescriptors === 0` means every
584
+ * descriptor was outside the supported version.
585
+ * @property {Record<string, number>} census.projectionSkipped.children
586
+ * - `withoutIdentity` counts children that folded no identity field at all;
587
+ * `withoutLabel` counts children whose `label` is absent — the field the
588
+ * `subagent*Count` matchers match on, so those are the records whose
589
+ * zero-count assertions pass only because there was nothing to match.
590
+ */