diff --git a/packages/coding-agent/docs/invocation-traces.md b/packages/coding-agent/docs/invocation-traces.md index 55c43998d..db5c69aa2 100644 --- a/packages/coding-agent/docs/invocation-traces.md +++ b/packages/coding-agent/docs/invocation-traces.md @@ -26,3 +26,43 @@ Moving or renaming a session does not rewrite existing trace references. This preserves invocation evidence while allowing readers to report that referenced session content is missing or moved. Trace files contain execution telemetry and immutable session entry references, not duplicated transcript content. + +## Evidence Ownership + +Each fact has one authoritative source so a trace remains compact and materialization does not create competing copies. + +| Source | Authoritative facts | Materialized form | +| --- | --- | --- | +| Coding-agent v3 session JSONL | Messages, assistant tool calls, tool results, message usage, model and thinking changes, compaction and branch summaries, custom extension entries, labels, and session metadata | `session_item` evidence selected by entry ordinal, entry ID, and digest | +| AgentHarness session JSONL | Durable entries, operation admission and settlement, abort requests, retry attempts, tool admission, queue changes, deferred writes, usage, lane pointers, and session facts | `session_item` evidence selected by durable session sequence | +| Agent and AI telemetry spans | Agent runs, turns, logical provider requests, provider outcomes, tool execution outcomes, usage, and causal invocation, turn, request, and tool-call identifiers | Consolidated `span` evidence | +| Coding-agent and extension telemetry spans | User-visible operations, retries and sleeps, extension hook outcomes, subagent coordination, terminal commands, external artifact references, and runtime diagnostics | Consolidated `span` evidence | +| Trace lifecycle records | Producer identity, session bindings, harness activation, session segments, trace writer degradation, and completed, failed, or aborted process termination | `trace_record` evidence and document diagnostics | +| Missing trace footer | A process that did not close its trace orderly | A `crashed` materialized termination status with a missing-footer diagnostic | + +Agent and extension events remain live notification APIs. +Their durable facts are represented by the owning session entry, session record, or telemetry span listed above rather than by copying event payloads into the trace. + +Startup and configuration failures are recorded as runtime diagnostic spans after trace creation. +Failures that prevent trace creation remain an explicit limitation because no durable trace destination exists. + +## Materialization + +`materializeJsonlInvocation()` reads and validates a trace, resolves every referenced session binding, verifies each segment boundary, and returns a versioned `MaterializedInvocation` document. +The optional session path resolver can locate a session that moved after the trace was written while preserving both its recorded and resolved paths. + +Coding-agent v3 content digests are SHA-256 hashes of `JSON.stringify(entry)`. +Prefix digests are SHA-256 hashes of the LF-framed canonical entry JSON from the first non-header entry through the referenced entry. +AgentHarness segments use inclusive durable session sequence bounds. + +The materializer embeds only session items referenced by trace segments. +It preserves message and tool payloads exactly as stored in the session, combines span starts, events, and settlements, and retains every contributing trace record ID, span ID, session binding ID, session entry ID, and session sequence. + +Evidence is ordered deterministically by trace sequence, session cursor, timestamp, span parentage, and causal identifiers. +Session cursor order is durable inside a segment, but the document reports ordering ambiguity when clocks and segment boundaries cannot establish a total order with trace records. + +Missing, changed, truncated, or incompatible session data produces diagnostics without discarding valid trace records or earlier session evidence. +Writer-degraded and crashed traces likewise produce partial documents instead of failing materialization. + +`serializeMaterializedInvocation()` emits stable, indented JSON with a trailing newline for storage or bounded LLM analysis. +The materialized document contains evidence only and does not include extracted claims, evolution proposals, approval state, or behavior mutations. diff --git a/packages/harness-trace/src/evidence-utils.ts b/packages/harness-trace/src/evidence-utils.ts new file mode 100644 index 000000000..54ca194a9 --- /dev/null +++ b/packages/harness-trace/src/evidence-utils.ts @@ -0,0 +1,26 @@ +import type { MaterializedCausalIdentifier } from "./materialized-types.ts"; + +function isIdentifierName(name: string): boolean { + return name === "id" || name.endsWith("Id") || name.endsWith(".id") || name.endsWith("_id"); +} + +function collectCausalIdentifiers(value: unknown, prefix = "", result = new Map()): void { + if (Array.isArray(value)) { + for (const item of value) collectCausalIdentifiers(item, prefix, result); + return; + } + if (typeof value !== "object" || value === null) return; + for (const [name, item] of Object.entries(value)) { + const qualifiedName = prefix ? `${prefix}.${name}` : name; + if (typeof item === "string" && isIdentifierName(name)) result.set(`${qualifiedName}\0${item}`, item); + else collectCausalIdentifiers(item, qualifiedName, result); + } +} + +export function causalIdentifiers(...values: readonly unknown[]): readonly MaterializedCausalIdentifier[] { + const identifiers = new Map(); + for (const value of values) collectCausalIdentifiers(value, "", identifiers); + return [...identifiers.entries()] + .map(([key, value]) => ({ name: key.slice(0, key.indexOf("\0")), value })) + .sort((left, right) => left.name.localeCompare(right.name) || left.value.localeCompare(right.value)); +} diff --git a/packages/harness-trace/src/index.ts b/packages/harness-trace/src/index.ts index bf68e5fd5..b320cd6e7 100644 --- a/packages/harness-trace/src/index.ts +++ b/packages/harness-trace/src/index.ts @@ -1,4 +1,26 @@ export { encodeTraceRecord, parseTraceRecord } from "./codec.ts"; +export { + MATERIALIZED_INVOCATION_VERSION, + type MaterializationDiagnostic, + type MaterializationDiagnosticCode, + type MaterializationIntegrity, + type MaterializedCausalIdentifier, + type MaterializedEvidence, + type MaterializedEvidenceOrder, + type MaterializedInvocation, + type MaterializedInvocationOrdering, + type MaterializedSessionEvidence, + type MaterializedSessionPathResolver, + type MaterializedSessionSource, + type MaterializedSpan, + type MaterializedSpanEvent, + type MaterializedSpanEvidence, + type MaterializedTerminationStatus, + type MaterializedTraceEvidence, + type MaterializedTraceRecordSource, + type MaterializeJsonlInvocationOptions, +} from "./materialized-types.ts"; +export { materializeJsonlInvocation, serializeMaterializedInvocation } from "./materializer.ts"; export { readJsonlTraceFile } from "./reader.ts"; export { createJsonlTraceContext } from "./recorder.ts"; export { diff --git a/packages/harness-trace/src/materialized-types.ts b/packages/harness-trace/src/materialized-types.ts new file mode 100644 index 000000000..a9e6f327b --- /dev/null +++ b/packages/harness-trace/src/materialized-types.ts @@ -0,0 +1,136 @@ +import type { SpanAttributes, SpanStatus } from "@earendil-works/diffex-telemetry"; +import type { + TraceHealth, + TraceProducer, + TraceRecord, + TraceSessionAttachRecord, + TraceTerminationStatus, +} from "./types.ts"; + +export const MATERIALIZED_INVOCATION_VERSION = 1 as const; + +export type MaterializationIntegrity = "complete" | "partial" | "incompatible"; +export type MaterializedTerminationStatus = TraceTerminationStatus | "crashed" | "unknown"; + +export type MaterializationDiagnosticCode = + | "trace_integrity" + | "trace_writer_degraded" + | "unsettled_span" + | "session_missing" + | "session_changed" + | "session_truncated" + | "session_incompatible" + | "session_invalid"; + +export interface MaterializationDiagnostic { + readonly code: MaterializationDiagnosticCode; + readonly message: string; + readonly recoverable: boolean; + readonly traceSequence?: number; + readonly bindingId?: string; + readonly sourceCode?: string; +} + +export interface MaterializedCausalIdentifier { + readonly name: string; + readonly value: string; +} + +export interface MaterializedEvidenceOrder { + readonly traceSequence: number; + readonly timestamp: string | null; + readonly sessionPosition?: number; + readonly parentSpanId?: number | null; + readonly causalIdentifiers: readonly MaterializedCausalIdentifier[]; + readonly ambiguity: readonly string[]; +} + +export interface MaterializedTraceRecordSource { + readonly recordId: string; + readonly sequence: number; + readonly type: TraceRecord["type"]; +} + +export interface MaterializedSessionSource { + readonly bindingId: string; + readonly sessionId: string; + readonly path: string; + readonly resolvedPath?: string; + readonly sessionFormat: string; + readonly formatVersion: number; + readonly segmentTraceSequences: readonly number[]; + readonly entryId?: string; + readonly entryOrdinal?: number; + readonly sessionSequence?: number; +} + +export type MaterializedSessionPathResolver = ( + attachment: TraceSessionAttachRecord, +) => string | undefined | Promise; + +export interface MaterializeJsonlInvocationOptions { + readonly resolveSessionPath?: MaterializedSessionPathResolver; +} + +export interface MaterializedSpanEvent { + readonly name: string; + readonly timestamp: string; + readonly attributes: Readonly; + readonly sourceTraceRecordId: string; +} + +export interface MaterializedSpan { + readonly spanId: number; + readonly parentSpanId: number | null; + readonly name: string; + readonly startedAt: string; + readonly endedAt: string | null; + readonly startAttributes: Readonly; + readonly endAttributes: Readonly | null; + readonly events: readonly MaterializedSpanEvent[]; + readonly status: SpanStatus | null; + readonly settled: boolean; +} + +interface MaterializedEvidenceBase { + readonly evidenceId: string; + readonly order: MaterializedEvidenceOrder; + readonly traceSources: readonly MaterializedTraceRecordSource[]; + readonly sessionSource?: MaterializedSessionSource; +} + +export interface MaterializedTraceEvidence extends MaterializedEvidenceBase { + readonly kind: "trace_record"; + readonly payload: TraceRecord; +} + +export interface MaterializedSpanEvidence extends MaterializedEvidenceBase { + readonly kind: "span"; + readonly payload: MaterializedSpan; +} + +export interface MaterializedSessionEvidence extends MaterializedEvidenceBase { + readonly kind: "session_item"; + readonly itemType: string; + readonly payload: Readonly>; +} + +export type MaterializedEvidence = MaterializedTraceEvidence | MaterializedSpanEvidence | MaterializedSessionEvidence; + +export interface MaterializedInvocationOrdering { + readonly criteria: readonly ["trace_sequence", "session_cursor", "timestamp", "span_parentage", "causal_id"]; + readonly ambiguity: readonly string[]; +} + +export interface MaterializedInvocation { + readonly version: typeof MATERIALIZED_INVOCATION_VERSION; + readonly tracePath: string; + readonly traceId: string | null; + readonly producer: TraceProducer | null; + readonly integrity: MaterializationIntegrity; + readonly terminationStatus: MaterializedTerminationStatus; + readonly health: TraceHealth | null; + readonly ordering: MaterializedInvocationOrdering; + readonly evidence: readonly MaterializedEvidence[]; + readonly diagnostics: readonly MaterializationDiagnostic[]; +} diff --git a/packages/harness-trace/src/materializer.ts b/packages/harness-trace/src/materializer.ts new file mode 100644 index 000000000..210e01d83 --- /dev/null +++ b/packages/harness-trace/src/materializer.ts @@ -0,0 +1,284 @@ +import { causalIdentifiers } from "./evidence-utils.ts"; +import { + MATERIALIZED_INVOCATION_VERSION, + type MaterializationDiagnostic, + type MaterializationIntegrity, + type MaterializedEvidence, + type MaterializedEvidenceOrder, + type MaterializedInvocation, + type MaterializedSpan, + type MaterializedSpanEvent, + type MaterializedTraceRecordSource, + type MaterializeJsonlInvocationOptions, +} from "./materialized-types.ts"; +import { readJsonlTraceFile } from "./reader.ts"; +import { materializeSessionEvidence } from "./session-materializer.ts"; +import type { + TraceFooterRecord, + TraceHeaderRecord, + TraceRecord, + TraceSessionAttachRecord, + TraceSessionSegmentRecord, + TraceSpanEndRecord, + TraceSpanEventRecord, + TraceSpanStartRecord, +} from "./types.ts"; + +interface IndexedTraceRecord { + readonly index: number; + readonly record: TraceRecord; + readonly source: MaterializedTraceRecordSource; +} + +const ORDERING_CRITERIA = ["trace_sequence", "session_cursor", "timestamp", "span_parentage", "causal_id"] as const; + +function traceRecordId(traceId: string | null, record: TraceRecord, index: number): string { + return `${traceId ?? "unknown-trace"}:record:${record.sequence}:${index + 1}`; +} + +function indexedTraceRecords(records: readonly TraceRecord[], traceId: string | null): readonly IndexedTraceRecord[] { + return records.map((record, index) => ({ + index, + record, + source: { + recordId: traceRecordId(traceId, record, index), + sequence: record.sequence, + type: record.type, + }, + })); +} + +function traceOrder( + record: TraceRecord, + causalValues: readonly unknown[], + parentSpanId?: number | null, +): MaterializedEvidenceOrder { + return { + traceSequence: record.sequence, + timestamp: record.timestamp, + ...(parentSpanId === undefined ? {} : { parentSpanId }), + causalIdentifiers: causalIdentifiers(...causalValues), + ambiguity: [], + }; +} + +function materializeSpan( + traceId: string | null, + start: IndexedTraceRecord & { readonly record: TraceSpanStartRecord }, + events: readonly (IndexedTraceRecord & { readonly record: TraceSpanEventRecord })[], + end: (IndexedTraceRecord & { readonly record: TraceSpanEndRecord }) | undefined, +): MaterializedEvidence { + const spanEvents: MaterializedSpanEvent[] = events.map((event) => ({ + name: event.record.name, + timestamp: event.record.timestamp, + attributes: event.record.attributes, + sourceTraceRecordId: event.source.recordId, + })); + const payload: MaterializedSpan = { + spanId: start.record.spanId, + parentSpanId: start.record.parentSpanId, + name: start.record.name, + startedAt: start.record.timestamp, + endedAt: end?.record.timestamp ?? null, + startAttributes: start.record.attributes, + endAttributes: end?.record.attributes ?? null, + events: spanEvents, + status: end?.record.status ?? null, + settled: end !== undefined, + }; + return { + evidenceId: `${traceId ?? "unknown-trace"}:span:${start.record.spanId}`, + kind: "span", + order: traceOrder( + start.record, + [start.record.attributes, ...events.map((event) => event.record.attributes), end?.record.attributes], + start.record.parentSpanId, + ), + traceSources: [start.source, ...events.map((event) => event.source), ...(end ? [end.source] : [])], + payload, + }; +} + +function materializeTraceEvidence( + traceId: string | null, + indexed: readonly IndexedTraceRecord[], +): { evidence: MaterializedEvidence[]; diagnostics: MaterializationDiagnostic[] } { + const evidence: MaterializedEvidence[] = []; + const diagnostics: MaterializationDiagnostic[] = []; + const consumed = new Set(); + const firstStarts = new Map(); + + for (const item of indexed) { + if (item.record.type === "span_start" && !firstStarts.has(item.record.spanId)) { + firstStarts.set(item.record.spanId, item as IndexedTraceRecord & { readonly record: TraceSpanStartRecord }); + } + } + + for (const start of firstStarts.values()) { + const end = indexed.find( + (item): item is IndexedTraceRecord & { readonly record: TraceSpanEndRecord } => + item.record.type === "span_end" && + item.record.spanId === start.record.spanId && + item.record.sequence >= start.record.sequence, + ); + const events = indexed.filter( + (item): item is IndexedTraceRecord & { readonly record: TraceSpanEventRecord } => + item.record.type === "span_event" && + item.record.spanId === start.record.spanId && + item.record.sequence >= start.record.sequence && + (end === undefined || item.record.sequence <= end.record.sequence), + ); + consumed.add(start.index); + for (const event of events) consumed.add(event.index); + if (end) consumed.add(end.index); + evidence.push(materializeSpan(traceId, start, events, end)); + if (!end) { + diagnostics.push({ + code: "unsettled_span", + message: `Span ${start.record.spanId} has no settlement record`, + recoverable: true, + traceSequence: start.record.sequence, + }); + } + } + + for (const item of indexed) { + if (consumed.has(item.index)) continue; + evidence.push({ + evidenceId: `${traceId ?? "unknown-trace"}:evidence:${item.record.sequence}:${item.index + 1}`, + kind: "trace_record", + order: traceOrder(item.record, [item.record], "spanId" in item.record ? item.record.spanId : undefined), + traceSources: [item.source], + payload: item.record, + }); + } + + evidence.sort( + (left, right) => + left.order.traceSequence - right.order.traceSequence || left.evidenceId.localeCompare(right.evidenceId), + ); + return { evidence, diagnostics }; +} + +function traceIntegrityDiagnostics( + result: Awaited>, +): MaterializationDiagnostic[] { + return result.diagnostics.map((diagnostic) => ({ + code: "trace_integrity", + message: diagnostic.message, + recoverable: diagnostic.code !== "incompatible_schema_version", + ...(diagnostic.sequence === undefined ? {} : { traceSequence: diagnostic.sequence }), + sourceCode: diagnostic.code, + })); +} + +function materializationIntegrity(diagnostics: readonly MaterializationDiagnostic[]): MaterializationIntegrity { + if (diagnostics.some((diagnostic) => !diagnostic.recoverable)) return "incompatible"; + return diagnostics.length === 0 ? "complete" : "partial"; +} + +async function materializeSessions( + traceId: string | null, + indexed: readonly IndexedTraceRecord[], + options: MaterializeJsonlInvocationOptions, +): Promise<{ evidence: MaterializedEvidence[]; diagnostics: MaterializationDiagnostic[] }> { + const attachments = indexed.filter( + (item): item is IndexedTraceRecord & { readonly record: TraceSessionAttachRecord } => + item.record.type === "session_attach", + ); + const segmentSources = new Map( + indexed + .filter((item) => item.record.type === "session_segment") + .map((item) => [item.record.sequence, item.source] as const), + ); + const results = await Promise.all( + attachments.map(async ({ record: attachment }) => { + const segments = indexed + .map((item) => item.record) + .filter( + (record): record is TraceSessionSegmentRecord => + record.type === "session_segment" && record.bindingId === attachment.bindingId, + ); + if (segments.length === 0) return { evidence: [], diagnostics: [] }; + let resolvedPath: string | undefined; + try { + resolvedPath = options.resolveSessionPath ? await options.resolveSessionPath(attachment) : attachment.path; + } catch (error) { + return { + evidence: [], + diagnostics: [ + { + code: "session_missing" as const, + message: error instanceof Error ? error.message : String(error), + recoverable: true, + bindingId: attachment.bindingId, + }, + ], + }; + } + return materializeSessionEvidence({ traceId, attachment, segments, segmentSources, resolvedPath }); + }), + ); + return { + evidence: results.flatMap((result) => result.evidence), + diagnostics: results.flatMap((result) => result.diagnostics), + }; +} + +function sortEvidence(evidence: MaterializedEvidence[]): void { + evidence.sort((left, right) => { + const sequenceOrder = left.order.traceSequence - right.order.traceSequence; + if (sequenceOrder !== 0) return sequenceOrder; + const leftPosition = left.order.sessionPosition ?? Number.MAX_SAFE_INTEGER; + const rightPosition = right.order.sessionPosition ?? Number.MAX_SAFE_INTEGER; + return leftPosition - rightPosition || left.evidenceId.localeCompare(right.evidenceId); + }); +} + +/** Builds a deterministic evidence document from a validated invocation trace. */ +export async function materializeJsonlInvocation( + tracePath: string, + options: MaterializeJsonlInvocationOptions = {}, +): Promise { + const result = await readJsonlTraceFile(tracePath); + const header = result.records.find((record): record is TraceHeaderRecord => record.type === "trace_header"); + const footer = result.records.find((record): record is TraceFooterRecord => record.type === "trace_footer"); + const traceId = header?.traceId ?? null; + const indexed = indexedTraceRecords(result.records, traceId); + const materialized = materializeTraceEvidence(traceId, indexed); + const sessions = await materializeSessions(traceId, indexed, options); + const diagnostics = [...traceIntegrityDiagnostics(result), ...materialized.diagnostics, ...sessions.diagnostics]; + if (footer && (footer.health.droppedRecords > 0 || footer.health.writerErrors > 0 || footer.health.writerDisabled)) { + diagnostics.push({ + code: "trace_writer_degraded", + message: `Trace writer degraded with ${footer.health.droppedRecords} dropped records and ${footer.health.writerErrors} writer errors`, + recoverable: true, + traceSequence: footer.sequence, + }); + } + + const evidence = [...materialized.evidence, ...sessions.evidence]; + sortEvidence(evidence); + const orderingAmbiguity = sessions.evidence.length + ? [ + "Session cursor order is durable within each segment, but clocks and segment boundaries may not define a total order with trace records", + ] + : []; + return { + version: MATERIALIZED_INVOCATION_VERSION, + tracePath, + traceId, + producer: header?.producer ?? null, + integrity: materializationIntegrity(diagnostics), + terminationStatus: footer?.terminationStatus ?? (header ? "crashed" : "unknown"), + health: footer?.health ?? null, + ordering: { criteria: ORDERING_CRITERIA, ambiguity: orderingAmbiguity }, + evidence, + diagnostics, + }; +} + +/** Serializes one materialized invocation as stable, human-readable JSON. */ +export function serializeMaterializedInvocation(invocation: MaterializedInvocation): string { + return `${JSON.stringify(invocation, null, 2)}\n`; +} diff --git a/packages/harness-trace/src/session-materializer.ts b/packages/harness-trace/src/session-materializer.ts new file mode 100644 index 000000000..017eccd1f --- /dev/null +++ b/packages/harness-trace/src/session-materializer.ts @@ -0,0 +1,474 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { StringDecoder } from "node:string_decoder"; +import { causalIdentifiers } from "./evidence-utils.ts"; +import type { + MaterializationDiagnostic, + MaterializedEvidence, + MaterializedSessionEvidence, + MaterializedSessionSource, + MaterializedTraceRecordSource, +} from "./materialized-types.ts"; +import type { + AgentHarnessSessionSegmentCursor, + CodingAgentV3EntryCursor, + CodingAgentV3SessionSegmentCursor, + TraceSessionAttachRecord, + TraceSessionSegmentRecord, +} from "./types.ts"; + +type CodingAgentSessionSegment = TraceSessionSegmentRecord & { + readonly cursor: CodingAgentV3SessionSegmentCursor; +}; + +type AgentHarnessSessionSegment = TraceSessionSegmentRecord & { + readonly cursor: AgentHarnessSessionSegmentCursor; +}; + +export interface SessionMaterializationRequest { + readonly traceId: string | null; + readonly attachment: TraceSessionAttachRecord; + readonly segments: readonly TraceSessionSegmentRecord[]; + readonly segmentSources: ReadonlyMap; + readonly resolvedPath: string | undefined; +} + +export interface SessionMaterializationResult { + readonly evidence: readonly MaterializedEvidence[]; + readonly diagnostics: readonly MaterializationDiagnostic[]; +} + +interface JsonlLine { + readonly text: string; + readonly lineNumber: number; + readonly terminated: boolean; +} + +interface ObservedCodingCursor { + readonly entryId: string; + readonly contentDigest: string; + readonly prefixDigest: string; +} + +const SESSION_ORDER_AMBIGUITY = + "Session items have durable cursor order, but their position relative to trace records at the segment boundary is not totally ordered"; + +async function* streamJsonlLines(path: string): AsyncGenerator { + const decoder = new StringDecoder("utf8"); + let pending = ""; + let lineNumber = 0; + for await (const chunk of createReadStream(path)) { + pending += decoder.write(chunk); + let newline = pending.indexOf("\n"); + while (newline !== -1) { + lineNumber++; + yield { text: pending.slice(0, newline), lineNumber, terminated: true }; + pending = pending.slice(newline + 1); + newline = pending.indexOf("\n"); + } + } + pending += decoder.end(); + if (pending.length > 0) { + lineNumber++; + yield { text: pending, lineNumber, terminated: false }; + } +} + +function objectValue(text: string): Readonly> | undefined { + try { + const value: unknown = JSON.parse(text); + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Readonly>) + : undefined; + } catch { + return undefined; + } +} + +function diagnostic( + request: SessionMaterializationRequest, + code: MaterializationDiagnostic["code"], + message: string, + traceSequence?: number, +): MaterializationDiagnostic { + return { + code, + message, + recoverable: true, + ...(traceSequence === undefined ? {} : { traceSequence }), + bindingId: request.attachment.bindingId, + }; +} + +function lineDiagnostic( + request: SessionMaterializationRequest, + line: JsonlLine, + message: string, +): MaterializationDiagnostic { + return diagnostic( + request, + line.terminated ? "session_invalid" : "session_truncated", + `${message} at line ${line.lineNumber}`, + ); +} + +function sessionTimestamp(value: unknown): string | null { + if (typeof value === "string") { + const milliseconds = Date.parse(value); + return Number.isFinite(milliseconds) ? new Date(milliseconds).toISOString() : null; + } + if (typeof value === "number" && Number.isFinite(value)) { + const timestamp = new Date(value); + return Number.isNaN(timestamp.getTime()) ? null : timestamp.toISOString(); + } + return null; +} + +function traceSources( + request: SessionMaterializationRequest, + segments: readonly TraceSessionSegmentRecord[], +): readonly MaterializedTraceRecordSource[] { + return segments + .map((segment) => request.segmentSources.get(segment.sequence)) + .filter((source): source is MaterializedTraceRecordSource => source !== undefined); +} + +function sessionSource( + request: SessionMaterializationRequest, + segments: readonly TraceSessionSegmentRecord[], + position: { entryId?: string; entryOrdinal?: number; sessionSequence?: number }, +): MaterializedSessionSource { + return { + bindingId: request.attachment.bindingId, + sessionId: request.attachment.sessionId, + path: request.attachment.path, + ...(request.resolvedPath === undefined || request.resolvedPath === request.attachment.path + ? {} + : { resolvedPath: request.resolvedPath }), + sessionFormat: request.attachment.sessionFormat, + formatVersion: request.attachment.formatVersion, + segmentTraceSequences: segments.map((segment) => segment.sequence), + ...position, + }; +} + +function sessionEvidence( + request: SessionMaterializationRequest, + segments: readonly TraceSessionSegmentRecord[], + itemType: string, + payload: Readonly>, + position: { entryId?: string; entryOrdinal?: number; sessionSequence?: number }, +): MaterializedSessionEvidence { + const traceSequence = Math.min(...segments.map((segment) => segment.sequence)); + const sessionPosition = position.entryOrdinal ?? position.sessionSequence ?? 0; + const source = sessionSource(request, segments, position); + const stablePosition = position.entryId + ? `entry:${position.entryId}:ordinal:${position.entryOrdinal ?? 0}` + : `sequence:${position.sessionSequence ?? 0}`; + return { + evidenceId: `${request.traceId ?? "unknown-trace"}:session:${request.attachment.bindingId}:${stablePosition}`, + kind: "session_item", + itemType, + order: { + traceSequence, + timestamp: sessionTimestamp(payload.timestamp), + sessionPosition, + causalIdentifiers: causalIdentifiers(payload), + ambiguity: [SESSION_ORDER_AMBIGUITY], + }, + traceSources: traceSources(request, segments), + sessionSource: source, + payload, + }; +} + +function cursorDigest(cursor: CodingAgentV3EntryCursor, observed: ObservedCodingCursor): string { + return cursor.digest.kind === "content" ? observed.contentDigest : observed.prefixDigest; +} + +function verifyCodingCursor( + request: SessionMaterializationRequest, + segment: TraceSessionSegmentRecord, + name: "start" | "end", + cursor: CodingAgentV3EntryCursor, + observed: ReadonlyMap, + entryCount: number, +): MaterializationDiagnostic[] { + const actual = observed.get(cursor.entryOrdinal); + if (!actual) { + return [ + diagnostic( + request, + "session_truncated", + `Session segment ${name} ordinal ${cursor.entryOrdinal} is unavailable after ${entryCount} entries`, + segment.sequence, + ), + ]; + } + const diagnostics: MaterializationDiagnostic[] = []; + if (actual.entryId !== cursor.entryId) { + diagnostics.push( + diagnostic( + request, + "session_changed", + `Session segment ${name} entry changed from ${cursor.entryId} to ${actual.entryId}`, + segment.sequence, + ), + ); + } + if (cursorDigest(cursor, actual) !== cursor.digest.value) { + diagnostics.push( + diagnostic( + request, + "session_changed", + `Session segment ${name} ${cursor.digest.kind} digest does not match entry ${cursor.entryId}`, + segment.sequence, + ), + ); + } + return diagnostics; +} + +async function materializeCodingAgentSession( + request: SessionMaterializationRequest, +): Promise { + if (request.attachment.formatVersion !== 3) { + return { + evidence: [], + diagnostics: [ + diagnostic( + request, + "session_incompatible", + `Coding-agent session format v${request.attachment.formatVersion} is unsupported`, + ), + ], + }; + } + const segments = request.segments.filter( + (segment): segment is CodingAgentSessionSegment => segment.cursor.kind === "coding-agent-v3", + ); + const diagnostics: MaterializationDiagnostic[] = []; + const evidence: MaterializedEvidence[] = []; + const observed = new Map(); + const prefixHash = createHash("sha256"); + let headerSeen = false; + let entryOrdinal = 0; + + for await (const line of streamJsonlLines(request.resolvedPath as string)) { + if (line.text.trim().length === 0) continue; + const value = objectValue(line.text); + if (!value) { + diagnostics.push(lineDiagnostic(request, line, "Session contains invalid JSON")); + continue; + } + if (!headerSeen) { + headerSeen = true; + if (value.type !== "session" || value.version !== 3) { + diagnostics.push( + diagnostic(request, "session_incompatible", "Coding-agent session header is not format v3"), + ); + continue; + } + if (value.id !== request.attachment.sessionId) { + diagnostics.push( + diagnostic( + request, + "session_changed", + `Session header id ${String(value.id)} does not match ${request.attachment.sessionId}`, + ), + ); + } + continue; + } + + if (typeof value.id !== "string" || typeof value.type !== "string" || value.type === "session") { + diagnostics.push(lineDiagnostic(request, line, "Session entry is invalid")); + continue; + } + const encoded = JSON.stringify(value); + const contentDigest = createHash("sha256").update(encoded).digest("hex"); + prefixHash.update(`${encoded}\n`); + observed.set(entryOrdinal, { + entryId: value.id, + contentDigest, + prefixDigest: prefixHash.copy().digest("hex"), + }); + const containingSegments = segments.filter( + (segment) => + entryOrdinal >= segment.cursor.start.entryOrdinal && entryOrdinal <= segment.cursor.end.entryOrdinal, + ); + if (containingSegments.length > 0) { + evidence.push( + sessionEvidence(request, containingSegments, value.type, value, { + entryId: value.id, + entryOrdinal, + }), + ); + } + entryOrdinal++; + } + + if (!headerSeen) + diagnostics.push(diagnostic(request, "session_truncated", "Coding-agent session header is missing")); + for (const segment of segments) { + diagnostics.push( + ...verifyCodingCursor(request, segment, "start", segment.cursor.start, observed, entryOrdinal), + ...verifyCodingCursor(request, segment, "end", segment.cursor.end, observed, entryOrdinal), + ); + } + return { evidence, diagnostics }; +} + +function agentHarnessItemType(value: Readonly>): string { + if (typeof value.kind !== "string") return "unknown"; + if (typeof value.type === "string") return `${value.kind}.${value.type}`; + if (typeof value.fact === "string") return `${value.kind}.${value.fact}`; + return value.kind; +} + +async function materializeAgentHarnessSession( + request: SessionMaterializationRequest, +): Promise { + const segments = request.segments.filter( + (segment): segment is AgentHarnessSessionSegment => segment.cursor.kind === "agent-harness", + ); + const diagnostics: MaterializationDiagnostic[] = []; + const evidence: MaterializedEvidence[] = []; + const observedSequences = new Set(); + let headerSeen = false; + + for await (const line of streamJsonlLines(request.resolvedPath as string)) { + if (line.text.trim().length === 0) continue; + const value = objectValue(line.text); + if (!value) { + diagnostics.push(lineDiagnostic(request, line, "Session contains invalid JSON")); + continue; + } + if (!headerSeen) { + headerSeen = true; + if (value.kind !== "header" || value.version !== request.attachment.formatVersion) { + diagnostics.push( + diagnostic( + request, + "session_incompatible", + `AgentHarness session header does not match format v${request.attachment.formatVersion}`, + ), + ); + continue; + } + if (value.id !== request.attachment.sessionId) { + diagnostics.push( + diagnostic( + request, + "session_changed", + `Session header id ${String(value.id)} does not match ${request.attachment.sessionId}`, + ), + ); + } + continue; + } + + if (!Number.isSafeInteger(value.seq) || (value.seq as number) < 1) { + diagnostics.push(lineDiagnostic(request, line, "AgentHarness session item has an invalid sequence")); + continue; + } + const sequence = value.seq as number; + if (observedSequences.has(sequence)) { + diagnostics.push( + diagnostic(request, "session_changed", `AgentHarness session sequence ${sequence} is duplicated`), + ); + continue; + } + observedSequences.add(sequence); + const containingSegments = segments.filter( + (segment) => sequence >= segment.cursor.startSequence && sequence <= segment.cursor.endSequence, + ); + if (containingSegments.length > 0) { + evidence.push( + sessionEvidence(request, containingSegments, agentHarnessItemType(value), value, { + sessionSequence: sequence, + }), + ); + } + } + + if (!headerSeen) + diagnostics.push(diagnostic(request, "session_truncated", "AgentHarness session header is missing")); + for (const segment of segments) { + const present = [...observedSequences] + .filter((sequence) => sequence >= segment.cursor.startSequence && sequence <= segment.cursor.endSequence) + .sort((left, right) => left - right); + let expected = segment.cursor.startSequence; + for (const sequence of present) { + if (sequence > expected) { + diagnostics.push( + diagnostic( + request, + "session_truncated", + `AgentHarness session sequences ${expected} through ${sequence - 1} are unavailable`, + segment.sequence, + ), + ); + } + expected = sequence + 1; + } + if (expected <= segment.cursor.endSequence) { + diagnostics.push( + diagnostic( + request, + "session_truncated", + `AgentHarness session sequences ${expected} through ${segment.cursor.endSequence} are unavailable`, + segment.sequence, + ), + ); + } + } + return { evidence, diagnostics }; +} + +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" + ? error.code + : undefined; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export async function materializeSessionEvidence( + request: SessionMaterializationRequest, +): Promise { + if (!request.resolvedPath) { + return { + evidence: [], + diagnostics: [diagnostic(request, "session_missing", "Referenced session could not be resolved")], + }; + } + try { + if (request.attachment.sessionFormat === "coding-agent") return await materializeCodingAgentSession(request); + if (request.attachment.sessionFormat === "agent-harness") return await materializeAgentHarnessSession(request); + return { + evidence: [], + diagnostics: [ + diagnostic( + request, + "session_incompatible", + `Session format ${request.attachment.sessionFormat} is unsupported`, + ), + ], + }; + } catch (error) { + const missing = errorCode(error) === "ENOENT"; + return { + evidence: [], + diagnostics: [ + diagnostic( + request, + missing ? "session_missing" : "session_invalid", + missing ? `Referenced session is missing at ${request.resolvedPath}` : errorMessage(error), + ), + ], + }; + } +} diff --git a/packages/harness-trace/test/fixtures/materialization/agent-harness-v4-session.jsonl b/packages/harness-trace/test/fixtures/materialization/agent-harness-v4-session.jsonl new file mode 100644 index 000000000..6a0622ce4 --- /dev/null +++ b/packages/harness-trace/test/fixtures/materialization/agent-harness-v4-session.jsonl @@ -0,0 +1,6 @@ +{"kind":"header","version":4,"id":"harness-session","createdAt":1787066340000,"cwd":"/workspace"} +{"kind":"entry","lane":"main","id":"entry-40","type":"message","parentId":null,"seq":40,"timestamp":1787066400000,"message":{"role":"user","content":"Continue the task","timestamp":1787066400000}} +{"kind":"record","id":"record-41","lane":"main","type":"step_attempt","runId":"run-1","step":"assistant","attempt":2,"resultEntryId":"entry-42","seq":41,"timestamp":1787066401000} +{"kind":"record","id":"record-42","lane":"main","type":"usage","cause":"assistant","runId":"run-1","entryId":"entry-42","attempt":2,"stopReason":"stop","usage":{"input":20,"output":10,"cacheRead":0,"cacheWrite":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"seq":42,"timestamp":1787066402000} +{"kind":"entry","lane":"main","id":"entry-43","type":"compaction","parentId":"entry-40","summary":"Retained task state","retainedTail":[],"tokensBefore":2200,"seq":43,"timestamp":1787066403000} +{"kind":"record","id":"record-44","lane":"main","type":"operation_finished","runId":"run-1","outcome":"aborted","seq":44,"timestamp":1787066404000} diff --git a/packages/harness-trace/test/fixtures/materialization/coding-agent-v3-session.jsonl b/packages/harness-trace/test/fixtures/materialization/coding-agent-v3-session.jsonl new file mode 100644 index 000000000..cc2fff383 --- /dev/null +++ b/packages/harness-trace/test/fixtures/materialization/coding-agent-v3-session.jsonl @@ -0,0 +1,5 @@ +{"type":"session","version":3,"id":"coding-session","timestamp":"2026-08-18T09:59:00.000Z","cwd":"/workspace"} +{"type":"message","id":"entry-0","parentId":null,"timestamp":"2026-08-18T10:00:00.000Z","message":{"role":"user","content":"Fix the failing test","timestamp":1787066400000}} +{"type":"message","id":"entry-1","parentId":"entry-0","timestamp":"2026-08-18T10:00:01.000Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"tool-1","name":"read","arguments":{"path":"src/app.ts"}}],"api":"test","provider":"faux","model":"faux","usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1787066401000}} +{"type":"message","id":"entry-2","parentId":"entry-1","timestamp":"2026-08-18T10:00:02.000Z","message":{"role":"toolResult","toolCallId":"tool-1","toolName":"read","content":[{"type":"text","text":"source"}],"isError":false,"timestamp":1787066402000}} +{"type":"compaction","id":"entry-3","parentId":"entry-2","timestamp":"2026-08-18T10:00:03.000Z","summary":"Inspected the source","firstKeptEntryId":"entry-2","tokensBefore":1200,"usage":{"input":100,"output":20,"cacheRead":0,"cacheWrite":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}}} diff --git a/packages/harness-trace/test/materializer.test.ts b/packages/harness-trace/test/materializer.test.ts new file mode 100644 index 000000000..1e4360cfe --- /dev/null +++ b/packages/harness-trace/test/materializer.test.ts @@ -0,0 +1,412 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; +import { + encodeTraceRecord, + materializeJsonlInvocation, + type SessionSegmentCursor, + serializeMaterializedInvocation, + type TraceRecord, +} from "../src/index.ts"; + +const temporaryDirectories: string[] = []; + +async function temporaryTracePath(): Promise { + const directory = await mkdtemp(join(tmpdir(), "diffex-materializer-")); + temporaryDirectories.push(directory); + return join(directory, "trace.jsonl"); +} + +function fixturePath(name: string): string { + return fileURLToPath(new URL(`./fixtures/materialization/${name}`, import.meta.url)); +} + +async function temporaryPath(name: string): Promise { + const directory = await mkdtemp(join(tmpdir(), "diffex-materializer-session-")); + temporaryDirectories.push(directory); + return join(directory, name); +} + +interface SessionTraceOptions { + readonly traceId: string; + readonly sessionId: string; + readonly sessionPath: string; + readonly sessionFormat: string; + readonly formatVersion: number; + readonly reason: "resume" | "switch"; + readonly cursor: SessionSegmentCursor; + readonly terminationStatus?: "completed" | "failed" | "aborted"; +} + +function sessionTrace(options: SessionTraceOptions): TraceRecord[] { + return [ + { + type: "trace_header", + sequence: 1, + timestamp: "2026-08-18T10:00:00.000Z", + schemaVersion: 1, + traceId: options.traceId, + producer: { + diffexVersion: "0.2.0", + sourceRevision: "abcdef", + runtime: { kind: "node", version: "22.19.0" }, + telemetrySchemaVersions: { agent: 1, "coding-agent": 2, harness: 1 }, + metadata: {}, + }, + }, + { + type: "session_attach", + sequence: 2, + timestamp: "2026-08-18T10:00:00.100Z", + bindingId: `${options.traceId}-binding`, + sessionId: options.sessionId, + path: options.sessionPath, + sessionFormat: options.sessionFormat, + formatVersion: options.formatVersion, + reason: options.reason, + }, + { + type: "session_segment", + sequence: 3, + timestamp: "2026-08-18T10:00:05.000Z", + bindingId: `${options.traceId}-binding`, + lane: "main", + operationId: `${options.traceId}-operation`, + cursor: options.cursor, + }, + { + type: "trace_footer", + sequence: 4, + timestamp: "2026-08-18T10:00:06.000Z", + terminationStatus: options.terminationStatus ?? "completed", + health: { droppedRecords: 0, writerErrors: 0, writerDisabled: false, closed: true }, + }, + ]; +} + +async function codingAgentCursor( + path: string, + start: number, + end: number, + digestKinds: { start: "content" | "prefix"; end: "content" | "prefix" } = { + start: "content", + end: "content", + }, +): Promise { + const lines = (await readFile(path, "utf8")) + .trimEnd() + .split("\n") + .slice(1) + .map((line) => JSON.parse(line) as Readonly>); + const entryCursor = (entryOrdinal: number, kind: "content" | "prefix") => { + const entry = lines[entryOrdinal]; + if (!entry || typeof entry.id !== "string") throw new Error(`Missing fixture entry ${entryOrdinal}`); + const digestInput = + kind === "content" + ? JSON.stringify(entry) + : lines + .slice(0, entryOrdinal + 1) + .map((item) => `${JSON.stringify(item)}\n`) + .join(""); + return { + entryOrdinal, + entryId: entry.id, + digest: { + kind, + algorithm: "sha256" as const, + value: createHash("sha256").update(digestInput).digest("hex"), + }, + }; + }; + return { + kind: "coding-agent-v3", + start: entryCursor(start, digestKinds.start), + end: entryCursor(end, digestKinds.end), + }; +} + +function traceRecords(options: { footer?: boolean; degraded?: boolean } = {}): TraceRecord[] { + const records: TraceRecord[] = [ + { + type: "trace_header", + sequence: 1, + timestamp: "2026-08-18T10:00:00.000Z", + schemaVersion: 1, + traceId: "trace-materialized", + producer: { + diffexVersion: "0.2.0", + sourceRevision: "abcdef", + runtime: { kind: "node", version: "22.19.0" }, + telemetrySchemaVersions: { agent: 1 }, + metadata: {}, + }, + }, + { + type: "span_start", + sequence: 2, + timestamp: "2026-08-18T10:00:00.100Z", + spanId: 1, + parentSpanId: null, + name: "diffex.agent.run", + attributes: { "diffex.invocation.id": "invocation-1" }, + }, + { + type: "span_event", + sequence: 3, + timestamp: "2026-08-18T10:00:00.200Z", + spanId: 1, + name: "checkpoint", + attributes: { "diffex.operation.id": "operation-1" }, + }, + { + type: "span_end", + sequence: 4, + timestamp: "2026-08-18T10:00:00.300Z", + spanId: 1, + attributes: { "diffex.agent.outcome": "completed" }, + status: { status: "ok" }, + }, + ]; + if (options.footer !== false) { + records.push({ + type: "trace_footer", + sequence: 5, + timestamp: "2026-08-18T10:00:00.400Z", + terminationStatus: "completed", + health: { + droppedRecords: options.degraded ? 2 : 0, + writerErrors: options.degraded ? 1 : 0, + writerDisabled: options.degraded ?? false, + closed: true, + }, + }); + } + return records; +} + +async function writeTrace(path: string, records: readonly TraceRecord[]): Promise { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, records.map(encodeTraceRecord).join(""), "utf8"); +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +describe("invocation materializer", () => { + it("joins span records into deterministic trace evidence", async () => { + const path = await temporaryTracePath(); + await writeTrace(path, traceRecords()); + + const first = await materializeJsonlInvocation(path); + const second = await materializeJsonlInvocation(path); + + expect(first).toEqual(second); + expect(first).toMatchObject({ + version: 1, + traceId: "trace-materialized", + integrity: "complete", + terminationStatus: "completed", + }); + expect(first.evidence.map((item) => item.kind)).toEqual(["trace_record", "span", "trace_record"]); + const span = first.evidence.find((item) => item.kind === "span"); + expect(span).toMatchObject({ + evidenceId: "trace-materialized:span:1", + order: { + traceSequence: 2, + parentSpanId: null, + causalIdentifiers: [ + { name: "diffex.invocation.id", value: "invocation-1" }, + { name: "diffex.operation.id", value: "operation-1" }, + ], + }, + traceSources: [ + expect.objectContaining({ sequence: 2 }), + expect.objectContaining({ sequence: 3 }), + expect.objectContaining({ sequence: 4 }), + ], + payload: { + name: "diffex.agent.run", + settled: true, + status: { status: "ok" }, + events: [expect.objectContaining({ name: "checkpoint" })], + }, + }); + expect(serializeMaterializedInvocation(first)).toBe(serializeMaterializedInvocation(second)); + }); + + it("retains crashed and writer-degraded evidence with diagnostics", async () => { + const crashedPath = await temporaryTracePath(); + await writeTrace(crashedPath, traceRecords({ footer: false })); + const crashed = await materializeJsonlInvocation(crashedPath); + expect(crashed.terminationStatus).toBe("crashed"); + expect(crashed.integrity).toBe("partial"); + expect(crashed.diagnostics).toEqual([ + expect.objectContaining({ code: "trace_integrity", sourceCode: "missing_footer" }), + ]); + + const degradedPath = await temporaryTracePath(); + await writeTrace(degradedPath, traceRecords({ degraded: true })); + const degraded = await materializeJsonlInvocation(degradedPath); + expect(degraded.integrity).toBe("partial"); + expect(degraded.diagnostics).toEqual([ + expect.objectContaining({ code: "trace_writer_degraded", traceSequence: 5 }), + ]); + }); + + it("resolves coding-agent transcript evidence through immutable cursors", async () => { + const sessionPath = fixturePath("coding-agent-v3-session.jsonl"); + const tracePath = await temporaryTracePath(); + await writeTrace( + tracePath, + sessionTrace({ + traceId: "coding-trace", + sessionId: "coding-session", + sessionPath: "/moved/coding-session.jsonl", + sessionFormat: "coding-agent", + formatVersion: 3, + reason: "resume", + cursor: await codingAgentCursor(sessionPath, 0, 3, { start: "prefix", end: "content" }), + }), + ); + + const invocation = await materializeJsonlInvocation(tracePath, { + resolveSessionPath: () => sessionPath, + }); + + expect(invocation.integrity).toBe("complete"); + expect(invocation.ordering.ambiguity).toHaveLength(1); + const sessionItems = invocation.evidence.filter((item) => item.kind === "session_item"); + expect(sessionItems.map((item) => item.itemType)).toEqual(["message", "message", "message", "compaction"]); + expect(sessionItems[1]).toMatchObject({ + evidenceId: "coding-trace:session:coding-trace-binding:entry:entry-1:ordinal:1", + order: { + traceSequence: 3, + sessionPosition: 1, + causalIdentifiers: [ + { name: "id", value: "entry-1" }, + { name: "message.content.id", value: "tool-1" }, + { name: "parentId", value: "entry-0" }, + ], + }, + sessionSource: { + path: "/moved/coding-session.jsonl", + resolvedPath: sessionPath, + entryId: "entry-1", + entryOrdinal: 1, + segmentTraceSequences: [3], + }, + }); + expect(sessionItems[3]?.payload).toMatchObject({ + type: "compaction", + summary: "Inspected the source", + usage: { input: 100, output: 20 }, + }); + }); + + it("normalizes durable AgentHarness session sequences", async () => { + const sessionPath = fixturePath("agent-harness-v4-session.jsonl"); + const tracePath = await temporaryTracePath(); + await writeTrace( + tracePath, + sessionTrace({ + traceId: "harness-trace", + sessionId: "harness-session", + sessionPath, + sessionFormat: "agent-harness", + formatVersion: 4, + reason: "switch", + cursor: { kind: "agent-harness", startSequence: 40, endSequence: 44 }, + terminationStatus: "aborted", + }), + ); + + const invocation = await materializeJsonlInvocation(tracePath); + + expect(invocation.integrity).toBe("complete"); + expect(invocation.terminationStatus).toBe("aborted"); + const sessionItems = invocation.evidence.filter((item) => item.kind === "session_item"); + expect(sessionItems.map((item) => item.itemType)).toEqual([ + "entry.message", + "record.step_attempt", + "record.usage", + "entry.compaction", + "record.operation_finished", + ]); + expect(sessionItems[1]).toMatchObject({ + evidenceId: "harness-trace:session:harness-trace-binding:sequence:41", + order: { + sessionPosition: 41, + causalIdentifiers: [ + { name: "id", value: "record-41" }, + { name: "resultEntryId", value: "entry-42" }, + { name: "runId", value: "run-1" }, + ], + }, + sessionSource: { sessionSequence: 41 }, + }); + }); + + it.each(["failed", "aborted"] as const)("preserves %s invocation outcomes", async (terminationStatus) => { + const sessionPath = fixturePath("agent-harness-v4-session.jsonl"); + const tracePath = await temporaryTracePath(); + await writeTrace( + tracePath, + sessionTrace({ + traceId: `${terminationStatus}-trace`, + sessionId: "harness-session", + sessionPath, + sessionFormat: "agent-harness", + formatVersion: 4, + reason: "resume", + cursor: { kind: "agent-harness", startSequence: 40, endSequence: 44 }, + terminationStatus, + }), + ); + + expect((await materializeJsonlInvocation(tracePath)).terminationStatus).toBe(terminationStatus); + }); + + it("reports missing, changed, truncated, and incompatible session data", async () => { + const fixture = fixturePath("coding-agent-v3-session.jsonl"); + const cursor = await codingAgentCursor(fixture, 0, 3); + const changedPath = await temporaryPath("changed.jsonl"); + await writeFile(changedPath, (await readFile(fixture, "utf8")).replace("Inspected the source", "Changed")); + const truncatedPath = await temporaryPath("truncated.jsonl"); + await writeFile(truncatedPath, (await readFile(fixture, "utf8")).split("\n").slice(0, 4).join("\n")); + const incompatiblePath = fixturePath("agent-harness-v4-session.jsonl"); + + const cases = [ + { name: "missing", path: "/missing/session.jsonl", code: "session_missing" }, + { name: "changed", path: changedPath, code: "session_changed" }, + { name: "truncated", path: truncatedPath, code: "session_truncated" }, + { name: "incompatible", path: incompatiblePath, code: "session_incompatible" }, + ] as const; + for (const testCase of cases) { + const tracePath = await temporaryTracePath(); + await writeTrace( + tracePath, + sessionTrace({ + traceId: `${testCase.name}-session-trace`, + sessionId: "coding-session", + sessionPath: testCase.path, + sessionFormat: "coding-agent", + formatVersion: 3, + reason: "resume", + cursor, + }), + ); + const invocation = await materializeJsonlInvocation(tracePath); + expect(invocation.integrity, testCase.name).not.toBe("complete"); + expect( + invocation.diagnostics.map((item) => item.code), + testCase.name, + ).toContain(testCase.code); + } + }); +});