mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 14:34:32 +00:00
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:
parent
c9e6e2ecae
commit
0b6141929d
6 changed files with 893 additions and 2 deletions
481
src/providers/dsh.ts
Normal file
481
src/providers/dsh.ts
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
406
tests/providers/dsh.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue