fix(dsh): correct usage attribution against the real session format

Reviewed src/providers/dsh.ts against deepseek-harness @ 99f6f02f and fixed
what the format says but the parser did not:

- A forked session's log replays its parent's events verbatim, and codeburn
  parses the parent's own log as its own session, so every inherited call was
  billed twice. The header's parentSession + seedLength mark that prefix;
  events with seq < seedLength are now skipped.
- The model now comes from the reporting assistant/message's own
  message.source, which is what actually served the step. request/header only
  describes the request DSH was about to make, and is the fallback.
- user/message also carries agent-injected context (runtime snapshots, skill
  bodies) under source.kind 'plugin'; only a typed prompt becomes the preview,
  and it is bounded to 500 chars like every other provider rather than holding
  a whole injected system prompt per turn.
- A log stamped with a session format version other than 0 is skipped with a
  notice. The format is pinned at 0 upstream with no compatibility implied, so
  reading a bumped format under today's assumptions would report confident
  wrong numbers.
- Timestamps go through the seconds-vs-milliseconds guard and fall back to the
  header createdAt, so a call can no longer carry an empty timestamp and land
  in the undated cache shard.
- The compressed read buffers the whole log to scan its frames, so it now takes
  the same oversize guard readSessionFile applies to the uncompressed variant.
- The zstd-unavailable notice fired once per session log; each distinct notice
  is now emitted once.
- Emit workingDirectory beside projectPath, as codex does.

Tests add the upstream examples/acp-agent snapshot as a fixture, covering the
real record shapes: packed reasoning-chunks/tool-call-chunks storage rows, a
plugin-injected user/message beside the typed one, and both the streamed usage
chunk and the final assistant/message usage for the same step. Plus the same
snapshot re-encoded as multi-frame zstd with a torn tail (identical output), a
forked session, an unsupported format version, and unparsable lines.
This commit is contained in:
iamtoruk 2026-08-17 10:59:52 -07:00
parent 56e291fc7c
commit 4fa16a2293
4 changed files with 258 additions and 11 deletions

View file

