diff --git a/packages/analyze/src/fidelity.test.ts b/packages/analyze/src/fidelity.test.ts index ffb3fb2..a0f0e70 100644 --- a/packages/analyze/src/fidelity.test.ts +++ b/packages/analyze/src/fidelity.test.ts @@ -1,7 +1,13 @@ import { strict as assert } from 'node:assert'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { describe, it } from 'node:test'; -import { EMPTY_COVERAGE, makeFidelity } from '@relayburn/reader'; +import { + EMPTY_COVERAGE, + makeFidelity, + parseOpencodeSession, +} from '@relayburn/reader'; import type { Fidelity, TurnRecord } from '@relayburn/reader'; import { @@ -10,6 +16,17 @@ import { summarizeFidelity, } from './fidelity.js'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const OPENCODE_FIXTURES = path.resolve( + __dirname, + '..', + '..', + '..', + 'tests', + 'fixtures', + 'opencode', +); + function turn(fidelity?: Fidelity): Pick { // exactOptionalPropertyTypes refuses `{ fidelity: undefined }` for the // optional field — only construct the property when we have a value. @@ -92,6 +109,26 @@ describe('summarizeFidelity', () => { }); }); +describe('summarizeFidelity over an OpenCode session (issue #89)', () => { + it('reports unknown === 0 for every turn produced by parseOpencodeSession', async () => { + const file = path.join( + OPENCODE_FIXTURES, + 'multi-turn', + 'storage', + 'session', + 'global', + 'ses_multi.json', + ); + const { turns } = await parseOpencodeSession(file); + assert.ok(turns.length > 0); + const summary = summarizeFidelity(turns); + assert.equal(summary.unknown, 0); + assert.equal(summary.total, turns.length); + // Every OpenCode turn carries per-turn granularity. + assert.equal(summary.byGranularity['per-turn'], turns.length); + }); +}); + describe('hasMinimumFidelity', () => { it('treats undefined fidelity as passing (backward compat)', () => { assert.equal(hasMinimumFidelity(undefined, 'full'), true); diff --git a/packages/reader/CHANGELOG.md b/packages/reader/CHANGELOG.md index 15e383b..283aa97 100644 --- a/packages/reader/CHANGELOG.md +++ b/packages/reader/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Claude parser emits `fork` and `continuation` `SessionRelationshipRecord` rows** ([#112](https://github.com/AgentWorkforce/burn/issues/112)). Closes the deferred-work item from #77/#42: the Claude passive reader now populates the full `RelationshipType` lattice instead of only `root` / `subagent`. Per-file evidence — in-log `sessionId` mismatches against the on-disk filename, the first user line's `parentUuid`, the first non-empty `version` field, all in-file uuids, and `/resume` / `/continue` slash-command markers — is collected during the existing parse pass and surfaced as a new `evidence: ClaudeRelationshipEvidence` field on `ParseResult` / `ParseIncrementalResult`. A `/resume` marker emits a local `continuation` row with `relatedSessionId` set to the resumed-from id; a new exported `reconcileClaudeSessionRelationships(inputs)` helper takes per-file evidence from a multi-file pass and emits the cross-file `fork` / `continuation` rows that single-file parsers can't surface. Existing `root` / `subagent` rows are stamped with `sourceSessionId` (foreign in-log id) and `sourceVersion` whenever the file carries them. Reconciliation strategy is **append, not mutate**: a prior `root` row and a later `continuation` / `fork` row for the same session id produce different `relationshipIdHash` values, so both rows coexist on disk and consumers prefer the more specific row when both are present. Re-ingesting a session is idempotent — the writer's existing dedup folds duplicates. New `ParseOptions.fileSessionId` lets callers pin the canonical session id explicitly; when omitted but `sessionPath` is set, the parser derives it from the `.jsonl` basename. - **Codex parser populates `TurnRecord.fidelity`** ([#84](https://github.com/AgentWorkforce/burn/issues/84)). `parseCodexSession` and `parseCodexSessionIncremental` now stamp `fidelity` on every emitted turn at `granularity: 'per-turn'`, mirroring the Claude parser. Coverage flags follow the rollout source: `hasInputTokens` / `hasOutputTokens` / `hasReasoningTokens` / `hasCacheReadTokens` flip to `true` only when a `token_count` event with `total_token_usage` arrived between `task_started` and `task_complete`; turns whose source omitted token counts now report `class: 'partial'` (the numeric `usage` fields still default to 0, but the coverage flag is the honest signal). `hasToolCalls` / `hasToolResultEvents` / `hasRawContent` are capability flags — true even on tool-less turns. `hasCacheCreateTokens` and `hasSessionRelationships` stay `false` (Codex rollouts have no cache-create or parent-tracking concept yet — the latter waits on #42 / #63). Closes the `unknown === 0` requirement from #41 for Codex sessions. +- **OpenCode parser populates `TurnRecord.fidelity`** ([#89](https://github.com/AgentWorkforce/burn/issues/89), follow-up to [#41](https://github.com/AgentWorkforce/burn/issues/41) / [#76](https://github.com/AgentWorkforce/burn/issues/76)). `parseOpencodeSession` and `parseOpencodeSessionIncremental` now stamp `fidelity` on every emitted turn at `granularity: 'per-turn'`. Usage coverage flags (`hasInputTokens`, `hasOutputTokens`, `hasReasoningTokens`, `hasCacheReadTokens`, `hasCacheCreateTokens`) reflect *presence* on the upstream `tokens` block — folded across both the assistant message and any `step-finish` parts that carry tokens — so a turn that never received cache fields reports `hasCacheReadTokens: false` instead of silently rendering `cacheRead === 0`. Capability flags (`hasToolCalls`, `hasToolResultEvents`, `hasSessionRelationships`, `hasRawContent`) are always true. Closes the "0 vs unknown" ambiguity for OpenCode in `summarizeFidelity` and `hasMinimumFidelity`. ## [0.19.0] - 2026-04-26 diff --git a/packages/reader/src/claude.test.ts b/packages/reader/src/claude.test.ts index 51141e5..5d010a8 100644 --- a/packages/reader/src/claude.test.ts +++ b/packages/reader/src/claude.test.ts @@ -971,13 +971,31 @@ describe('parseClaudeSession fork / continuation relationships (#112)', () => { // relationship rows. The on-disk dedup is keyed by `relationshipIdHash` // (source + sessionId + relationshipType + relatedSessionId + agentId + // parentToolUseId), so the parser must produce equivalent rows on both - // passes for the writer's existing dedup to fold them. - const { relationshipIdHash } = await import('@relayburn/ledger'); + // passes for the writer's existing dedup to fold them. Reproduce the same + // canonical key here rather than importing from `@relayburn/ledger`, which + // already depends on `@relayburn/reader` (importing it back would create a + // cycle that breaks `tsc --build`). + const keyOf = (r: { + source: string; + sessionId: string; + relationshipType: string; + relatedSessionId?: string | undefined; + agentId?: string | undefined; + parentToolUseId?: string | undefined; + }) => + [ + r.source, + r.sessionId, + r.relationshipType, + r.relatedSessionId ?? '', + r.agentId ?? '', + r.parentToolUseId ?? '', + ].join('|'); const file = path.join(FIXTURES, 'resume-marker.jsonl'); const a = await parseClaudeSession(file, { sessionPath: file }); const b = await parseClaudeSession(file, { sessionPath: file }); - const idsA = new Set(a.relationships.map(relationshipIdHash)); - const idsB = new Set(b.relationships.map(relationshipIdHash)); + const idsA = new Set(a.relationships.map(keyOf)); + const idsB = new Set(b.relationships.map(keyOf)); assert.equal(idsA.size, a.relationships.length); assert.deepEqual([...idsA].sort(), [...idsB].sort()); }); diff --git a/packages/reader/src/opencode.test.ts b/packages/reader/src/opencode.test.ts index 56fbf27..1a25da8 100644 --- a/packages/reader/src/opencode.test.ts +++ b/packages/reader/src/opencode.test.ts @@ -384,6 +384,201 @@ describe('parseOpencodeSession content capture', () => { }); }); +describe('parseOpencodeSession fidelity (issue #89)', () => { + it('emits per-turn fidelity with full coverage when tokens are fully populated', async () => { + const { turns } = await parseOpencodeSession(sessionFile('simple', 'ses_simple')); + assert.equal(turns.length, 1); + const f = turns[0]!.fidelity; + assert.ok(f, 'fidelity is populated on every emitted turn'); + assert.equal(f!.granularity, 'per-turn'); + // The simple fixture's tokens block carries input/output/reasoning + cache.read/write, + // and OpenCode always exposes tool calls, tool-result events, session relationships, + // and raw content (when contentMode is full) — so the turn classifies as full. + assert.equal(f!.class, 'full'); + assert.deepEqual(f!.coverage, { + hasInputTokens: true, + hasOutputTokens: true, + hasReasoningTokens: true, + hasCacheReadTokens: true, + hasCacheCreateTokens: true, + hasToolCalls: true, + hasToolResultEvents: true, + hasSessionRelationships: true, + hasRawContent: true, + }); + }); + + it('emits partial fidelity for an assistant message with no tokens block', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const tmp = await mkdtemp(path.join(tmpdir(), 'burn-oc-fid-no-tokens-')); + try { + const storage = path.join(tmp, 'storage'); + const sessionDir = path.join(storage, 'session', 'global'); + const msgDir = path.join(storage, 'message', 'ses_no_tokens'); + const partAsstDir = path.join(storage, 'part', 'msg_no_tokens_asst'); + await mkdir(sessionDir, { recursive: true }); + await mkdir(msgDir, { recursive: true }); + await mkdir(partAsstDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'ses_no_tokens.json'), + JSON.stringify({ id: 'ses_no_tokens', directory: '/tmp/proj' }), + ); + await writeFile( + path.join(msgDir, 'msg_no_tokens_asst.json'), + JSON.stringify({ + id: 'msg_no_tokens_asst', + sessionID: 'ses_no_tokens', + role: 'assistant', + providerID: 'anthropic', + modelID: 'claude-haiku-4-5', + time: { created: 1_776_988_001_000 }, + path: { cwd: '/tmp/proj' }, + }), + ); + const { turns } = await parseOpencodeSession( + path.join(sessionDir, 'ses_no_tokens.json'), + ); + assert.equal(turns.length, 1); + const f = turns[0]!.fidelity; + assert.ok(f); + assert.equal(f!.granularity, 'per-turn'); + // Missing input/output → partial (not usage-only, not full). + assert.equal(f!.class, 'partial'); + assert.equal(f!.coverage.hasInputTokens, false); + assert.equal(f!.coverage.hasOutputTokens, false); + assert.equal(f!.coverage.hasReasoningTokens, false); + assert.equal(f!.coverage.hasCacheReadTokens, false); + assert.equal(f!.coverage.hasCacheCreateTokens, false); + // Capability flags are still true — OpenCode would surface these if they + // existed; the turn just doesn't have the data. + assert.equal(f!.coverage.hasToolCalls, true); + assert.equal(f!.coverage.hasToolResultEvents, true); + assert.equal(f!.coverage.hasSessionRelationships, true); + assert.equal(f!.coverage.hasRawContent, true); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + + it('flips hasCacheReadTokens and hasCacheCreateTokens when cache fields are present', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const tmp = await mkdtemp(path.join(tmpdir(), 'burn-oc-fid-cache-')); + try { + const storage = path.join(tmp, 'storage'); + const sessionDir = path.join(storage, 'session', 'global'); + const msgDir = path.join(storage, 'message', 'ses_cache'); + const partAsstDir = path.join(storage, 'part', 'msg_cache_asst'); + await mkdir(sessionDir, { recursive: true }); + await mkdir(msgDir, { recursive: true }); + await mkdir(partAsstDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'ses_cache.json'), + JSON.stringify({ id: 'ses_cache', directory: '/tmp/proj' }), + ); + await writeFile( + path.join(msgDir, 'msg_cache_asst.json'), + JSON.stringify({ + id: 'msg_cache_asst', + sessionID: 'ses_cache', + role: 'assistant', + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + time: { created: 1_776_988_001_000 }, + path: { cwd: '/tmp/proj' }, + tokens: { + input: 100, + output: 50, + cache: { read: 12000, write: 800 }, + }, + }), + ); + const { turns } = await parseOpencodeSession( + path.join(sessionDir, 'ses_cache.json'), + ); + assert.equal(turns.length, 1); + const f = turns[0]!.fidelity; + assert.ok(f); + assert.equal(f!.coverage.hasCacheReadTokens, true); + assert.equal(f!.coverage.hasCacheCreateTokens, true); + // Reasoning was not in the tokens block — coverage should be false even + // though `usage.reasoning` defaults to 0. + assert.equal(f!.coverage.hasReasoningTokens, false); + assert.equal(turns[0]!.usage.reasoning, 0); + // Full required = input + output + cacheRead + capability flags → class is full. + assert.equal(f!.class, 'full'); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); + + it('every emitted turn carries a populated fidelity field across multi-turn sessions', async () => { + // Mirrors what `summarizeFidelity` checks downstream — `unknown === 0` + // means every turn has a non-undefined `fidelity`. + const { turns } = await parseOpencodeSession(sessionFile('multi-turn', 'ses_multi')); + assert.ok(turns.length > 0); + const unknown = turns.filter((t) => !t.fidelity).length; + assert.equal(unknown, 0, 'every OpenCode turn carries fidelity now'); + for (const t of turns) { + assert.equal(t.fidelity!.granularity, 'per-turn'); + } + }); + + it('rolls up coverage from step-finish parts when assistant tokens are partial', async () => { + const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises'); + const { tmpdir } = await import('node:os'); + const tmp = await mkdtemp(path.join(tmpdir(), 'burn-oc-fid-stepfinish-')); + try { + const storage = path.join(tmp, 'storage'); + const sessionDir = path.join(storage, 'session', 'global'); + const msgDir = path.join(storage, 'message', 'ses_sf'); + const partAsstDir = path.join(storage, 'part', 'msg_sf_asst'); + await mkdir(sessionDir, { recursive: true }); + await mkdir(msgDir, { recursive: true }); + await mkdir(partAsstDir, { recursive: true }); + await writeFile( + path.join(sessionDir, 'ses_sf.json'), + JSON.stringify({ id: 'ses_sf', directory: '/tmp/proj' }), + ); + // Assistant message has only input/output; step-finish part carries cache. + await writeFile( + path.join(msgDir, 'msg_sf_asst.json'), + JSON.stringify({ + id: 'msg_sf_asst', + sessionID: 'ses_sf', + role: 'assistant', + providerID: 'anthropic', + modelID: 'claude-sonnet-4-5', + time: { created: 1_776_988_001_000 }, + path: { cwd: '/tmp/proj' }, + tokens: { input: 5, output: 3 }, + }), + ); + await writeFile( + path.join(partAsstDir, 'prt_sf_1.json'), + JSON.stringify({ + id: 'prt_sf_1', + sessionID: 'ses_sf', + messageID: 'msg_sf_asst', + type: 'step-finish', + reason: 'end_turn', + tokens: { input: 5, output: 3, cache: { read: 1000, write: 200 } }, + }), + ); + const { turns } = await parseOpencodeSession(path.join(sessionDir, 'ses_sf.json')); + assert.equal(turns.length, 1); + const f = turns[0]!.fidelity; + assert.ok(f); + // Cache flags rolled up from the step-finish even though `m.tokens` lacked them. + assert.equal(f!.coverage.hasCacheReadTokens, true); + assert.equal(f!.coverage.hasCacheCreateTokens, true); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + }); +}); + describe('parseOpencodeSession user-turn block sizes (issue #86)', () => { it('emits one UserTurnRecord per gap between assistant turns', async () => { const file = sessionFile('user-turn-blocks', 'ses_utb'); diff --git a/packages/reader/src/opencode.ts b/packages/reader/src/opencode.ts index f0d5d85..0df030a 100644 --- a/packages/reader/src/opencode.ts +++ b/packages/reader/src/opencode.ts @@ -2,11 +2,14 @@ import { readFile, readdir } from 'node:fs/promises'; import * as path from 'node:path'; import { classifyActivity } from './classifier.js'; +import { EMPTY_COVERAGE, makeFidelity } from './fidelity.js'; import { resolveProject } from './git.js'; import { argsHash } from './hash.js'; import type { ContentRecord, ContentStoreMode, + Coverage, + Fidelity, Subagent, ToolCall, TurnRecord, @@ -176,7 +179,16 @@ export async function parseOpencodeSessionIncremental( const model = buildModel(m.providerID, m.modelID); const project = m.path?.cwd ?? session.directory; + // Numeric usage is the assistant message's rolled-up totals — OpenCode + // already pre-aggregates step-finish tokens onto `m.tokens`, so we don't + // re-sum or we'd double-count. Coverage *flags* fold both sources together + // (issue #89): a turn whose `m.tokens` lacks cache but whose step-finish + // parts carry cache.read still honestly reports `hasCacheReadTokens: true`. const usage = toUsage(m.tokens); + let usageCoverage = coverageFromTokens(m.tokens); + for (const sf of stepFinishTokens(parts)) { + usageCoverage = mergeUsageCoverage(usageCoverage, coverageFromTokens(sf)); + } const record: TurnRecord = { v: 1, @@ -201,6 +213,7 @@ export async function parseOpencodeSessionIncremental( record.subagent = sub; } if (stopReason !== undefined) record.stopReason = stopReason; + record.fidelity = buildOpencodeFidelity(usageCoverage); const userMessage = findPrecedingUser(users, m.time.created); const userText = userMessage ? await readUserText(storageRoot, userMessage.id) : ''; @@ -598,6 +611,72 @@ function toUsage(t: MessageTokens | undefined): Usage { }; } +// Coverage tracks *whether the upstream message carried a field*, not whether +// its numeric value is non-zero. A `tokens.input: 0` still flips +// `hasInputTokens: true`. OpenCode collapses 5m/1h ephemeral spans into a +// single `cache.write` count, so any presence of `cache.write` flips +// `hasCacheCreateTokens`. +type OpencodeUsageCoverage = Pick< + Coverage, + | 'hasInputTokens' + | 'hasOutputTokens' + | 'hasReasoningTokens' + | 'hasCacheReadTokens' + | 'hasCacheCreateTokens' +>; + +function coverageFromTokens(t: MessageTokens | undefined): OpencodeUsageCoverage { + return { + hasInputTokens: t?.input !== undefined, + hasOutputTokens: t?.output !== undefined, + hasReasoningTokens: t?.reasoning !== undefined, + hasCacheReadTokens: t?.cache?.read !== undefined, + hasCacheCreateTokens: t?.cache?.write !== undefined, + }; +} + +function mergeUsageCoverage( + a: OpencodeUsageCoverage, + b: OpencodeUsageCoverage, +): OpencodeUsageCoverage { + // Coverage is monotonic across the assistant message + its step-finish + // parts: once any source shows a field, the merged turn has it. + return { + hasInputTokens: a.hasInputTokens || b.hasInputTokens, + hasOutputTokens: a.hasOutputTokens || b.hasOutputTokens, + hasReasoningTokens: a.hasReasoningTokens || b.hasReasoningTokens, + hasCacheReadTokens: a.hasCacheReadTokens || b.hasCacheReadTokens, + hasCacheCreateTokens: a.hasCacheCreateTokens || b.hasCacheCreateTokens, + }; +} + +function stepFinishTokens(parts: Part[]): MessageTokens[] { + const out: MessageTokens[] = []; + for (const p of parts) { + if (p.type !== 'step-finish') continue; + const sf = p as StepFinishPart; + if (sf.tokens) out.push(sf.tokens); + } + return out; +} + +function buildOpencodeFidelity(usageCoverage: OpencodeUsageCoverage): Fidelity { + // Numeric usage flags reflect *presence* on the upstream message (or its + // step-finish parts). Capability flags are always true: OpenCode captures + // tool calls, tool-result events (state.output + state.metadata), + // session relationships (session.parentID drives `isSidechain`), and full + // raw content when contentMode === 'full'. + const coverage: Coverage = { + ...EMPTY_COVERAGE, + ...usageCoverage, + hasToolCalls: true, + hasToolResultEvents: true, + hasSessionRelationships: true, + hasRawContent: true, + }; + return makeFidelity('per-turn', coverage); +} + function buildModel(providerID: string | undefined, modelID: string | undefined): string { if (providerID && modelID) return `${providerID}/${modelID}`; return modelID ?? providerID ?? '';