mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-28 18:35:29 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
* sessions: fold subagent runs into PR attribution Sidechain (subagent) session cost never reached the by-PR view, so a session orchestrated on one model with subagent lanes on another showed only the parent model on every PR row. Fold each sidechain's cost, calls, models, and categories into the parent turn that spawned it, so it inherits that turn's PR set under the existing turn-level state machine. Linkage, in priority order: the spawn result's toolUseResult.agentId pairs the child's agent id with the Agent/Task tool_use id that launched it (recorded per turn), which is the true launch point and wins even when the child's first activity landed during a later turn; else the child's first-activity timestamp is bucketed into the containing turn span; else the child folds into the parent's unattributed spend. Children of parents that referenced no PR, and orphans whose parent is absent from the scan, contribute nothing, unchanged. Cache v6 to v7 (neither shipped, so one combined bump from v5): per-turn spawnToolUseIds, per-file parentSessionId and agentSpawnLinks; the validator and the append/compact paths thread them like prRefs. By-PR footers now count parents plus folded subagent runs and the payload gains an additive subagentSessions field. distinctCost now includes folded subagent spend, documented in the payload comment. * sessions: address adversarial review of subagent PR attribution Rework child attribution to resolve each subagent to the PR its launching turn was working on, using the parent's UNFILTERED turn data, and enforce that every dollar is counted exactly once. - Mutual exclusion: a child that referenced its own PR attributes standalone and is never folded; a child with no links is folded only. Fixes a double-charge where a self-linking child was both folded and self-attributed. - Recursion: a fold aggregates a child plus its non-self-linking descendants (depth-first, cycle-guarded), so grandchildren spawned by subagents reach the PR report. - Global linkage: the subagent index keys by parentSessionId alone (UUIDs are globally unique), so a child whose worktree resolves to a different project still links. - Date-range correctness: spawn-to-PR sets are built at assembly from the full turn list, so a spawn in a pre-range turn attributes to the right PR; a PR-linked parent whose own turns fall out of range is kept as a 0-cost fold anchor so its in-range child is not lost. - Timestamp fallback compares epoch ms (mixed UTC offsets order right) and is end-bounded: a child active after the parent's last turn is unlinked (contributes nothing), matching orphan semantics. - Cache adoption tries the newest prior versioned file (v6 then v5) so the preceding build's expired-PR history survives the v7 bump; an invariant note requires the list to cover every version that can exist on disk. - Spawn-result pairing matches the tool_result block that carries the agentId, not the first block, when a record batches several results. - resolveSubagentAttribution is computed once and shared by aggregateByPr and prLinkedTotals. subagentSessions now counts folded subtrees (children plus descendants). Verified on real data: attributed + unattributed reconciles to cost, and parent-only cost plus folded-children cost equals the folded total to the cent (no double-count). * sessions: round-2 hardening of subagent PR attribution Address a second adversarial review of the new machinery. - ID collision: parents and a child's parent reference are keyed by provider + sessionId, not bare sessionId. When two distinct parents still share a key (true duplicate/imported data), the child folds into NEITHER (deterministic skip, stays standalone): correctness over coverage. - Recursion dedup is global: one claimed-set spans all of a parent's direct children, so a descendant reachable through two paths (a diamond or duplicate id) folds exactly once and a parent-link cycle terminates. - Cache adoption migrates every prior version oldest-to-newest and MERGES per source path (newer wins per entry), so a sparse or partial newer file no longer masks older-only expired-PR orphans. - Fold anchors (0-cost PR-linked parents kept only for attribution) live in a new ProjectSummary.subagentAnchors, never in `sessions`, so they no longer contaminate session counts, averages, or any per-session report. Folded PR rows take their date span from the contributing child activity rather than the anchor's empty timestamps. - One-pass buildPrAttribution computes rows and totals together; the payload builder and CLI call it once. Drops the identity-keyed memoization, which could return stale folds if the array was mutated. - Ambiguous multi-block spawn-result pairing leaves the spawn link unset on purpose; the child then folds via the timestamp fallback rather than pairing with the wrong id or disappearing. Every fix is mutation-verified. A fresh real-data drive re-proves the no-double-count identity to the cent (parent-only cost plus folded cost equals the folded total) and that the PR rows sum to attributedCost. * sessions: round-3 hardening of subagent PR attribution Third adversarial review pass. - Ambiguity counts ALL candidate parents (and anchors) sharing a provider+sessionId key, not just PR-bearing ones, and uses a per-record fingerprint: a key carried by more than one DISTINCT record folds its child/subtree into NEITHER (identical duplicates still fold once). This unifies the parent-collision and duplicate-descendant rules and is deterministic across input order. - Project-rebuilding filters (by day, by date range, by config source) now carry subagentAnchors through, and a date filter CONVERTS a spawn parent whose in-range turns are all filtered out into an anchor so a surviving in-range child still folds. Rebuilt sessions also keep their PR + subagent-linkage metadata (prLinks, parentSessionId, spawnPrSets, ...), which buildSessionSummary otherwise drops, so by-PR and folding work on a filtered slice (menubar/dashboard flow). - Fold anchors leave ProjectSummary.sessions entirely and folded PR rows take their span from the child, so 0-cost anchors never touch session counts or averages. - Ambiguous spawn pairing (parent named the agent but its exact launching tool_use could not be paired) is recorded per parent; a late child of such a pairing folds to the parent's last turn within a 30 minute grace window, else stays unlinked. A truly-absent pairing gets no grace. - Row session key is NUL-delimited and provider-prefixed, so a project name or session id containing a space no longer collides and undercounts distinct sessions. Every fix mutation-verified. A fresh real-data drive re-proves the no-double-count identity to the cent, and a day-filtered drive proves the filter fix end to end (anchors created, subagents fold, identity holds). * sessions: round-4 hardening of subagent PR attribution Fourth adversarial review pass. - sessionFingerprint now covers the COMPLETE linkage-relevant payload, not just headline stats: a canonical (sorted-key) serialization of agentSpawnLinks, spawnPrSets, prRefsAtRangeStart, ambiguousSpawnAgentIds, parent/agent identity, and the per-turn prRefs timeline. Two records that share an id and headline stats but map the child to different spawns/PRs now fingerprint DISTINCT, so the ambiguity rule fires and they fold into neither, deterministically rather than order-dependent first-wins. - A date/day filter recomputes prRefsAtRangeStart at the new slice boundary by replaying the original full turn sequence, instead of copying the wide range's value. A PR switch between the wide start and the slice start (July 1 A, July 10 B, slice July 20) now carries B, not a stale A; a turn exactly on the boundary stays in-slice and applies its own refs. The recompute selects by timestamp, so it is order-independent. Non-contiguous day selections are documented as treated contiguous from the earliest selected day (a single session-level seed cannot represent multiple segments; the menubar selection is a single day or a run). - The anchor-carry path drops an anchor that duplicates a surviving session id, so malformed merged input cannot double-count. Also: rebuilt filtered sessions were losing their PR/subagent-linkage metadata (a child its parentSessionId, a parent its prLinks), which carryLinkageFields now restores, so by-PR and folding work on any filtered slice. Every fix mutation-verified. A fresh real-data drive re-proves the no-double-count identity and reconciliation to the cent for both a lifetime scan and a day-filtered slice (anchors created, subagents fold through the filter). * sessions: round-5 hardening of subagent PR attribution Fifth adversarial review pass; closed-form fixes. - sessionFingerprint serializes the COMPLETE fold-determining state via a real recursive canonical encoder: session-level linkage AND, per turn in sequence, timestamp, prRefs, cost, calls, savings, and per-model cost. Object keys are sorted recursively and set-semantic arrays (PR-ref lists, ambiguous ids, spawnPrSets values) are sorted, while the turn list keeps order; the structure is emitted through JSON.stringify (no delimiter concatenation). Two same-id parents that differ only in a turn timestamp now fingerprint DISTINCT (fold neither), and records differing only in set-array order fingerprint EQUAL (no false ambiguity). - recomputeRangeStartPrRefs breaks an exact-same-millisecond tie deterministically by the lexicographically-last sorted-ref key, so the recomputed seed is stable regardless of turn order. - A day filter seeds EACH selected day's first ref-less turn by replaying the original full turn sequence up to that day's start (per-day seeding), so a PR switch on an UNSELECTED day between two selected days carries to the later day. Contiguous and non-contiguous selections are both correct. - The anchor dedupe drops an anchor only when a surviving session shares the full provider-aware, fingerprint-qualified identity (a proven duplicate): a different-provider or different-record same-id session no longer wrongly drops the anchor. Also fixed a double-count the fingerprint test exposed: two duplicate parent sessions share a key and the SAME resolved children, so folding is now done once per parent key. Every fix mutation-verified. Fresh real-data drives (lifetime, single-day, and a NON-CONTIGUOUS day selection) re-prove no-double-count and reconciliation to the cent. --------- Co-authored-by: reviewer <review@local>
374 lines
18 KiB
TypeScript
374 lines
18 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
import { mkdtemp, mkdir, writeFile, appendFile, readFile, rm, stat, unlink } from 'fs/promises'
|
|
import { join } from 'path'
|
|
import { tmpdir } from 'os'
|
|
|
|
// Spy on readSessionLines so a test can PROVE the incremental path activates:
|
|
// only the appended-parse path passes a non-zero `startByteOffset`. The real
|
|
// implementation is preserved; we merely record the offset each call receives.
|
|
const readLineCalls: Array<{ filePath: string; startByteOffset?: number }> = []
|
|
vi.mock('../src/fs-utils.js', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('../src/fs-utils.js')>()
|
|
return {
|
|
...actual,
|
|
readSessionLines: (filePath: string, skip?: unknown, options?: { startByteOffset?: number }) => {
|
|
readLineCalls.push({ filePath, startByteOffset: options?.startByteOffset })
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return (actual.readSessionLines as any)(filePath, skip, options)
|
|
},
|
|
}
|
|
})
|
|
|
|
import { parseAllSessions, clearSessionCache } from '../src/parser.js'
|
|
import { sessionCachePath } from '../src/session-cache.js'
|
|
import type { ProjectSummary } from '../src/types.js'
|
|
|
|
let tmpDir: string
|
|
let projectDir: string
|
|
let sessionPath: string
|
|
const CWD = '/tmp/incr-proj'
|
|
|
|
beforeEach(async () => {
|
|
clearSessionCache()
|
|
readLineCalls.length = 0
|
|
tmpDir = await mkdtemp(join(tmpdir(), 'incr-append-'))
|
|
projectDir = join(tmpDir, 'projects', 'incr-proj')
|
|
await mkdir(projectDir, { recursive: true })
|
|
sessionPath = join(projectDir, 'sess-1.jsonl')
|
|
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
|
|
process.env['CODEBURN_DESKTOP_SESSIONS_DIR'] = join(tmpDir, 'desktop-sessions')
|
|
})
|
|
|
|
afterEach(async () => {
|
|
clearSessionCache()
|
|
delete process.env['CODEBURN_CACHE_DIR']
|
|
await rm(tmpDir, { recursive: true, force: true })
|
|
})
|
|
|
|
// ── fixture builders ───────────────────────────────────────────────────
|
|
|
|
function userLine(ts: string, text: string): string {
|
|
return JSON.stringify({
|
|
type: 'user', sessionId: 'sess-1', timestamp: ts, cwd: CWD,
|
|
message: { role: 'user', content: text },
|
|
})
|
|
}
|
|
|
|
function mcpLine(ts: string, addedNames: string[]): string {
|
|
return JSON.stringify({
|
|
type: 'user', sessionId: 'sess-1', timestamp: ts, cwd: CWD,
|
|
attachment: { type: 'deferred_tools_delta', addedNames },
|
|
})
|
|
}
|
|
|
|
function asstLine(
|
|
id: string,
|
|
ts: string,
|
|
usage: Record<string, number>,
|
|
blocks: Array<Record<string, unknown>> = [],
|
|
model = 'claude-sonnet-4-5',
|
|
): string {
|
|
return JSON.stringify({
|
|
type: 'assistant', sessionId: 'sess-1', timestamp: ts, cwd: CWD,
|
|
message: { id, type: 'message', role: 'assistant', model, content: blocks, usage },
|
|
})
|
|
}
|
|
|
|
const readBlock = (file: string) => ({ type: 'tool_use', name: 'Read', input: { file_path: file } })
|
|
const bashBlock = (cmd: string) => ({ type: 'tool_use', name: 'Bash', input: { command: cmd } })
|
|
|
|
function prLinkLine(ts: string, url: string): string {
|
|
return JSON.stringify({ type: 'pr-link', sessionId: 'sess-1', timestamp: ts, cwd: CWD, prUrl: url })
|
|
}
|
|
|
|
// A representative multi-turn session: MCP inventory, tools, bash, and a
|
|
// streaming re-emit of one assistant message (same id, updated usage) inside a
|
|
// turn — exercises dedup, breakdowns, and turn assembly.
|
|
function baseLines(): string[] {
|
|
return [
|
|
mcpLine('2026-05-01T10:00:00.000Z', ['mcp__ctx__search', 'mcp__ctx__fetch']),
|
|
userLine('2026-05-01T10:00:01.000Z', 'first task please'),
|
|
asstLine('msg-a', '2026-05-01T10:00:02.000Z', { input_tokens: 100, output_tokens: 20 }, [readBlock('/a.ts')]),
|
|
// streaming re-emit of msg-a with grown usage (last one wins)
|
|
asstLine('msg-a', '2026-05-01T10:00:03.000Z', { input_tokens: 100, output_tokens: 55, cache_read_input_tokens: 300 }, [readBlock('/a.ts')]),
|
|
userLine('2026-05-01T10:05:00.000Z', 'second task please'),
|
|
asstLine('msg-b', '2026-05-01T10:05:02.000Z', { input_tokens: 200, output_tokens: 80 }, [bashBlock('ls -la')]),
|
|
]
|
|
}
|
|
|
|
// ── helpers ────────────────────────────────────────────────────────────
|
|
|
|
async function parseWith(cacheDir: string): Promise<ProjectSummary[]> {
|
|
clearSessionCache()
|
|
process.env['CODEBURN_CACHE_DIR'] = cacheDir
|
|
return parseAllSessions()
|
|
}
|
|
|
|
// A cold full re-parse of the file's CURRENT contents, using a pristine cache
|
|
// dir so nothing is served incrementally — the correctness oracle.
|
|
async function coldFullReparse(): Promise<ProjectSummary[]> {
|
|
const freshCache = await mkdtemp(join(tmpdir(), 'incr-cold-'))
|
|
try {
|
|
return await parseWith(freshCache)
|
|
} finally {
|
|
await rm(freshCache, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
function offsetsFor(path: string): Array<number | undefined> {
|
|
return readLineCalls.filter(c => c.filePath === path).map(c => c.startByteOffset)
|
|
}
|
|
|
|
// ── tests ──────────────────────────────────────────────────────────────
|
|
|
|
describe('incremental append parsing', () => {
|
|
it('CORE: warm append merge deep-equals a cold full re-parse (with torn final line)', async () => {
|
|
const warmCache = await mkdtemp(join(tmpdir(), 'incr-warm-'))
|
|
|
|
// 1) cold parse of the base file → warm cache seeded with offset+turns.
|
|
await writeFile(sessionPath, baseLines().join('\n') + '\n')
|
|
await parseWith(warmCache)
|
|
|
|
const cachedOffset: number = JSON.parse(await readFile(sessionCachePath(), 'utf-8'))
|
|
.providers.claude.files[sessionPath].lastCompleteLineOffset
|
|
expect(cachedOffset).toBeGreaterThan(0)
|
|
|
|
// 2) append a new complete turn plus a torn (invalid JSON, no newline) tail.
|
|
const appended =
|
|
userLine('2026-05-01T11:00:00.000Z', 'third task please') + '\n' +
|
|
asstLine('msg-c', '2026-05-01T11:00:02.000Z', { input_tokens: 300, output_tokens: 90 }, [readBlock('/c.ts'), bashBlock('grep x')]) + '\n' +
|
|
'{"type":"assistant","sessionId":"sess-1","timestamp":"2026-05-01T11:05' // torn: invalid + no newline
|
|
await appendFile(sessionPath, appended)
|
|
|
|
// 3) warm parse (same cache) → must take the incremental path.
|
|
readLineCalls.length = 0
|
|
const warm = await parseWith(warmCache)
|
|
|
|
// proof: the session file was read from the cached offset, not byte 0.
|
|
expect(offsetsFor(sessionPath)).toContain(cachedOffset)
|
|
|
|
// 4) oracle: cold full re-parse of the identical file.
|
|
const cold = await coldFullReparse()
|
|
|
|
expect(warm).toEqual(cold)
|
|
await rm(warmCache, { recursive: true, force: true })
|
|
})
|
|
|
|
it('PR-REFS: survive the incremental append path (continuation merge unions refs)', async () => {
|
|
const warmCache = await mkdtemp(join(tmpdir(), 'incr-pr-'))
|
|
// Base: one turn that creates PR-1.
|
|
await writeFile(sessionPath,
|
|
userLine('2026-05-01T10:00:01.000Z', 'ship PR one') + '\n' +
|
|
asstLine('msg-a', '2026-05-01T10:00:02.000Z', { input_tokens: 100, output_tokens: 20 }, [bashBlock('gh pr create')]) + '\n' +
|
|
prLinkLine('2026-05-01T10:00:03.000Z', 'https://github.com/o/r/pull/1') + '\n')
|
|
await parseWith(warmCache)
|
|
|
|
// Append a continuation of that same turn (no leading user message) that
|
|
// references PR-2, then a fresh turn that references PR-3.
|
|
await appendFile(sessionPath,
|
|
asstLine('msg-b', '2026-05-01T10:00:04.000Z', { input_tokens: 50, output_tokens: 10 }, [bashBlock('gh pr create')]) + '\n' +
|
|
prLinkLine('2026-05-01T10:00:05.000Z', 'https://github.com/o/r/pull/2') + '\n' +
|
|
userLine('2026-05-01T10:10:00.000Z', 'ship PR three') + '\n' +
|
|
asstLine('msg-c', '2026-05-01T10:10:02.000Z', { input_tokens: 80, output_tokens: 20 }, [bashBlock('gh pr create')]) + '\n' +
|
|
prLinkLine('2026-05-01T10:10:03.000Z', 'https://github.com/o/r/pull/3') + '\n')
|
|
|
|
readLineCalls.length = 0
|
|
const warm = await parseWith(warmCache)
|
|
expect(offsetsFor(sessionPath).some(o => o !== undefined && o > 0)).toBe(true) // took the append path
|
|
const cold = await coldFullReparse()
|
|
expect(warm).toEqual(cold)
|
|
|
|
const turns = warm[0]!.sessions[0]!.turns
|
|
expect(turns[0]!.prRefs).toEqual(['https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2'])
|
|
expect(turns[1]!.prRefs).toEqual(['https://github.com/o/r/pull/3'])
|
|
await rm(warmCache, { recursive: true, force: true })
|
|
})
|
|
|
|
it('PR-REFS: survive when a straddled append falls back to a full re-parse', async () => {
|
|
const warmCache = await mkdtemp(join(tmpdir(), 'incr-pr2-'))
|
|
await writeFile(sessionPath,
|
|
userLine('2026-05-01T10:00:01.000Z', 'ship PR one') + '\n' +
|
|
asstLine('msg-a', '2026-05-01T10:00:02.000Z', { input_tokens: 100, output_tokens: 20 }, [bashBlock('gh pr create')]) + '\n' +
|
|
prLinkLine('2026-05-01T10:00:03.000Z', 'https://github.com/o/r/pull/1') + '\n')
|
|
await parseWith(warmCache)
|
|
|
|
// Re-emit msg-a (an id already committed in the cached prefix) -> straddle ->
|
|
// the shortcut is abandoned and the file re-parses from byte 0.
|
|
await appendFile(sessionPath,
|
|
asstLine('msg-a', '2026-05-01T10:00:02.500Z', { input_tokens: 100, output_tokens: 40 }, [bashBlock('gh pr create')]) + '\n' +
|
|
prLinkLine('2026-05-01T10:00:06.000Z', 'https://github.com/o/r/pull/2') + '\n')
|
|
|
|
const warm = await parseWith(warmCache)
|
|
const cold = await coldFullReparse()
|
|
expect(warm).toEqual(cold)
|
|
expect(warm[0]!.sessions[0]!.turns[0]!.prRefs).toEqual([
|
|
'https://github.com/o/r/pull/1', 'https://github.com/o/r/pull/2',
|
|
])
|
|
await rm(warmCache, { recursive: true, force: true })
|
|
})
|
|
|
|
it('SUBAGENT-LINKS: spawnToolUseIds + agentSpawnLinks survive the incremental append path', async () => {
|
|
const warmCache = await mkdtemp(join(tmpdir(), 'incr-spawn-'))
|
|
const agentBlock = (id: string) => ({ type: 'tool_use', name: 'Agent', id, input: {} })
|
|
const spawnResultLine = (ts: string, toolUseId: string, agentId: string): string =>
|
|
JSON.stringify({
|
|
type: 'user', sessionId: 'sess-1', timestamp: ts, cwd: CWD,
|
|
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, content: 'agent done' }] },
|
|
toolUseResult: { status: 'completed', agentId },
|
|
})
|
|
// Base: one turn spawns agent1; its result records the agentId->tool_use link.
|
|
await writeFile(sessionPath,
|
|
userLine('2026-05-01T10:00:01.000Z', 'launch a reviewer') + '\n' +
|
|
asstLine('msg-a', '2026-05-01T10:00:02.000Z', { input_tokens: 100, output_tokens: 20 }, [agentBlock('toolu_a1')]) + '\n' +
|
|
spawnResultLine('2026-05-01T10:00:03.000Z', 'toolu_a1', 'agent1') + '\n')
|
|
await parseWith(warmCache)
|
|
|
|
// Append: a continuation of that same turn spawns agent2 (merged into turn 0),
|
|
// then a fresh turn spawns agent3.
|
|
await appendFile(sessionPath,
|
|
asstLine('msg-b', '2026-05-01T10:00:04.000Z', { input_tokens: 50, output_tokens: 10 }, [agentBlock('toolu_a2')]) + '\n' +
|
|
spawnResultLine('2026-05-01T10:00:05.000Z', 'toolu_a2', 'agent2') + '\n' +
|
|
userLine('2026-05-01T10:10:00.000Z', 'launch another') + '\n' +
|
|
asstLine('msg-c', '2026-05-01T10:10:02.000Z', { input_tokens: 80, output_tokens: 20 }, [agentBlock('toolu_a3')]) + '\n' +
|
|
spawnResultLine('2026-05-01T10:10:03.000Z', 'toolu_a3', 'agent3') + '\n')
|
|
|
|
readLineCalls.length = 0
|
|
const warm = await parseWith(warmCache)
|
|
expect(offsetsFor(sessionPath).some(o => o !== undefined && o > 0)).toBe(true) // took the append path
|
|
const cold = await coldFullReparse()
|
|
expect(warm).toEqual(cold)
|
|
|
|
const session = warm[0]!.sessions[0]!
|
|
expect(session.turns[0]!.spawnToolUseIds).toEqual(['toolu_a1', 'toolu_a2'])
|
|
expect(session.turns[1]!.spawnToolUseIds).toEqual(['toolu_a3'])
|
|
expect(session.agentSpawnLinks).toEqual({ agent1: 'toolu_a1', agent2: 'toolu_a2', agent3: 'toolu_a3' })
|
|
await rm(warmCache, { recursive: true, force: true })
|
|
})
|
|
|
|
it('EDGE: append after a previously-torn line completes still equals cold', async () => {
|
|
const warmCache = await mkdtemp(join(tmpdir(), 'incr-warm2-'))
|
|
|
|
// Base file whose LAST line is a complete assistant entry WITHOUT a trailing
|
|
// newline — cached as a turn, but the resume offset sits before it.
|
|
const tail = asstLine('msg-b', '2026-05-01T10:05:02.000Z', { input_tokens: 200, output_tokens: 80 }, [bashBlock('ls -la')])
|
|
const head = baseLines().slice(0, 5).join('\n') + '\n'
|
|
await writeFile(sessionPath, head + tail) // no trailing newline
|
|
await parseWith(warmCache)
|
|
|
|
// Complete the boundary (newline terminates the former tail) and append more.
|
|
// msg-b is re-read from the offset -> must dedup against the cached copy.
|
|
const more = '\n' +
|
|
asstLine('msg-c', '2026-05-01T11:00:02.000Z', { input_tokens: 300, output_tokens: 90 }, [readBlock('/c.ts')]) + '\n'
|
|
await appendFile(sessionPath, more)
|
|
|
|
const warm = await parseWith(warmCache)
|
|
const cold = await coldFullReparse()
|
|
expect(warm).toEqual(cold)
|
|
await rm(warmCache, { recursive: true, force: true })
|
|
})
|
|
|
|
it('EDGE: only a torn partial appended (no new complete line) equals cold', async () => {
|
|
const warmCache = await mkdtemp(join(tmpdir(), 'incr-warm3-'))
|
|
await writeFile(sessionPath, baseLines().join('\n') + '\n')
|
|
await parseWith(warmCache)
|
|
|
|
// Append only an incomplete line (no newline) — nothing new to commit yet.
|
|
await appendFile(sessionPath, '{"type":"assistant","partial":true')
|
|
const warm = await parseWith(warmCache)
|
|
const cold = await coldFullReparse()
|
|
expect(warm).toEqual(cold)
|
|
await rm(warmCache, { recursive: true, force: true })
|
|
})
|
|
|
|
it('EDGE: file replaced (inode change) falls back to a full re-parse', async () => {
|
|
const warmCache = await mkdtemp(join(tmpdir(), 'incr-warm4-'))
|
|
await writeFile(sessionPath, baseLines().join('\n') + '\n')
|
|
await parseWith(warmCache)
|
|
const inoBefore = (await stat(sessionPath)).ino
|
|
|
|
// Replace the file (new inode) with different, LARGER content.
|
|
await unlink(sessionPath)
|
|
const replaced = [
|
|
...baseLines(),
|
|
userLine('2026-05-01T12:00:00.000Z', 'brand new task'),
|
|
asstLine('msg-z', '2026-05-01T12:00:02.000Z', { input_tokens: 500, output_tokens: 120 }, [readBlock('/z.ts')]),
|
|
].join('\n') + '\n'
|
|
await writeFile(sessionPath, replaced)
|
|
expect((await stat(sessionPath)).ino).not.toBe(inoBefore)
|
|
|
|
readLineCalls.length = 0
|
|
const warm = await parseWith(warmCache)
|
|
// inode changed => modified => full re-parse from byte 0, never an offset.
|
|
expect(offsetsFor(sessionPath).every(o => o === undefined || o === 0)).toBe(true)
|
|
|
|
const cold = await coldFullReparse()
|
|
expect(warm).toEqual(cold)
|
|
await rm(warmCache, { recursive: true, force: true })
|
|
})
|
|
|
|
it('EDGE: cached offset beyond current EOF falls back to a full re-parse', async () => {
|
|
const warmCache = await mkdtemp(join(tmpdir(), 'incr-warm5-'))
|
|
await writeFile(sessionPath, baseLines().join('\n') + '\n')
|
|
await parseWith(warmCache)
|
|
|
|
// Corrupt the persisted offset to point far beyond the file, then grow it.
|
|
const cachePath = sessionCachePath()
|
|
const cache = JSON.parse(await readFile(cachePath, 'utf-8'))
|
|
cache.providers.claude.files[sessionPath].lastCompleteLineOffset = 10_000_000
|
|
await writeFile(cachePath, JSON.stringify(cache))
|
|
|
|
await appendFile(sessionPath,
|
|
userLine('2026-05-01T13:00:00.000Z', 'grow the file') + '\n' +
|
|
asstLine('msg-y', '2026-05-01T13:00:02.000Z', { input_tokens: 400, output_tokens: 100 }, [bashBlock('pwd')]) + '\n')
|
|
|
|
readLineCalls.length = 0
|
|
const warm = await parseWith(warmCache)
|
|
// guard: never resume from the stranded offset.
|
|
expect(offsetsFor(sessionPath).some(o => o === 10_000_000)).toBe(false)
|
|
|
|
const cold = await coldFullReparse()
|
|
expect(warm).toEqual(cold)
|
|
await rm(warmCache, { recursive: true, force: true })
|
|
})
|
|
})
|
|
|
|
describe('straddle guard (streamed id restated across the append boundary)', () => {
|
|
// A streamed assistant id whose first emission sits in the committed prefix
|
|
// (and NOT in the last cached turn) can be restated in the appended region.
|
|
// The boundary merge would splice it into the wrong turn and count it twice;
|
|
// the guard must abandon the shortcut and re-parse the file from byte 0,
|
|
// matching the cold-parse oracle exactly.
|
|
it('falls back to a full re-parse when an appended id already exists in the cached turns', async () => {
|
|
const cacheDir = await mkdtemp(join(tmpdir(), 'incr-straddle-'))
|
|
try {
|
|
await writeFile(sessionPath, baseLines().join('\n') + '\n')
|
|
await parseWith(cacheDir)
|
|
|
|
// Restatement of msg-a (first turn's id, grown usage) followed by a new
|
|
// turn — the shape image-heavy sessions produce while streaming.
|
|
const appended = [
|
|
asstLine('msg-a', '2026-05-01T10:06:00.000Z', { input_tokens: 100, output_tokens: 90, cache_read_input_tokens: 300 }, [readBlock('/a.ts')]),
|
|
userLine('2026-05-01T10:07:00.000Z', 'third task please'),
|
|
asstLine('msg-c', '2026-05-01T10:07:02.000Z', { input_tokens: 50, output_tokens: 10 }, []),
|
|
]
|
|
await appendFile(sessionPath, appended.join('\n') + '\n')
|
|
|
|
readLineCalls.length = 0
|
|
const incremental = await parseWith(cacheDir)
|
|
const oracle = await coldFullReparse()
|
|
|
|
const sum = (ps: ProjectSummary[]) => ({
|
|
calls: ps.reduce((s, p) => s + p.totalApiCalls, 0),
|
|
cost: ps.reduce((s, p) => s + p.totalCostUSD, 0),
|
|
turns: ps.flatMap(p => p.sessions).reduce((s, x) => s + x.turns.length, 0),
|
|
})
|
|
expect(sum(incremental)).toEqual(sum(oracle))
|
|
|
|
// The guard must have re-read the file from byte 0, not the append offset.
|
|
const offsets = offsetsFor(sessionPath)
|
|
expect(offsets.some(o => o === undefined || o === 0)).toBe(true)
|
|
} finally {
|
|
await rm(cacheDir, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|