feat: add DeepSeek Harness (dsh) provider

Reads DSH sessions from $DSH_HOME/sessions (default ~/.dsh/sessions):
one directory per session holding session.jsonl.zstd (or an
uncompressed session.jsonl when compression=none).

The .zstd log is a concatenation of independent zstd frames (one per
appended event batch), which node:zlib's one-shot API cannot decode
whole; the provider ports the frame-boundary scan from the official
@deepseek-ai/dsh-session-persistence-jsonl package and decompresses
frame by frame. zstd needs Node >= 22.15; older runtimes get a notice
and DSH data is skipped.

Usage follows dsh-token-meter semantics: an assistant/message usage
report is the final value for its (turn, step) and replaces the
earlier assistant/chunk sample instead of double counting. Models come
from the most recent request/header config; reasoning tokens are
billed at the output rate. One parsed call per (turn, step), dedup key
dsh:<sessionId>:<turn>:<step>.
This commit is contained in:
MiloMMIN 2026-08-16 16:03:07 +08:00
parent c9e6e2ecae
commit 0b6141929d
6 changed files with 893 additions and 2 deletions

481
src/providers/dsh.ts Normal file
View file

@ -0,0 +1,481 @@
import { open, readdir, readFile, stat } from 'fs/promises'
import { join } from 'path'
import { homedir } from 'os'
import zlib from 'zlib'
import { 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'
// DeepSeek Harness (dsh) stores one session per directory:
// <DSH_HOME|~/.dsh>/sessions/<encoded-cwd>/session-<uuid>/session.jsonl.zstd
// (or an uncompressed session.jsonl when compression=none). The .zstd file is
// a concatenation of INDEPENDENT zstd frames — one per appended event batch —
// so node:zlib's one-shot zstdDecompressSync (which decodes a single frame)
// must be driven frame-by-frame behind a structural frame-boundary scan. The
// scan below is a port of scanZstdFrames from the official
// @deepseek-ai/dsh-session-persistence-jsonl package.
// zstd landed in node:zlib in 22.15 / 23.8; the package floor is lower, so the
// provider degrades with a notice instead of assuming the export exists.
const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }).zstdDecompressSync
const ZSTD_MAGIC = 0xfd2fb528
type ZstdFrame = { start: number; end: number }
// Locate complete frames without decompressing their blocks. An EOF inside the
// final frame (a torn append from a crashed writer) returns its start so the
// caller can ignore the tail; invalid complete structure rejects.
function scanZstdFrames(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): { frames: ZstdFrame[]; tornStart?: number } {
const frames: ZstdFrame[] = []
let offset = 0
while (offset < buffer.length) {
const start = offset
if (buffer.length - offset < 4) return { frames, tornStart: start }
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
throw new Error(`invalid zstd frame magic at byte ${offset}`)
}
offset += 4
if (offset === buffer.length) return { frames, tornStart: start }
const descriptor = buffer.readUInt8(offset)!
offset += 1
if ((descriptor & 24) !== 0) throw new Error(`reserved frame-header bit at byte ${offset - 1}`)
const contentSizeFlag = descriptor >>> 6
const singleSegment = (descriptor & 32) !== 0
const checksum = (descriptor & 4) !== 0
const dictionaryFlag = descriptor & 3
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
offset += remainingHeaderBytes
for (;;) {
if (buffer.length - offset < 3) return { frames, tornStart: start }
const blockHeader = buffer.readUIntLE(offset, 3)
offset += 3
const lastBlock = (blockHeader & 1) !== 0
const blockType = (blockHeader >>> 1) & 3
const blockSize = blockHeader >>> 3
if (blockType === 3) throw new Error(`reserved block type at byte ${offset - 3}`)
const payloadBytes = blockType === 1 ? 1 : blockSize
if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
offset += payloadBytes
if (lastBlock) break
}
if (checksum) {
if (buffer.length - offset < 4) return { frames, tornStart: start }
offset += 4
}
frames.push({ start, end: offset })
if (frames.length === maxFrames) return { frames }
}
return { frames }
}
type DshUsage = {
inputTokens?: number
outputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
reasoningTokens?: number
}
type DshEvent = {
type?: string
seq?: number
time?: number
// Session header fields live at the top level of the first event.
id?: string
cwd?: string
data?: {
turn?: number
step?: number
content?: Array<{ type?: string; text?: string }>
header?: { config?: { model?: string; provider?: string } }
chunk?: { type?: string; usage?: DshUsage }
usage?: DshUsage
name?: string
arguments?: string
}
}
type StepBucket = {
usage: DshUsage
// A usage report from assistant/message is the final value for its
// (turn, step) and replaces an earlier assistant/chunk sample (the two are
// adjacent reports of the same API call, per dsh-token-meter's usage
// 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: string
tools: string[]
skills: string[]
bashCommands: string[]
}
const toolNameMap: Record<string, string> = {
bash: 'Bash',
pwsh: 'Bash',
read: 'Read',
write: 'Write',
edit: 'Edit',
str_replace_editor: 'Edit',
glob: 'Glob',
grep: 'Grep',
todo_write: 'TodoWrite',
todo: 'TodoWrite',
web_search: 'WebSearch',
skill: 'Skill',
agent: 'Agent',
ask_user_question: 'AskUserQuestion',
}
function mapToolName(raw: string): string {
return toolNameMap[raw] ?? raw
}
function getDshHome(override?: string): string {
// An empty-string DSH_HOME is treated as unset.
return override ?? (process.env['DSH_HOME'] || undefined) ?? join(homedir(), '.dsh')
}
// DSH writes native-platform paths into the header (backslashes on Windows);
// split on both separators so discovery is correct on any host.
function projectFromCwd(cwd: string, fallback: string): string {
const segments = cwd.split(/[\\/]/).filter(Boolean)
return segments[segments.length - 1] ?? fallback
}
// Decode every complete frame and yield its JSONL lines. A torn final frame is
// ignored; a structurally corrupt file throws for the caller to report.
function* readZstdLines(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): Generator<string> {
const { frames } = scanZstdFrames(buffer, maxFrames)
for (const frame of frames) {
const text = zstdDecompress!(buffer.subarray(frame.start, frame.end)).toString('utf-8')
for (const line of text.split('\n')) {
if (line.trim()) yield line
}
}
}
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')
return null
}
let buffer: Buffer
try {
buffer = await readFile(filePath)
} catch {
return null
}
try {
return [...readZstdLines(buffer)]
} catch (err) {
process.stderr.write(`codeburn: skipped corrupt DSH session log ${filePath}: ${err instanceof Error ? err.message : err}\n`)
return null
}
}
const content = await readSessionFile(filePath)
if (content === null) return null
return content.split('\n').filter(l => l.trim())
}
// Cheap discovery probe: decompress ONLY the first frame (the session header
// batch) instead of the whole log. The header frame is tiny, so a bounded head
// read almost always contains it; fall back to a full read when it does not.
async function readSessionHeader(filePath: string): Promise<DshEvent | null> {
const firstLine = async (): Promise<string | null> => {
if (filePath.endsWith('.zstd')) {
if (!zstdDecompress) return null
let head: Buffer
try {
const handle = await open(filePath, 'r')
try {
const size = (await handle.stat()).size
const length = Math.min(size, 256 * 1024)
head = Buffer.alloc(length)
await handle.read(head, 0, length, 0)
} finally {
await handle.close()
}
} catch {
return null
}
let { frames } = scanZstdFrames(head, 1)
if (frames.length === 0) {
// Head read did not cover one full frame; take the whole file.
try {
const full = await readFile(filePath)
frames = scanZstdFrames(full, 1).frames
if (frames.length === 0) return null
head = full
} catch {
return null
}
}
const text = zstdDecompress(head.subarray(frames[0]!.start, frames[0]!.end)).toString('utf-8')
return text.split('\n').find(l => l.trim()) ?? null
}
const content = await readSessionFile(filePath)
return content?.split('\n').find(l => l.trim()) ?? null
}
try {
const line = await firstLine()
if (!line) return null
const event = JSON.parse(line) as DshEvent
return event.type === 'session' ? event : null
} catch {
return null
}
}
async function discoverSessionsInDir(sessionsDir: string): Promise<SessionSource[]> {
const sources: SessionSource[] = []
let projectDirs: string[]
try {
projectDirs = await readdir(sessionsDir)
} catch {
return sources
}
for (const dirName of projectDirs) {
const dirPath = join(sessionsDir, dirName)
const dirStat = await stat(dirPath).catch(() => null)
if (!dirStat?.isDirectory()) continue
let sessionDirs: string[]
try {
sessionDirs = await readdir(dirPath)
} catch {
continue
}
for (const sessionDir of sessionDirs) {
const sessionPath = join(dirPath, sessionDir)
const sessionStat = await stat(sessionPath).catch(() => null)
if (!sessionStat?.isDirectory()) continue
// Compressed log first; the uncompressed variant exists when
// compression=none. Never both for the same session.
let filePath: string | null = null
for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
const candidate = join(sessionPath, name)
const fileStat = await stat(candidate).catch(() => null)
if (fileStat?.isFile()) {
filePath = candidate
break
}
}
if (!filePath) continue
const header = await readSessionHeader(filePath)
if (!header) continue
const cwd = typeof header.cwd === 'string' && header.cwd.trim() ? header.cwd : dirName
sources.push({ path: filePath, project: projectFromCwd(cwd, dirName), provider: 'dsh' })
}
}
return sources
}
function parseToolArguments(raw: string | undefined): Record<string, unknown> | null {
if (!raw) return null
try {
const parsed = JSON.parse(raw) as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
} catch {
return null
}
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const lines = await readEventLines(source.path)
if (!lines) return
let sessionId = ''
let cwd = ''
let model = 'unknown'
let currentTurn = 0
const userMessageByTurn = new Map<number, string>()
const buckets = new Map<string, StepBucket>()
for (const line of lines) {
let event: DshEvent
try {
event = JSON.parse(line) as DshEvent
} catch {
continue
}
if (event.type === 'session') {
sessionId = event.id ?? sessionId
cwd = event.cwd ?? cwd
continue
}
if (event.type === 'turn/start') {
currentTurn = event.data?.turn ?? currentTurn
continue
}
if (event.type === 'request/header') {
// Emitted at most once per request; steps after the last header
// inherit its config as their model.
const headerModel = event.data?.header?.config?.model
if (typeof headerModel === 'string' && headerModel) model = headerModel
continue
}
if (event.type === 'user/message') {
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(' '))
continue
}
if (event.type === 'tool/call') {
const turn = event.data?.turn ?? currentTurn
const step = event.data?.step ?? 0
const rawName = event.data?.name
if (!rawName) continue
const key = `${turn}:${step}`
let bucket = buckets.get(key)
if (!bucket) {
bucket = { usage: {}, final: false, model, tools: [], skills: [], bashCommands: [] }
buckets.set(key, bucket)
}
bucket.tools.push(mapToolName(rawName))
const args = parseToolArguments(event.data?.arguments)
if ((rawName === 'bash' || rawName === 'pwsh') && typeof args?.['command'] === 'string') {
bucket.bashCommands.push(...extractBashCommands(args['command']))
}
if (rawName === 'skill' && typeof args?.['name'] === 'string') {
bucket.skills.push(args['name'])
}
continue
}
let usage: DshUsage | undefined
let isFinal = false
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
} else {
continue
}
if (!usage) continue
const turn = event.data?.turn ?? currentTurn
const step = event.data?.step ?? 0
const key = `${turn}:${step}`
let bucket = buckets.get(key)
if (!bucket) {
bucket = { usage: {}, final: false, model, tools: [], skills: [], bashCommands: [] }
buckets.set(key, bucket)
}
// A final report replaces an earlier sample; a late sample never
// overwrites a final one. The model snapshot follows the winning
// report (a header can change the model mid-turn between steps).
if (isFinal || !bucket.final) {
bucket.usage = usage
bucket.final = isFinal
bucket.time = event.time
bucket.model = model
}
}
const sortedKeys = [...buckets.keys()].sort((a, b) => {
const [ta, sa] = a.split(':').map(Number)
const [tb, sb] = b.split(':').map(Number)
return ta! - tb! || sa! - sb!
})
for (const key of sortedKeys) {
const bucket = buckets.get(key)!
const input = bucket.usage.inputTokens ?? 0
const output = bucket.usage.outputTokens ?? 0
const cacheRead = bucket.usage.cacheReadTokens ?? 0
const cacheWrite = bucket.usage.cacheWriteTokens ?? 0
const reasoning = bucket.usage.reasoningTokens ?? 0
if (input + output + cacheRead + cacheWrite + reasoning === 0) continue
const dedupKey = `dsh:${sessionId || source.path}:${key}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
// DSH bills reasoning tokens at the output rate (same as Gemini).
const costUSD = calculateCost(bucket.model, input, output + reasoning, cacheWrite, cacheRead, 0)
const [turn] = key.split(':').map(Number)
yield {
provider: 'dsh',
model: bucket.model,
inputTokens: input,
outputTokens: output,
cacheCreationInputTokens: cacheWrite,
cacheReadInputTokens: cacheRead,
cachedInputTokens: cacheRead,
reasoningTokens: reasoning,
webSearchRequests: 0,
costUSD,
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() : '',
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: userMessageByTurn.get(turn!) ?? '',
sessionId: sessionId || source.path,
project: cwd ? projectFromCwd(cwd, source.project) : source.project,
projectPath: cwd || undefined,
}
}
},
}
}
export function createDshProvider(dshHomeOverride?: string): Provider {
const dshHome = getDshHome(dshHomeOverride)
const sessionsDir = join(dshHome, 'sessions')
return {
name: 'dsh',
displayName: 'DeepSeek Harness',
modelDisplayName(model: string): string {
return getShortModelName(model)
},
toolDisplayName(rawTool: string): string {
return mapToolName(rawTool)
},
async probeRoots(): Promise<ProbeRoot[]> {
return [{ path: sessionsDir, label: 'sessions' }]
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverSessionsInDir(sessionsDir)
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const dsh = createDshProvider()

View file

@ -7,6 +7,7 @@ import { codex } from './codex.js'
import { copilot } from './copilot.js'
import { droid } from './droid.js'
import { devin } from './devin.js'
import { dsh } from './dsh.js'
import { gemini } from './gemini.js'
import { hermes } from './hermes.js'
import { ibmBob } from './ibm-bob.js'
@ -192,7 +193,7 @@ async function loadZed(): Promise<Provider | null> {
}
}
const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok]
const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, dsh, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok]
// Lazily loaded providers, listed by name so --provider validation works even
// when an optional module fails to load. Must stay in sync with getAllProviders.

View file

@ -198,6 +198,7 @@ export const PROVIDER_ENV_VARS: Record<string, string[]> = {
hermes: ['HERMES_HOME'],
'lingtai-tui': ['LINGTAI_HOME', 'LINGTAI_TUI_HOME', 'LINGTAI_TUI_GLOBAL_DIR'],
droid: ['FACTORY_DIR'],
dsh: ['DSH_HOME'],
cursor: ['CODEBURN_CURSOR_MAX_BUBBLES'],
// XDG_DATA_HOME is stale here (cursor-agent never reads it) but deliberately
// kept: removing it would force a re-parse to fix nothing.
@ -266,6 +267,7 @@ 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',
hermes: 'reasoning-output-accounting-v1-est-cost',
'lingtai-tui': 'token-ledger-registry-activity-v3',
'ibm-bob': 'worktree-project-grouping-v1',

View file

@ -34,6 +34,7 @@ const FILE_PROVIDERS: Record<string, string[]> = {
'codex.ts': ['codex'],
'copilot.ts': ['copilot'],
'droid.ts': ['droid'],
'dsh.ts': ['dsh'],
'hermes.ts': ['hermes'],
'lingtai-tui.ts': ['lingtai-tui'],
// Its only literal read is CODEBURN_CURSOR_MAX_BUBBLES (cursor.ts:692).

View file

@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok'])
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'dsh', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok'])
})
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {

406
tests/providers/dsh.test.ts Normal file
View file

@ -0,0 +1,406 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { homedir, tmpdir } from 'os'
import zlib from 'zlib'
import { createDshProvider } from '../../src/providers/dsh.js'
import { calculateCost } from '../../src/models.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
// DSH session logs are concatenations of INDEPENDENT zstd frames (one per
// appended event batch), so fixtures must compress each batch separately —
// a single zstdCompressSync over the whole file is a different (single-frame)
// format than what DSH writes.
const zstdCompress = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'dsh-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
function sessionHeader(opts: { id?: string; cwd?: string } = {}) {
return JSON.stringify({
type: 'session',
version: 0,
id: opts.id ?? 'session-00000000-0000-0000-0000-000000000001',
createdAt: 1786707336131,
cwd: opts.cwd ?? 'C:\\Users\\test\\myproject',
delegationDepth: 0,
agentPreset: 'cordis',
})
}
function requestHeader(model: string, time = 1786707337000) {
return JSON.stringify({
type: 'request/header',
seq: 10,
time,
data: { header: { config: { provider: 'deepseek-official', model, reasoningEffort: 'max', maxTokens: 256000 } } },
})
}
function turnStart(turn: number, time: number) {
return JSON.stringify({ type: 'turn/start', seq: 1, time, data: { turn } })
}
function userMessage(text: string, time: number) {
return JSON.stringify({
type: 'user/message',
seq: 2,
time,
data: { content: [{ type: 'text', text }], source: { kind: 'user' }, role: 'user', id: 'msg-1' },
})
}
function chunkUsage(turn: number, step: number, usage: Record<string, number>, time: number) {
return JSON.stringify({
type: 'assistant/chunk',
seq: 3,
time,
data: { turn, step, chunk: { type: 'usage', usage } },
})
}
function assistantMessage(turn: number, step: number, usage: Record<string, number> | undefined, time: number) {
return JSON.stringify({
type: 'assistant/message',
seq: 4,
time,
data: {
turn,
step,
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
...(usage ? { usage } : {}),
},
})
}
function toolCall(turn: number, step: number, name: string, args: Record<string, unknown>, time: number) {
return JSON.stringify({
type: 'tool/call',
seq: 5,
time,
data: { turn, step, callId: `call_${name}`, name, arguments: JSON.stringify(args) },
})
}
// Write one frame per batch of lines, matching DSH's append-per-batch layout.
async function writeZstdSession(projectDirName: string, sessionDirName: string, batches: string[][]) {
const dir = join(tmpDir, 'sessions', projectDirName, sessionDirName)
await mkdir(dir, { recursive: true })
const filePath = join(dir, 'session.jsonl.zstd')
const frames = batches.map(lines => zstdCompress!(Buffer.from(lines.join('\n') + '\n', 'utf-8')))
await writeFile(filePath, Buffer.concat(frames))
return filePath
}
async function writePlainSession(projectDirName: string, sessionDirName: string, lines: string[]) {
const dir = join(tmpDir, 'sessions', projectDirName, sessionDirName)
await mkdir(dir, { recursive: true })
const filePath = join(dir, 'session.jsonl')
await writeFile(filePath, lines.join('\n') + '\n')
return filePath
}
async function parseAll(provider: ReturnType<typeof createDshProvider>, filePath: string): Promise<ParsedProviderCall[]> {
const source = { path: filePath, project: 'myproject', provider: 'dsh' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
calls.push(call)
}
return calls
}
describe('dsh provider - session discovery', () => {
it('discovers a multi-frame zstd session, project from the header cwd', async () => {
await writeZstdSession('--C-Users-test-myproject--', 'session-abc', [
[sessionHeader({ cwd: 'C:\\Users\\test\\myproject' })],
[assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)],
])
const provider = createDshProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('dsh')
expect(sessions[0]!.project).toBe('myproject')
expect(sessions[0]!.path).toContain('session.jsonl.zstd')
})
it('discovers the uncompressed session.jsonl variant (compression=none)', async () => {
await writePlainSession('--home-u-proj--', 'session-plain', [
sessionHeader({ cwd: '/home/u/proj' }),
assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000),
])
const provider = createDshProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.path).toContain('session.jsonl')
expect(sessions[0]!.path).not.toContain('zstd')
expect(sessions[0]!.project).toBe('proj')
})
it('returns empty for a non-existent home', async () => {
const provider = createDshProvider('/nonexistent/dsh/home')
expect(await provider.discoverSessions()).toEqual([])
})
it('skips session dirs without a session log', async () => {
await mkdir(join(tmpDir, 'sessions', '--x--', 'session-empty'), { recursive: true })
const provider = createDshProvider(tmpDir)
expect(await provider.discoverSessions()).toEqual([])
})
it('DSH_HOME relocates discovery; an empty string is treated as unset', async () => {
const home = join(tmpDir, 'dsh-home')
await mkdir(join(home, 'sessions', '--x--', 'session-env'), { recursive: true })
await writeFile(
join(home, 'sessions', '--x--', 'session-env', 'session.jsonl'),
sessionHeader({ cwd: '/x' }) + '\n',
)
const saved = process.env['DSH_HOME']
process.env['DSH_HOME'] = home
try {
const sessions = await createDshProvider().discoverSessions()
expect(sessions).toHaveLength(1)
} finally {
if (saved === undefined) delete process.env['DSH_HOME']
else process.env['DSH_HOME'] = saved
}
process.env['DSH_HOME'] = ''
try {
const roots = await createDshProvider().probeRoots!()
expect(roots).toEqual([{ path: join(homedir(), '.dsh', 'sessions'), label: 'sessions' }])
} finally {
if (saved === undefined) delete process.env['DSH_HOME']
else process.env['DSH_HOME'] = saved
}
})
it('probeRoots reports the sessions dir under the factory root', async () => {
expect(await createDshProvider('/tmp/dsh-a').probeRoots!()).toEqual([
{ path: join('/tmp/dsh-a', 'sessions'), label: 'sessions' },
])
})
})
describe('dsh provider - parsing', () => {
it('decodes events spread across multiple independent zstd frames', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-multi', [
[sessionHeader({ id: 'session-multi', cwd: 'C:\\Users\\test\\myproject' })],
[turnStart(1, 1786707339000), userMessage('build the thing', 1786707339100)],
[chunkUsage(1, 1, { inputTokens: 500, outputTokens: 50 }, 1786707340000)],
[chunkUsage(1, 2, { inputTokens: 800, outputTokens: 80 }, 1786707341000)],
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(2)
expect(calls[0]!.inputTokens).toBe(500)
expect(calls[1]!.inputTokens).toBe(800)
})
it('a final assistant/message usage REPLACES the earlier chunk sample for the same turn/step', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-replace', [
[sessionHeader({ id: 'session-replace' })],
[turnStart(1, 1786707339000)],
// Early sample, then the final report of the SAME API call: the totals
// must come from the final report only, not the sum of both.
[chunkUsage(1, 1, { inputTokens: 14900, outputTokens: 600, reasoningTokens: 500 }, 1786707340000)],
[assistantMessage(1, 1, { inputTokens: 14981, outputTokens: 656, cacheReadTokens: 0, reasoningTokens: 609 }, 1786707340050)],
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(14981)
expect(calls[0]!.outputTokens).toBe(656)
expect(calls[0]!.reasoningTokens).toBe(609)
expect(calls[0]!.timestamp).toBe(new Date(1786707340050).toISOString())
})
it('a chunk sample arriving after the final report does not overwrite it', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-late', [
[sessionHeader({ id: 'session-late' })],
[assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340050)],
[chunkUsage(1, 1, { inputTokens: 999, outputTokens: 99 }, 1786707340100)],
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(100)
})
it('falls back to the chunk sample when no assistant/message usage arrives', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-sample', [
[sessionHeader({ id: 'session-sample' })],
[chunkUsage(2, 3, { inputTokens: 42, outputTokens: 7 }, 1786707340000)],
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(42)
expect(calls[0]!.deduplicationKey).toBe('dsh:session-sample:2:3')
})
it('steps inherit the model of the most recent request/header', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-model', [
[sessionHeader({ id: 'session-model' })],
[requestHeader('deepseek-v4-pro', 1786707337000)],
[assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)],
[assistantMessage(1, 2, { inputTokens: 200, outputTokens: 20 }, 1786707341000)],
[requestHeader('deepseek-v4-flash', 1786707342000)],
[assistantMessage(2, 1, { inputTokens: 300, outputTokens: 30 }, 1786707343000)],
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls.map(c => c.model)).toEqual(['deepseek-v4-pro', 'deepseek-v4-pro', 'deepseek-v4-flash'])
})
it('bills reasoning tokens at the output rate', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-reason', [
[sessionHeader({ id: 'session-reason' })],
[requestHeader('deepseek-v4-pro')],
[assistantMessage(1, 1, { inputTokens: 1000, outputTokens: 100, cacheWriteTokens: 50, cacheReadTokens: 500, reasoningTokens: 400 }, 1786707340000)],
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBeCloseTo(calculateCost('deepseek-v4-pro', 1000, 500, 50, 500, 0), 12)
})
it('collects mapped tools, skill names and bash commands from tool/call events', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-tools', [
[sessionHeader({ id: 'session-tools' })],
[
toolCall(1, 1, 'read', { path: '/x/a.ts' }, 1786707339500),
toolCall(1, 1, 'edit', { path: '/x/a.ts' }, 1786707339600),
toolCall(1, 1, 'bash', { command: 'git status && bun test' }, 1786707339700),
toolCall(1, 1, 'skill', { name: 'coding-agent-orchestration' }, 1786707339800),
toolCall(1, 1, 'cordis_run', { id: 'j1' }, 1786707339900),
chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000),
],
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash', 'Skill', 'cordis_run'])
expect(calls[0]!.bashCommands).toEqual(['git', 'bun'])
expect(calls[0]!.skills).toEqual(['coding-agent-orchestration'])
})
it('pairs the user message of the turn and carries session id and project', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-ctx', [
[sessionHeader({ id: 'session-ctx', cwd: 'C:\\Users\\test\\myproject' })],
[turnStart(1, 1786707339000), userMessage('first question', 1786707339100)],
[chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)],
[turnStart(2, 1786707350000), userMessage('second question', 1786707350100)],
[chunkUsage(2, 1, { inputTokens: 200, outputTokens: 20 }, 1786707351000)],
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(2)
expect(calls[0]!.userMessage).toBe('first question')
expect(calls[1]!.userMessage).toBe('second question')
expect(calls[0]!.sessionId).toBe('session-ctx')
expect(calls[0]!.project).toBe('myproject')
expect(calls[0]!.projectPath).toBe('C:\\Users\\test\\myproject')
})
it('parses the uncompressed session.jsonl variant', async () => {
const filePath = await writePlainSession('--home-u-proj--', 'session-plain', [
sessionHeader({ id: 'session-plain', cwd: '/home/u/proj' }),
turnStart(1, 1786707339000),
userMessage('hello', 1786707339100),
chunkUsage(1, 1, { inputTokens: 123, outputTokens: 45 }, 1786707340000),
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(123)
expect(calls[0]!.outputTokens).toBe(45)
})
it('skips buckets whose usage is all zero', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-zero', [
[sessionHeader({ id: 'session-zero' })],
[assistantMessage(1, 1, { inputTokens: 0, outputTokens: 0 }, 1786707340000)],
])
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(0)
})
it('ignores a torn final frame appended by a crashed writer', async () => {
const dir = join(tmpDir, 'sessions', '--C-Users-test-myproject--', 'session-torn')
await mkdir(dir, { recursive: true })
const filePath = join(dir, 'session.jsonl.zstd')
const good = zstdCompress!(Buffer.from(
sessionHeader({ id: 'session-torn' }) + '\n' +
chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000) + '\n',
))
const torn = zstdCompress!(Buffer.from(chunkUsage(1, 2, { inputTokens: 1, outputTokens: 1 }, 1786707341000) + '\n'))
await writeFile(filePath, Buffer.concat([good, torn.subarray(0, Math.floor(torn.length / 2))]))
const calls = await parseAll(createDshProvider(tmpDir), filePath)
expect(calls).toHaveLength(1)
expect(calls[0]!.inputTokens).toBe(100)
})
it('deduplicates (turn, step) calls seen across multiple parses', async () => {
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-dedup', [
[sessionHeader({ id: 'session-dedup' })],
[chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)],
])
const provider = createDshProvider(tmpDir)
const source = { path: filePath, project: 'myproject', provider: 'dsh' }
const seenKeys = new Set<string>()
const firstRun: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seenKeys).parse()) firstRun.push(call)
const secondRun: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seenKeys).parse()) secondRun.push(call)
expect(firstRun).toHaveLength(1)
expect(secondRun).toHaveLength(0)
})
it('handles a missing session file gracefully', async () => {
const provider = createDshProvider(tmpDir)
const source = { path: join(tmpDir, 'nope', 'session.jsonl.zstd'), project: 'test', provider: 'dsh' }
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
expect(calls).toHaveLength(0)
})
})
describe('dsh provider - display names', () => {
const provider = createDshProvider('/tmp')
it('has correct name and displayName', () => {
expect(provider.name).toBe('dsh')
expect(provider.displayName).toBe('DeepSeek Harness')
})
it('maps deepseek models to readable names and passes unknown ids through', () => {
expect(provider.modelDisplayName('deepseek-v4-pro')).toBe('DeepSeek v4 Pro')
expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model')
})
it('normalizes tool names, keeping unknown names raw', () => {
expect(provider.toolDisplayName('bash')).toBe('Bash')
expect(provider.toolDisplayName('pwsh')).toBe('Bash')
expect(provider.toolDisplayName('todo_write')).toBe('TodoWrite')
expect(provider.toolDisplayName('cordis_run')).toBe('cordis_run')
})
})