@ -3,7 +3,7 @@ import { join } from 'path'
import { homedir } from 'os'
import zlib from 'zlib'
import { readSessionFile } from '../fs-utils.js'
import { MAX_SESSION_FILE_BYTES, readSessionFile } from '../fs-utils.js'
import { calculateCost, getShortModelName } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
@ -23,6 +23,23 @@ const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }
const ZSTD_MAGIC = 0xfd2fb528
// SESSION_FORMAT_VERSION in @deepseek-ai/dsh-session. DSH refuses to load a log
// stamped with any other version, and a bump means an event's meaning changed,
// so a foreign version is skipped rather than read with today's assumptions.
const SESSION_FORMAT_VERSION = 0
const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000
// Discovery walks every session, so a per-file notice would repeat once per
// log; each distinct message is worth saying exactly once.
const noticed = new Set<string>()
function notice(message: string): void {
if (noticed.has(message)) return
noticed.add(message)
process.stderr.write(message)
}
type ZstdFrame = { start: number; end: number }
// Locate complete frames without decompressing their blocks. An EOF inside the
@ -87,13 +104,21 @@ type DshEvent = {
seq?: number
time?: number
// Session header fields live at the top level of the first event.
version?: number
id?: string
cwd?: string
createdAt?: number
parentSession?: string
seedLength?: number
data?: {
turn?: number
step?: number
content?: Array<{ type?: string; text?: string }>
// `user/message` carries the message author: a real prompt is
// `{ kind: 'user' }`, agent-injected context is `{ kind: 'plugin' }`.
source?: { kind?: string }
header?: { config?: { model?: string; provider?: string } }
message?: { source?: { kind?: string; model?: string; provider?: string } }
chunk?: { type?: string; usage?: DshUsage }
usage?: DshUsage
name?: string
@ -109,8 +134,9 @@ type StepBucket = {
// projection). Time follows the winning report.
final: boolean
time?: number
// Model in force when this step's usage was reported (the most recent
// request/header config at that point in the log).
// Model that produced this step: the reporting assistant/message's own
// `message.source` when it names one, else the most recent request/header
// config (a header can change the model mid-turn between steps).
model: string
tools: string[]
skills: string[]
@ -138,6 +164,25 @@ function mapToolName(raw: string): string {
return toolNameMap[raw] ?? raw
}
// A log stamped with a version this parser was not written against is skipped
// whole: a bump means an event's meaning changed, so reading it with today's
// assumptions would report confident wrong numbers.
function isReadableVersion(header: DshEvent, filePath: string): boolean {
if (header.version === SESSION_FORMAT_VERSION) return true
notice(`codeburn: skipping DSH session ${filePath}: unsupported session format version ${String(header.version)}; upgrade codeburn.\n`)
return false
}
// DSH writes epoch milliseconds; promote a seconds-resolution value and reject
// what stays implausible, matching the guard cline-cli.ts uses on the hazard.
function isoTimestamp(value: number | undefined, fallback: string): string {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback
const ms = value < MIN_REASONABLE_TIMESTAMP_MS ? value * 1000 : value
const date = new Date(ms)
if (Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS) return fallback
return date.toISOString()
}
function getDshHome(override?: string): string {
// An empty-string DSH_HOME is treated as unset.
return override ?? (process.env['DSH_HOME'] || undefined) ?? join(homedir(), '.dsh')
@ -165,11 +210,18 @@ function* readZstdLines(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): G
async function readEventLines(filePath: string): Promise<string[] | null> {
if (filePath.endsWith('.zstd')) {
if (!zstdDecompress) {
process.stderr.write('codeburn: DSH sessions need Node >= 22.15 (zstd support); skipping DSH usage.\n')
notice('codeburn: DSH sessions need Node >= 22.15 (zstd support); skipping DSH usage.\n')
return null
}
let buffer: Buffer
try {
// The whole log is buffered to scan its frames, so it needs the same
// oversize guard readSessionFile applies to the uncompressed variant.
const size = (await stat(filePath)).size
if (size > MAX_SESSION_FILE_BYTES) {
notice(`codeburn: skipped oversize DSH session log ${filePath} (${size} bytes)\n`)
return null
}
buffer = await readFile(filePath)
} catch {
return null
@ -177,7 +229,7 @@ async function readEventLines(filePath: string): Promise<string[] | null> {
try {
return [...readZstdLines(buffer)]
} catch (err) {
process.stderr.write(`codeburn: skipped corrupt DSH session log ${filePath}: ${err instanceof Error ? err.message : err}\n`)
notice(`codeburn: skipped corrupt DSH session log ${filePath}: ${err instanceof Error ? err.message : err}\n`)
return null
}
}
@ -230,7 +282,8 @@ async function readSessionHeader(filePath: string): Promise<DshEvent | null> {
const line = await firstLine()
if (!line) return null
const event = JSON.parse(line) as DshEvent
return event.type === 'session' ? event : null
if (event.type !== 'session') return null
return isReadableVersion(event, filePath) ? event : null
} catch {
return null
}
@ -307,6 +360,11 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
let cwd = ''
let model = 'unknown'
let currentTurn = 0
let sessionStart = ''
// Events a forked session inherited from its parent. They are a verbatim
// copy of the parent's log, which codeburn parses as its own session, so
// counting them here would bill the same calls twice.
let seedLength = 0
const userMessageByTurn = new Map<number, string>()
const buckets = new Map<string, StepBucket>()
@ -319,11 +377,18 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
}
if (event.type === 'session') {
if (!isReadableVersion(event, source.path)) return
sessionId = event.id ?? sessionId
cwd = event.cwd ?? cwd
sessionStart = isoTimestamp(event.createdAt, sessionStart)
if (typeof event.parentSession === 'string' && event.parentSession && typeof event.seedLength === 'number') {
seedLength = event.seedLength
}
continue
}
if (typeof event.seq === 'number' && event.seq < seedLength) continue
if (event.type === 'turn/start') {
currentTurn = event.data?.turn ?? currentTurn
continue
@ -338,10 +403,15 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
}
if (event.type === 'user/message') {
// Plugin-injected context (runtime snapshots, skill bodies, file-change
// notices) rides the same event type as a typed prompt; only the latter
// is a useful preview.
if (event.data?.source?.kind !== 'user') continue
if (userMessageByTurn.has(currentTurn)) continue
const texts = (event.data?.content ?? [])
.filter(c => c.type === 'text' && typeof c.text === 'string' && c.text)
.map(c => c.text!)
if (texts.length > 0) userMessageByTurn.set(currentTurn, texts.join(' '))
if (texts.length > 0) userMessageByTurn.set(currentTurn, texts.join(' ').slice(0, 500))
continue
}
@ -369,11 +439,16 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
let usage: DshUsage | undefined
let isFinal = false
// The model that actually served the call, when the message records it.
// request/header only describes the request codeburn is about to see.
let reportedModel = model
if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage') {
usage = event.data.chunk.usage
} else if (event.type === 'assistant/message' && event.data?.usage) {
usage = event.data.usage
isFinal = true
const messageModel = event.data.message?.source?.model
if (typeof messageModel === 'string' && messageModel) reportedModel = messageModel
} else {
continue
}
@ -394,7 +469,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
bucket.usage = usage
bucket.final = isFinal
bucket.time = event.time
bucket.model = model
bucket.model = reportedModel
}
}
@ -435,13 +510,14 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
tools: [...new Set(bucket.tools)],
bashCommands: bucket.bashCommands,
skills: bucket.skills.length > 0 ? [...new Set(bucket.skills)] : undefined,
timestamp: typeof bucket.time === 'number' ? new Date(bucket.time).toISOString() : '',
timestamp: isoTimestamp(bucket.time, sessionStart),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: userMessageByTurn.get(turn!) ?? '',
sessionId: sessionId || source.path,
project: cwd ? projectFromCwd(cwd, source.project) : source.project,
projectPath: cwd || undefined,
workingDirectory: cwd || undefined,
}
}
},

