Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion packages/analyze/src/fidelity.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<TurnRecord, 'fidelity'> {
// exactOptionalPropertyTypes refuses `{ fidelity: undefined }` for the
// optional field — only construct the property when we have a value.
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/reader/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 22 additions & 4 deletions packages/reader/src/claude.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});
Expand Down
195 changes: 195 additions & 0 deletions packages/reader/src/opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading