Context
PR #76 (#41 first cut) introduced TurnRecord.fidelity (granularity + per-field coverage + derived class) and the helpers EMPTY_COVERAGE, classifyFidelity, makeFidelity in @relayburn/reader. The Claude parser populates fidelity on every turn (packages/reader/src/claude.ts:255,1049 via buildClaudeFidelity at :604).
The OpenCode parser does not. Quoting PR #76's deferred-work paragraph:
Codex and OpenCode parsers (they still emit no fidelity; consumers treat absence as best-effort full for backward compat).
packages/reader/src/opencode.ts emits TurnRecord objects without any fidelity field. As a result, summarizeFidelity (packages/analyze/src/fidelity.ts) buckets every OpenCode turn under unknown, and hasMinimumFidelity(undefined, …) passes silently — the exact "0 vs unknown" ambiguity #41 was filed to fix.
Proposal
Populate TurnRecord.fidelity in parseOpencodeSession / parseOpencodeSessionIncremental (packages/reader/src/opencode.ts) on every emitted turn.
Coverage for OpenCode assistant messages (AssistantMessage.tokens at packages/reader/src/opencode.ts:27-35,46):
hasInputTokens: true when tokens.input is present on the assistant message (or rolled up from step-finish parts), otherwise false.
hasOutputTokens: true when tokens.output is present, otherwise false.
hasReasoningTokens: true when tokens.reasoning is present, otherwise false.
hasCacheReadTokens: true when tokens.cache?.read is present, otherwise false.
hasCacheCreateTokens: true when tokens.cache?.write is present, otherwise false (OpenCode collapses 5m/1h ephemeral spans into a single cache.write count).
hasToolCalls: true — tool parts are captured.
hasToolResultEvents: true — tool state.output and state.metadata are captured for tool-result attribution.
hasSessionRelationships: true — OpenCode already exposes session.parentID (read at parse time to populate isSidechain).
hasRawContent: true — full content capture path exists when contentMode === 'full'.
Granularity: 'per-turn'. OpenCode's per-message tokens accumulate into a per-turn record at the assistant-message boundary.
Mirror Claude's pattern: track per-field token presence as parts are folded into the turn (analogous to the WorkingRecord.usageCoverage accumulator in claude.ts), so a turn that gets step-finish for input/output but no cache fields reports hasCacheReadTokens: false rather than silently rendering cacheRead === 0.
Acceptance criteria
- Every
TurnRecord emitted by parseOpencodeSession / parseOpencodeSessionIncremental carries a fidelity field.
granularity === 'per-turn'.
- Coverage flags follow the matrix above; usage flags reflect presence on the upstream message, capability flags (
hasToolCalls, hasToolResultEvents, hasSessionRelationships, hasRawContent) are always true.
- A turn whose assistant message had no
tokens block reports class === 'partial' (or 'cost-only' if literally only timestamps survive); a fully-populated turn reports class === 'full'.
- New tests in
packages/reader/src/opencode.test.ts cover a full-fidelity turn, a tokens-missing turn (partial), and a turn with cache fields populated (hasCacheReadTokens and hasCacheCreateTokens both true).
summarizeFidelity over a real OpenCode session yields unknown === 0 for the produced turns.
Out of scope
Refs
Context
PR #76 (#41 first cut) introduced
TurnRecord.fidelity(granularity + per-field coverage + derived class) and the helpersEMPTY_COVERAGE,classifyFidelity,makeFidelityin@relayburn/reader. The Claude parser populates fidelity on every turn (packages/reader/src/claude.ts:255,1049viabuildClaudeFidelityat:604).The OpenCode parser does not. Quoting PR #76's deferred-work paragraph:
packages/reader/src/opencode.tsemitsTurnRecordobjects without anyfidelityfield. As a result,summarizeFidelity(packages/analyze/src/fidelity.ts) buckets every OpenCode turn underunknown, andhasMinimumFidelity(undefined, …)passes silently — the exact "0 vs unknown" ambiguity #41 was filed to fix.Proposal
Populate
TurnRecord.fidelityinparseOpencodeSession/parseOpencodeSessionIncremental(packages/reader/src/opencode.ts) on every emitted turn.Coverage for OpenCode assistant messages (
AssistantMessage.tokensatpackages/reader/src/opencode.ts:27-35,46):hasInputTokens: truewhentokens.inputis present on the assistant message (or rolled up fromstep-finishparts), otherwisefalse.hasOutputTokens: truewhentokens.outputis present, otherwisefalse.hasReasoningTokens: truewhentokens.reasoningis present, otherwisefalse.hasCacheReadTokens: truewhentokens.cache?.readis present, otherwisefalse.hasCacheCreateTokens: truewhentokens.cache?.writeis present, otherwisefalse(OpenCode collapses 5m/1h ephemeral spans into a singlecache.writecount).hasToolCalls: true—toolparts are captured.hasToolResultEvents: true— toolstate.outputandstate.metadataare captured for tool-result attribution.hasSessionRelationships: true— OpenCode already exposessession.parentID(read at parse time to populateisSidechain).hasRawContent: true— full content capture path exists whencontentMode === 'full'.Granularity:
'per-turn'. OpenCode's per-message tokens accumulate into a per-turn record at the assistant-message boundary.Mirror Claude's pattern: track per-field token presence as parts are folded into the turn (analogous to the
WorkingRecord.usageCoverageaccumulator inclaude.ts), so a turn that getsstep-finishfor input/output but no cache fields reportshasCacheReadTokens: falserather than silently renderingcacheRead === 0.Acceptance criteria
TurnRecordemitted byparseOpencodeSession/parseOpencodeSessionIncrementalcarries afidelityfield.granularity === 'per-turn'.hasToolCalls,hasToolResultEvents,hasSessionRelationships,hasRawContent) are alwaystrue.tokensblock reportsclass === 'partial'(or'cost-only'if literally only timestamps survive); a fully-populated turn reportsclass === 'full'.packages/reader/src/opencode.test.tscover a full-fidelity turn, a tokens-missing turn (partial), and a turn with cache fields populated (hasCacheReadTokensandhasCacheCreateTokensbothtrue).summarizeFidelityover a real OpenCode session yieldsunknown === 0for the produced turns.Out of scope
Refs