View file

@ -285,7 +285,10 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// input/cache rollup; this bump re-parses them so the missing tokens land.
copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1',
grok: 'estimated-cost-v1',
dsh: 'v1',
// seed-aware-v1: the parser now skips the parent events a forked session
// replays (double-counted before), takes the model from the reporting
// assistant/message, and keeps agent-injected context out of the preview.
dsh: 'seed-aware-v1',
hermes: 'reasoning-output-accounting-v1-est-cost',
'lingtai-tui': 'token-ledger-registry-activity-v3',
'ibm-bob': 'worktree-project-grouping-v1',

35
tests/fixtures/dsh/bash-tool-turn.jsonl vendored Normal file
View file

@ -0,0 +1,35 @@
{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/home/u/proj","delegationDepth":0}
{"type":"agent/inbox/spliced","seq":0,"time":1785498771334,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"38694db6-921d-41fd-b1fb-3b0c40caf67c"}]}}
{"type":"turn/start","seq":1,"time":1785821375023,"data":{"turn":1}}
{"type":"agent/inbox/spliced","seq":2,"time":1785821375023,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
{"type":"step/start","seq":3,"time":1783352050755,"data":{"turn":1,"step":1}}
{"type":"user/message","seq":4,"time":1785498771360,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"38694db6-921d-41fd-b1fb-3b0c40caf67c"},"surfaceOp":"append"}
{"type":"user/message","seq":5,"time":1785730424635,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"80474489-442a-4e98-beef-df6cd1e85870"},"surfaceOp":"append"}
{"type":"session/title","seq":6,"time":1785730424635,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}}
{"type":"request/header","seq":7,"time":1785498771361,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"you are dsh","tools":[]},"reason":"initial"}}
{"type":"request/context","seq":8,"time":1785730424636,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"assistant/chunk","seq":9,"time":1783352051590,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":10,"time0":1783352051618,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,0,26,30,0,0,1,0,27,1,0,0,0,86,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]}}
{"type":"assistant/chunk","seq":28,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
{"type":"tool-call-chunks","seq0":29,"time0":1783352051820,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,28,0,0,0,29,0,0,28,1,0,29,0,0,0,32,0,0,0,0,0,74,0,0,13,0,63,1],"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," TER","MIN","AL","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," TER","MIN","AL","_OK"," to"," verify"," terminal"," access","\"","}"]}}
{"type":"assistant/chunk","seq":60,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."}}}}
{"type":"assistant/chunk","seq":61,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}}
{"type":"assistant/chunk","seq":62,"time":1785498771373,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}}
{"type":"assistant/chunk","seq":63,"time":1785730424645,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":64,"time":1785730424645,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a855246-fbf6-4f91-87b4-c6f1889effe7"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"}
{"type":"tool/call","seq":65,"time":1785730424646,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}
{"type":"tool/result","seq":66,"time":1785730424665,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233"},"content":[{"type":"tool-result","toolCallId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false}],"role":"user","id":"908ca4f5-efbb-443b-9b07-acbf25edf954"}},"sourceEventSeqs":[65],"surfaceOp":"append"}
{"type":"step/end","seq":67,"time":1785730424665,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":68,"time":1785730424676,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":69,"time":1783352052780,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
{"type":"reasoning-chunks","seq0":70,"time0":1783352052809,"data":{"turn":1,"step":2,"index":0,"dt":[29,0,0,29,0,0,0,0,0,28,1,28,1,0,0,32,0,0,0,0,0],"texts":["The"," command"," ran"," successfully"," and"," output"," \"","TER","MIN","AL","_OK","\"."," I"," should"," now"," reply"," with"," just"," \"","D","ONE","\"."]}}
{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
{"type":"assistant/chunk","seq":94,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
{"type":"assistant/chunk","seq":95,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."}}}}
{"type":"assistant/chunk","seq":96,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
{"type":"assistant/chunk","seq":97,"time":1785498771406,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}}
{"type":"assistant/chunk","seq":98,"time":1785730424681,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
{"type":"assistant/message","seq":99,"time":1785730424681,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"aa705bf0-9b5b-4af3-9763-dbf93c98e4c4"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98],"surfaceOp":"append"}
{"type":"step/end","seq":100,"time":1785730424682,"data":{"turn":1,"step":2}}
{"type":"turn/end","seq":101,"time":1785730424682,"data":{"turn":1,"reason":{"kind":"completed"}}}

View file

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'fs/promises'
import { join } from 'path'
import { homedir, tmpdir } from 'os'
import zlib from 'zlib'
@ -404,3 +404,136 @@ describe('dsh provider - display names', () => {
expect(provider.toolDisplayName('cordis_run')).toBe('cordis_run')
})
})
describe('dsh provider - real log fidelity', () => {
// The upstream snapshot from deepseek-ai/deepseek-harness
// (examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl), with its
// template placeholders filled in. It is the reference for every shape the
// parser reads: packed `reasoning-chunks`/`tool-call-chunks` storage rows, a
// plugin-injected user/message beside the typed one, and both the streamed
// usage chunk and the final assistant/message usage for the same step.
async function writeRealSession(): Promise<string> {
const lines = (await readFile(join(import.meta.dirname, '../fixtures/dsh/bash-tool-turn.jsonl'), 'utf-8'))
.split('\n').filter(l => l.trim())
return writePlainSession('--home-u-proj--', 'e128dda9-ed11-4868-8266-0ef90d03c3d6', lines)
}
it('parses the upstream snapshot: two steps, exact usage, model from the message source', async () => {
const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession())
expect(calls).toHaveLength(2)
expect(calls.map(c => c.model)).toEqual(['deepseek-v4-flash', 'deepseek-v4-flash'])
expect(calls[0]).toMatchObject({
inputTokens: 2877,
outputTokens: 90,
cacheReadInputTokens: 0,
reasoningTokens: 18,
sessionId: 'e128dda9-ed11-4868-8266-0ef90d03c3d6',
project: 'proj',
projectPath: '/home/u/proj',
workingDirectory: '/home/u/proj',
})
expect(calls[1]).toMatchObject({ inputTokens: 168, outputTokens: 25, cacheReadInputTokens: 2816, reasoningTokens: 22 })
// Reasoning bills at the output rate, so it must not appear as input.
expect(calls[0]!.costUSD).toBe(calculateCost('deepseek-v4-flash', 2877, 90 + 18, 0, 0, 0))
expect(calls[0]!.costUSD).toBeGreaterThan(0)
})
it('takes the typed prompt as the preview, not the plugin-injected context', async () => {
const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession())
expect(calls[0]!.userMessage).toBe('Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop.')
expect(calls[0]!.userMessage).not.toContain('Current runtime context')
})
it('reads the tool call through the packed chunk rows around it', async () => {
const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession())
expect(calls[0]!.tools).toEqual(['Bash'])
expect(calls[0]!.bashCommands).toEqual(['echo'])
})
})
describe('dsh provider - defensive reads', () => {
it('skips a log stamped with an unsupported session format version', async () => {
const filePath = await writePlainSession('--home-u-proj--', 'session-future', [
JSON.stringify({ type: 'session', version: 1, id: 'session-future', createdAt: 1786707336131, cwd: '/home/u/proj', delegationDepth: 0 }),
chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000),
])
expect(await createDshProvider(tmpDir).discoverSessions()).toEqual([])
expect(await parseAll(createDshProvider(tmpDir), filePath)).toEqual([])
})
it('does not bill a forked session for the events it inherited from its parent', async () => {
const filePath = await writePlainSession('--home-u-proj--', 'session-fork', [
JSON.stringify({
type: 'session', version: 0, id: 'session-fork', createdAt: 1786707336131,
cwd: '/home/u/proj', parentSession: 'session-parent', seedLength: 3, delegationDepth: 0,
}),
// seq 0..2 are a verbatim copy of the parent's log, which codeburn parses
// as its own session; only seq >= 3 is this session's own work.
JSON.stringify({ type: 'turn/start', seq: 0, time: 1786707337000, data: { turn: 1 } }),
JSON.stringify({ type: 'assistant/message', seq: 1, time: 1786707337100, data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 9999, outputTokens: 999 } } }),
JSON.stringify({ type: 'session/end-seed', seq: 2, time: 1786707337200, data: {} }),
JSON.stringify({ type: 'turn/start', seq: 3, time: 1786707338000, data: { turn: 2 } }),
JSON.stringify({ type: 'assistant/message', seq: 4, time: 1786707338100, data: { turn: 2, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 100, outputTokens: 10 } } }),
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(100)
})
it('ignores unknown event types, packed chunk rows, and unparsable lines', async () => {
const filePath = await writePlainSession('--home-u-proj--', 'session-noise', [
sessionHeader({ id: 'session-noise', cwd: '/home/u/proj' }),
JSON.stringify({ type: 'agent/inbox/spliced', seq: 0, time: 1786707337000, data: { target: 'next-turn' } }),
JSON.stringify({ type: 'reasoning-chunks', seq0: 1, time0: 1786707337100, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b'] } }),
'{ not json at all',
' ',
chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000),
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(100)
})
it('falls back to the header createdAt when a usage event carries no usable time', async () => {
const filePath = await writePlainSession('--home-u-proj--', 'session-notime', [
JSON.stringify({ type: 'session', version: 0, id: 'session-notime', createdAt: 1786707336131, cwd: '/home/u/proj', delegationDepth: 0 }),
JSON.stringify({ type: 'assistant/message', seq: 1, data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 100, outputTokens: 10 } } }),
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.timestamp).toBe(new Date(1786707336131).toISOString())
})
})
describe('dsh provider - real log, real container', () => {
it('reads the upstream snapshot out of multi-frame zstd with a torn tail identically to plain jsonl', async () => {
const lines = (await readFile(join(import.meta.dirname, '../fixtures/dsh/bash-tool-turn.jsonl'), 'utf-8'))
.split('\n').filter(l => l.trim())
const plain = await parseAll(
createDshProvider(tmpDir),
await writePlainSession('--home-u-proj--', 'plain', lines),
)
// Header batch, then three append batches — the layout DSH writes.
const dir = join(tmpDir, 'sessions', '--home-u-proj--', 'framed')
await mkdir(dir, { recursive: true })
const filePath = join(dir, 'session.jsonl.zstd')
const frames = [[lines[0]!], lines.slice(1, 10), lines.slice(10, 25), lines.slice(25)]
.map(batch => zstdCompress!(Buffer.from(batch.join('\n') + '\n', 'utf-8')))
// A crashed writer's half-written final batch, carrying usage that must not count.
const torn = zstdCompress!(Buffer.from(assistantMessage(9, 9, { inputTokens: 123456, outputTokens: 1 }, 1785730424999) + '\n', 'utf-8'))
await writeFile(filePath, Buffer.concat([...frames, torn.subarray(0, Math.floor(torn.length / 2))]))
const framed = await parseAll(createDshProvider(tmpDir), filePath)
expect(framed.map(c => [c.inputTokens, c.outputTokens, c.reasoningTokens, c.model]))
.toEqual(plain.map(c => [c.inputTokens, c.outputTokens, c.reasoningTokens, c.model]))
expect(framed).toHaveLength(2)
})
})