mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-10 01:29:41 +00:00
feat(providers): add coder/mux as a datasource (#438)
* feat(providers): add coder/mux as a datasource Reads coder/mux session data from ~/.mux/sessions/<workspaceId>/chat.jsonl and normalizes per-assistant-message token usage into ParsedProviderCall. Discovery resolves project names from config.json; the token decomposition matches mux's own inclusive input/output accounting, and cost is recomputed via LiteLLM. Includes unit tests and a provider doc. Change-Id: Ie6ce9d41254d8bf05ff0965b443328dfa7b598de Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com> * fix(providers): discover mux sub-agent transcripts discoverSessions only globbed sessions/<id>/chat.jsonl and skipped each spawned sub-agent's transcript at sessions/<id>/subagent-transcripts/<childTaskId>/chat.jsonl. Against a real ~/.mux (629 workspaces) those nested files are 5,889 sessions and ~51% of all assistant messages, so sub-agent spend was silently dropped: total mux usage went from $17,113 to $21,564 (+26%) once counted. Walk the subagent-transcripts dir per workspace and attribute each child session to the parent's project. Dedup is unaffected: the parser keys off the <childTaskId> dir name, which is disjoint from every workspace id, so each call is still counted once. Cross-checked parsed per-model token totals against mux's sibling session-usage.json. Also corrects the provider doc, which wrongly claimed sub-agents get their own top-level sessions/<id>/chat.jsonl, and documents the Google reasoning>output decomposition edge case. Change-Id: I1d43f26eec7c9c6c523e5ea541e2ff8d0c3aa07e Signed-off-by: Thomas Kosiewski <tk@coder.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Signed-off-by: Thomas Kosiewski <tk@coder.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
42ef1ff97e
commit
e0051fc403
6 changed files with 748 additions and 2 deletions
|
|
@ -121,6 +121,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
|
|||
| <img src="assets/providers/antigravity.png" width="28" /> | Antigravity | Yes | [antigravity.md](docs/providers/antigravity.md) |
|
||||
| <img src="assets/providers/crush.png" width="28" /> | Crush | Yes | [crush.md](docs/providers/crush.md) |
|
||||
| | Warp | Yes | [warp.md](docs/providers/warp.md) |
|
||||
| | Mux (coder) | Yes | [mux.md](docs/providers/mux.md) |
|
||||
|
||||
Each provider doc lists the exact data location, storage format, and known quirks. Linux and Windows paths are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues).
|
||||
|
||||
|
|
|
|||
56
docs/providers/mux.md
Normal file
56
docs/providers/mux.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Mux
|
||||
|
||||
[coder/mux](https://github.com/coder/mux) — Coder's desktop/CLI app for parallel agentic development. Mux makes its own LLM API calls (via the Vercel AI SDK) and records per-turn token usage natively, so codeburn reads it directly.
|
||||
|
||||
- **Source:** `src/providers/mux.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:13`)
|
||||
- **Test:** `tests/providers/mux.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
| Order | Path |
|
||||
|---|---|
|
||||
| 1 | `$CODEBURN_MUX_DIR` (codeburn-only override) |
|
||||
| 2 | `$MUX_ROOT` (mux's own override) |
|
||||
| 3 | `~/.mux` (default) |
|
||||
|
||||
Session files: `<root>/sessions/<workspaceId>/chat.jsonl`, plus each spawned sub-agent's own transcript at `<root>/sessions/<workspaceId>/subagent-transcripts/<childTaskId>/chat.jsonl` (see Quirks). Dev builds of mux use `~/.mux-dev` and an older install may use `~/.cmux` (migrated to `~/.mux`); point `CODEBURN_MUX_DIR`/`MUX_ROOT` at those if needed.
|
||||
|
||||
The human-readable project name is resolved from `<root>/config.json` (shape: `{ projects: Array<[projectPath, { workspaces: [{ id }] }] > }`); the directory under `sessions/` is the `workspaceId`, matched against `workspace.id`. Falls back to the raw `workspaceId` when there's no mapping.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL, one `MuxMessage` per line. Token usage rides on each assistant message's `metadata.usage` (`{ inputTokens, outputTokens, reasoningTokens, cachedInputTokens }`, typed `z.any()` upstream so it's read defensively). Tool calls and the user prompt are `parts[]` entries (`dynamic-tool` / `text`).
|
||||
|
||||
## Pricing
|
||||
|
||||
Cost is recomputed locally via `calculateCost` (mux and codeburn both price from the LiteLLM snapshot). On-disk model strings are `provider:modelId` (e.g. `anthropic:claude-opus-4-8`); the `provider:` prefix is stripped **at parse time** so the stored model is the bare LiteLLM id. This matters because codeburn's canonicalizer (`getCanonicalName`) only strips slash-style prefixes, and `parser.ts` re-prices from the stored model after the cache round-trip — a colon-prefixed model would price at $0 and render raw in the breakdown.
|
||||
|
||||
Token decomposition matches mux's own `displayUsage.ts` (AI SDK v6 semantics):
|
||||
|
||||
- `inputTokens` is **inclusive** of cache-read and cache-creation → `input = inputTokens − cachedInputTokens − cacheCreation`.
|
||||
- `outputTokens` is **inclusive** of reasoning → `output = outputTokens − reasoning`; reasoning bills at the output rate, so it's folded back into the output arg of `calculateCost`.
|
||||
- Cache-creation tokens are provider-specific: read from `metadata.providerMetadata.anthropic.cacheCreationInputTokens`.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `mux:<workspaceId>:<message.id>`. `message.id` is required by mux's schema; for corrupt id-less lines it falls back to the (unique) line index.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **No double-counting with `claude`.** Mux calls model APIs itself and stores usage under `~/.mux`; it does not shell out to Claude Code or read `~/.claude`.
|
||||
- **Sub-agents.** A spawned sub-agent is its own LLM-client session, but mux writes it to `sessions/<workspaceId>/subagent-transcripts/<childTaskId>/chat.jsonl` — *nested under the parent workspace*, not as a top-level `sessions/<id>` dir. `discoverSessions` walks these explicitly and attributes them to the parent's project; missing them undercounts real sessions substantially (sub-agent calls are routinely the majority of a session's turns). No double-count: the dedup key is derived from the `<childTaskId>` directory name, which is disjoint from every workspace id.
|
||||
- **Reasoning vs. output decomposition.** `output = outputTokens − reasoningTokens` assumes `outputTokens` is reasoning-inclusive, which holds for every record carrying `outputTokenDetails` (text + reasoning). A small fraction of Google `gemini-3-*-preview` records report `reasoningTokens > outputTokens`; for those the text component clamps to 0 (reasoning is still billed at the output rate). This matches mux's own `displayUsage.ts` and the token/cost effect is negligible.
|
||||
- **Read only `chat.jsonl`.** `session-usage.json` (pre-aggregated per model) and `analytics/analytics.db` (DuckDB, derived from `chat.jsonl`) describe the *same* usage; summing them would double-count.
|
||||
- **No web-search field.** Mux records no web-search/server-tool request count, so `webSearchRequests` is always `0`.
|
||||
- **Remote runtimes.** Mux can run workspaces on SSH/Docker, but the desktop app is the LLM client and writes `sessions/*/chat.jsonl` on the machine running mux — so usage is captured locally regardless of where commands execute.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Confirm whether the bug is in **discovery** (sessions not picked up — `discoverSessions`/`loadProjectMap`) or **parsing** (`createParser`).
|
||||
2. `metadata.usage` is untyped upstream and its exact field set varies by provider family (anthropic/openai/google/xai/deepseek). Validate against a real `chat.jsonl` sample and cross-check the parsed per-model totals against the sibling `session-usage.json` `byModel` — they should match.
|
||||
3. Add a fixture-driven case to `tests/providers/mux.test.ts`. Do not mock the filesystem; write a temp `~/.mux` layout like the existing tests.
|
||||
|
|
@ -10,6 +10,7 @@ import { kiloCode } from './kilo-code.js'
|
|||
import { kiro } from './kiro.js'
|
||||
import { kimi } from './kimi.js'
|
||||
import { mistralVibe } from './mistral-vibe.js'
|
||||
import { mux } from './mux.js'
|
||||
import { openclaw } from './openclaw.js'
|
||||
import { pi, omp } from './pi.js'
|
||||
import { qwen } from './qwen.js'
|
||||
|
|
@ -135,7 +136,7 @@ async function loadCrush(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, openclaw, pi, omp, qwen, rooCode]
|
||||
const coreProviders: Provider[] = [claude, cline, codebuff, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, mistralVibe, mux, openclaw, pi, omp, qwen, rooCode]
|
||||
|
||||
export async function getAllProviders(): Promise<Provider[]> {
|
||||
const [ag, forge, gs, cursor, opencode, cursorAgent, crush, warp] = await Promise.all([loadAntigravity(), loadForge(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush(), loadWarp()])
|
||||
|
|
|
|||
284
src/providers/mux.ts
Normal file
284
src/providers/mux.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionLines } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
bash: 'Bash',
|
||||
file_read: 'Read',
|
||||
file_edit_replace_string: 'Edit',
|
||||
file_edit_replace_lines: 'Edit',
|
||||
file_edit_insert: 'Edit',
|
||||
file_edit_operation: 'Edit',
|
||||
web_fetch: 'WebFetch',
|
||||
web_search: 'WebSearch',
|
||||
task: 'Agent',
|
||||
todo: 'TodoWrite',
|
||||
}
|
||||
|
||||
type MuxPart = {
|
||||
type?: string
|
||||
text?: string
|
||||
toolName?: string
|
||||
input?: unknown
|
||||
}
|
||||
|
||||
type MuxMessage = {
|
||||
id?: string
|
||||
role?: string
|
||||
parts?: MuxPart[]
|
||||
createdAt?: string
|
||||
metadata?: {
|
||||
model?: string
|
||||
timestamp?: number
|
||||
historySequence?: number
|
||||
usage?: unknown
|
||||
providerMetadata?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
function expandHome(p: string): string {
|
||||
if (p === '~') return homedir()
|
||||
if (p.startsWith('~/') || p.startsWith('~\\')) return join(homedir(), p.slice(2))
|
||||
return p
|
||||
}
|
||||
|
||||
function getMuxRoot(override?: string): string {
|
||||
if (override) return resolve(expandHome(override))
|
||||
const codeburnOverride = process.env['CODEBURN_MUX_DIR']
|
||||
if (codeburnOverride) return resolve(expandHome(codeburnOverride))
|
||||
const muxRoot = process.env['MUX_ROOT']
|
||||
if (muxRoot) return resolve(expandHome(muxRoot))
|
||||
return join(homedir(), '.mux')
|
||||
}
|
||||
|
||||
// Splits on the first colon only, leaving any colon inside the id intact.
|
||||
function stripProvider(model: string): string {
|
||||
const i = model.indexOf(':')
|
||||
return i >= 0 ? model.slice(i + 1) : model
|
||||
}
|
||||
|
||||
function num(v: unknown): number {
|
||||
return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0
|
||||
}
|
||||
|
||||
function asRecord(v: unknown): Record<string, unknown> | undefined {
|
||||
return v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
// Guard against non-finite / out-of-range ms, which make toISOString() throw.
|
||||
function toIsoTimestamp(ts: unknown, createdAt: unknown): string {
|
||||
if (typeof ts === 'number' && Number.isFinite(ts)) {
|
||||
const d = new Date(ts)
|
||||
if (!Number.isNaN(d.getTime())) return d.toISOString()
|
||||
}
|
||||
return typeof createdAt === 'string' ? createdAt : ''
|
||||
}
|
||||
|
||||
// config.json shape: { projects: [[projectPath, { workspaces: [{ id }] }], ...] }
|
||||
async function loadProjectMap(root: string): Promise<Map<string, string>> {
|
||||
const map = new Map<string, string>()
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(await readFile(join(root, 'config.json'), 'utf-8'))
|
||||
} catch {
|
||||
return map
|
||||
}
|
||||
const projects = asRecord(data)?.['projects']
|
||||
if (!Array.isArray(projects)) return map
|
||||
for (const pair of projects) {
|
||||
if (!Array.isArray(pair) || pair.length < 2) continue
|
||||
const projectPath = pair[0]
|
||||
if (typeof projectPath !== 'string') continue
|
||||
const label = basename(projectPath) || projectPath
|
||||
const workspaces = asRecord(pair[1])?.['workspaces']
|
||||
if (!Array.isArray(workspaces)) continue
|
||||
for (const ws of workspaces) {
|
||||
const id = asRecord(ws)?.['id']
|
||||
if (typeof id === 'string' && id) map.set(id, label)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
async function pushChatSource(sources: SessionSource[], chatPath: string, project: string): Promise<void> {
|
||||
const s = await stat(chatPath).catch(() => null)
|
||||
if (s?.isFile()) sources.push({ path: chatPath, project, provider: 'mux' })
|
||||
}
|
||||
|
||||
async function discoverSessions(root: string): Promise<SessionSource[]> {
|
||||
const sessionsDir = join(root, 'sessions')
|
||||
|
||||
let workspaceIds: string[]
|
||||
try {
|
||||
workspaceIds = await readdir(sessionsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
const projectMap = await loadProjectMap(root)
|
||||
const sources: SessionSource[] = []
|
||||
for (const workspaceId of workspaceIds) {
|
||||
const workspaceDir = join(sessionsDir, workspaceId)
|
||||
const project = projectMap.get(workspaceId) ?? workspaceId
|
||||
|
||||
// The workspace's own turns.
|
||||
await pushChatSource(sources, join(workspaceDir, 'chat.jsonl'), project)
|
||||
|
||||
// Sub-agent turns. Each spawned sub-agent is a separate LLM-client session
|
||||
// recorded at subagent-transcripts/<childTaskId>/chat.jsonl — mux does NOT
|
||||
// mirror these into a top-level sessions/<id> dir, so they are only
|
||||
// reachable here. They carry real token usage (often the bulk of a
|
||||
// session's spend) and are attributed to the parent workspace's project.
|
||||
// Dedup stays correct: the parser keys off the child-task dir name, which
|
||||
// is distinct from every workspace id, so each call is still counted once.
|
||||
const subagentDir = join(workspaceDir, 'subagent-transcripts')
|
||||
let childTaskIds: string[]
|
||||
try {
|
||||
childTaskIds = await readdir(subagentDir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const childTaskId of childTaskIds) {
|
||||
await pushChatSource(sources, join(subagentDir, childTaskId, 'chat.jsonl'), project)
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const workspaceId = basename(dirname(source.path))
|
||||
let pendingUserMessage = ''
|
||||
let lineIdx = 0
|
||||
|
||||
for await (const line of readSessionLines(source.path)) {
|
||||
lineIdx++
|
||||
let msg: MuxMessage
|
||||
try {
|
||||
msg = JSON.parse(line) as MuxMessage
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
if (!msg || typeof msg !== 'object') continue
|
||||
|
||||
if (msg.role === 'user') {
|
||||
const texts = (Array.isArray(msg.parts) ? msg.parts : [])
|
||||
.filter(p => p?.type === 'text' && typeof p.text === 'string')
|
||||
.map(p => p.text as string)
|
||||
.filter(Boolean)
|
||||
if (texts.length > 0) pendingUserMessage = texts.join(' ').slice(0, 500)
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role !== 'assistant') continue
|
||||
const meta = msg.metadata
|
||||
const usage = asRecord(meta?.usage)
|
||||
if (!meta || !usage) continue
|
||||
|
||||
const pm = meta.providerMetadata ?? {}
|
||||
const anthropic = asRecord(pm['anthropic'])
|
||||
const openai = asRecord(pm['openai'])
|
||||
|
||||
// mux reports inputTokens inclusive of cache read+creation and
|
||||
// outputTokens inclusive of reasoning; decompose to codeburn's
|
||||
// cache/reasoning-exclusive convention. Cache creation is Anthropic-only.
|
||||
const cacheRead = num(usage['cachedInputTokens'])
|
||||
const cacheCreate = num(anthropic?.['cacheCreationInputTokens'])
|
||||
const reasoning = num(usage['reasoningTokens']) || num(openai?.['reasoningTokens'])
|
||||
const inputTokens = Math.max(0, num(usage['inputTokens']) - cacheRead - cacheCreate)
|
||||
const outputTokens = Math.max(0, num(usage['outputTokens']) - reasoning)
|
||||
|
||||
if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && cacheCreate === 0 && reasoning === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Strip the "provider:" prefix — codeburn's getCanonicalName only strips
|
||||
// slash prefixes, so a colon-prefixed model would price at $0.
|
||||
const rawModel = typeof meta.model === 'string' && meta.model ? meta.model : 'unknown'
|
||||
const model = stripProvider(rawModel)
|
||||
const id = typeof msg.id === 'string' && msg.id ? msg.id : `L${lineIdx}`
|
||||
const dedupKey = `mux:${workspaceId}:${id}`
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const toolParts = (Array.isArray(msg.parts) ? msg.parts : []).filter(
|
||||
p => p?.type === 'dynamic-tool' && typeof p.toolName === 'string',
|
||||
)
|
||||
const tools = toolParts.map(p => toolNameMap[p.toolName!] ?? p.toolName!)
|
||||
const bashCommands = toolParts
|
||||
.filter(p => p.toolName === 'bash')
|
||||
.flatMap(p => {
|
||||
const input = asRecord(p.input)
|
||||
const script = input?.['script'] ?? input?.['command']
|
||||
return typeof script === 'string' ? extractBashCommands(script) : []
|
||||
})
|
||||
|
||||
const costUSD = calculateCost(
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens + reasoning,
|
||||
cacheCreate,
|
||||
cacheRead,
|
||||
0,
|
||||
)
|
||||
|
||||
const timestamp = toIsoTimestamp(meta.timestamp, msg.createdAt)
|
||||
|
||||
yield {
|
||||
provider: 'mux',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens: cacheCreate,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cachedInputTokens: cacheRead,
|
||||
reasoningTokens: reasoning,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools,
|
||||
bashCommands,
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: pendingUserMessage,
|
||||
sessionId: workspaceId,
|
||||
}
|
||||
|
||||
pendingUserMessage = ''
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createMuxProvider(muxRoot?: string): Provider {
|
||||
const root = getMuxRoot(muxRoot)
|
||||
|
||||
return {
|
||||
name: 'mux',
|
||||
displayName: 'Mux',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
return getShortModelName(stripProvider(model))
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessions(root)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const mux = createMuxProvider()
|
||||
|
|
@ -3,7 +3,7 @@ import { providers, getAllProviders, getProvider } from '../src/providers/index.
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codebuff', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'mux', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
|
||||
})
|
||||
|
||||
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
|
||||
|
|
|
|||
404
tests/providers/mux.test.ts
Normal file
404
tests/providers/mux.test.ts
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createMuxProvider } from '../../src/providers/mux.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'mux-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// ~/.mux/config.json shape: { projects: Array<[projectPath, { workspaces: [{ id }] }]> }
|
||||
async function writeConfig(root: string, entries: Array<[string, string[]]>) {
|
||||
const data = {
|
||||
projects: entries.map(([projectPath, ids]) => [
|
||||
projectPath,
|
||||
{ workspaces: ids.map(id => ({ id, name: 'main', path: `${projectPath}/wt-${id}` })) },
|
||||
]),
|
||||
}
|
||||
await writeFile(join(root, 'config.json'), JSON.stringify(data))
|
||||
}
|
||||
|
||||
async function writeWorkspace(root: string, workspaceId: string, lines: string[]) {
|
||||
const dir = join(root, 'sessions', workspaceId)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filePath = join(dir, 'chat.jsonl')
|
||||
await writeFile(filePath, lines.join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
// Sub-agent transcripts live nested under the parent workspace, not as a
|
||||
// top-level sessions/<id> dir.
|
||||
async function writeSubagent(root: string, workspaceId: string, childTaskId: string, lines: string[]) {
|
||||
const dir = join(root, 'sessions', workspaceId, 'subagent-transcripts', childTaskId)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filePath = join(dir, 'chat.jsonl')
|
||||
await writeFile(filePath, lines.join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
function userMessage(text: string, id = 'msg-user-1') {
|
||||
return JSON.stringify({
|
||||
id,
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text }],
|
||||
metadata: { historySequence: 0, timestamp: 1776023210000 },
|
||||
})
|
||||
}
|
||||
|
||||
type AsstOpts = {
|
||||
id?: string
|
||||
timestamp?: number
|
||||
model?: string
|
||||
input?: number
|
||||
output?: number
|
||||
reasoning?: number
|
||||
cacheRead?: number
|
||||
cacheCreate?: number
|
||||
tools?: Array<{ name: string; script?: string }>
|
||||
text?: string
|
||||
}
|
||||
|
||||
function assistantMessage(opts: AsstOpts = {}) {
|
||||
const parts: Array<Record<string, unknown>> = [{ type: 'text', text: opts.text ?? 'done' }]
|
||||
for (const t of opts.tools ?? []) {
|
||||
parts.push({
|
||||
type: 'dynamic-tool',
|
||||
toolCallId: `call-${t.name}`,
|
||||
toolName: t.name,
|
||||
input: t.script !== undefined ? { script: t.script } : {},
|
||||
state: 'output-available',
|
||||
output: {},
|
||||
})
|
||||
}
|
||||
const metadata: Record<string, unknown> = {
|
||||
historySequence: 1,
|
||||
model: opts.model ?? 'anthropic:claude-opus-4-8',
|
||||
timestamp: opts.timestamp ?? 1776023230000,
|
||||
usage: {
|
||||
inputTokens: opts.input ?? 1000,
|
||||
outputTokens: opts.output ?? 200,
|
||||
reasoningTokens: opts.reasoning ?? 0,
|
||||
cachedInputTokens: opts.cacheRead ?? 0,
|
||||
},
|
||||
...(opts.cacheCreate
|
||||
? { providerMetadata: { anthropic: { cacheCreationInputTokens: opts.cacheCreate } } }
|
||||
: {}),
|
||||
}
|
||||
return JSON.stringify({ id: opts.id ?? 'msg-asst-1', role: 'assistant', parts, metadata })
|
||||
}
|
||||
|
||||
describe('mux provider - session discovery', () => {
|
||||
it('discovers chat.jsonl per workspace and resolves project from config.json', async () => {
|
||||
await writeWorkspace(tmpDir, 'ws-abc', [userMessage('hi'), assistantMessage({})])
|
||||
await writeConfig(tmpDir, [['/Users/test/myproject', ['ws-abc']]])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('mux')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
expect(sessions[0]!.path).toContain(join('sessions', 'ws-abc', 'chat.jsonl'))
|
||||
})
|
||||
|
||||
it('falls back to the workspaceId when config.json has no mapping', async () => {
|
||||
await writeWorkspace(tmpDir, 'ws-orphan', [assistantMessage({})])
|
||||
// no config.json written
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('ws-orphan')
|
||||
})
|
||||
|
||||
it('discovers multiple workspaces across projects', async () => {
|
||||
await writeWorkspace(tmpDir, 'ws-1', [assistantMessage({})])
|
||||
await writeWorkspace(tmpDir, 'ws-2', [assistantMessage({})])
|
||||
await writeConfig(tmpDir, [
|
||||
['/Users/test/project-a', ['ws-1']],
|
||||
['/Users/test/project-b', ['ws-2']],
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions.map(s => s.project).sort()).toEqual(['project-a', 'project-b'])
|
||||
})
|
||||
|
||||
it('returns empty for a non-existent root', async () => {
|
||||
const provider = createMuxProvider('/nonexistent/path/that/does/not/exist')
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
})
|
||||
|
||||
it('skips workspace directories without a chat.jsonl', async () => {
|
||||
await mkdir(join(tmpDir, 'sessions', 'ws-empty'), { recursive: true })
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
})
|
||||
|
||||
it('discovers sub-agent transcripts and attributes them to the parent project', async () => {
|
||||
await writeWorkspace(tmpDir, 'ws-parent', [assistantMessage({ id: 'parent-1' })])
|
||||
await writeSubagent(tmpDir, 'ws-parent', 'child-a', [assistantMessage({ id: 'child-a-1' })])
|
||||
await writeSubagent(tmpDir, 'ws-parent', 'child-b', [assistantMessage({ id: 'child-b-1' })])
|
||||
await writeConfig(tmpDir, [['/Users/test/myproject', ['ws-parent']]])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
// parent chat.jsonl + two sub-agent transcripts, all under one project.
|
||||
expect(sessions).toHaveLength(3)
|
||||
expect(sessions.every(s => s.project === 'myproject')).toBe(true)
|
||||
const subagentPaths = sessions
|
||||
.map(s => s.path)
|
||||
.filter(p => p.includes('subagent-transcripts'))
|
||||
.sort()
|
||||
expect(subagentPaths).toHaveLength(2)
|
||||
expect(subagentPaths[0]).toContain(join('subagent-transcripts', 'child-a', 'chat.jsonl'))
|
||||
})
|
||||
|
||||
it('discovers a sub-agent transcript even when the workspace has no top-level chat.jsonl', async () => {
|
||||
// mkdir the workspace dir without a chat.jsonl, but with a sub-agent transcript.
|
||||
await writeSubagent(tmpDir, 'ws-parent', 'child-only', [assistantMessage({ id: 'child-only-1' })])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.path).toContain(join('subagent-transcripts', 'child-only', 'chat.jsonl'))
|
||||
})
|
||||
|
||||
it('counts sub-agent calls once, with a workspace-distinct dedup key', async () => {
|
||||
const seenKeys = new Set<string>()
|
||||
await writeWorkspace(tmpDir, 'ws-parent', [assistantMessage({ id: 'shared-id', input: 100 })])
|
||||
// Same message id inside the sub-agent must NOT collide with the parent's.
|
||||
await writeSubagent(tmpDir, 'ws-parent', 'child-a', [assistantMessage({ id: 'shared-id', input: 200 })])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of sessions) {
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(new Set(calls.map(c => c.deduplicationKey))).toEqual(
|
||||
new Set(['mux:ws-parent:shared-id', 'mux:child-a:shared-id']),
|
||||
)
|
||||
expect(calls.map(c => c.sessionId).sort()).toEqual(['child-a', 'ws-parent'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mux provider - chat.jsonl parsing', () => {
|
||||
it('decomposes inclusive input/output usage into codeburn token fields', async () => {
|
||||
// input is inclusive of cache; output is inclusive of reasoning.
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [
|
||||
userMessage('implement the feature'),
|
||||
assistantMessage({
|
||||
id: 'msg-1',
|
||||
input: 1000,
|
||||
output: 230,
|
||||
reasoning: 30,
|
||||
cacheRead: 200,
|
||||
cacheCreate: 50,
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('mux')
|
||||
expect(call.model).toBe('claude-opus-4-8') // provider prefix stripped so codeburn prices/displays it
|
||||
expect(call.inputTokens).toBe(750) // 1000 - 200 cacheRead - 50 cacheCreate
|
||||
expect(call.outputTokens).toBe(200) // 230 - 30 reasoning
|
||||
expect(call.reasoningTokens).toBe(30)
|
||||
expect(call.cacheReadInputTokens).toBe(200)
|
||||
expect(call.cachedInputTokens).toBe(200)
|
||||
expect(call.cacheCreationInputTokens).toBe(50)
|
||||
expect(call.webSearchRequests).toBe(0)
|
||||
expect(call.sessionId).toBe('ws-abc')
|
||||
expect(call.userMessage).toBe('implement the feature')
|
||||
expect(call.timestamp).toBe(new Date(1776023230000).toISOString())
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
expect(call.deduplicationKey).toBe('mux:ws-abc:msg-1')
|
||||
})
|
||||
|
||||
it('maps tool names and extracts bash command programs', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [
|
||||
assistantMessage({
|
||||
tools: [
|
||||
{ name: 'file_read' },
|
||||
{ name: 'file_edit_replace_string' },
|
||||
{ name: 'bash', script: 'git status && bun test' },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash'])
|
||||
expect(calls[0]!.bashCommands).toEqual(['git', 'bun'])
|
||||
})
|
||||
|
||||
it('skips assistant messages without a usage blob', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [
|
||||
JSON.stringify({
|
||||
id: 'no-usage',
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'thinking...' }],
|
||||
metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1 },
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips assistant messages with all-zero tokens', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [
|
||||
assistantMessage({ input: 0, output: 0 }),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates calls seen across multiple parses', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [assistantMessage({ id: 'dup' })])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const first: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) first.push(call)
|
||||
const second: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) second.push(call)
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('yields one call per assistant message and pairs the preceding user prompt', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-multi', [
|
||||
userMessage('first question', 'u1'),
|
||||
assistantMessage({ id: 'a1', input: 500, output: 100 }),
|
||||
userMessage('second question', 'u2'),
|
||||
assistantMessage({ id: 'a2', input: 600, output: 120 }),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.userMessage).toBe('first question')
|
||||
expect(calls[0]!.inputTokens).toBe(500)
|
||||
expect(calls[1]!.userMessage).toBe('second question')
|
||||
expect(calls[1]!.inputTokens).toBe(600)
|
||||
})
|
||||
|
||||
it('handles a missing session file gracefully', async () => {
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: join(tmpDir, 'sessions', 'nope', 'chat.jsonl'), project: 'x', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps distinct id-less assistant messages via the line-index fallback', async () => {
|
||||
const asst = (text: string, input: number) =>
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text }],
|
||||
// No `id`, identical historySequence — the fallback must stay unique.
|
||||
metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1, historySequence: 5, usage: { inputTokens: input, outputTokens: 5 } },
|
||||
})
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-noid', [asst('a', 10), asst('b', 11)])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'p', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(new Set(calls.map(c => c.deduplicationKey)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('tolerates malformed lines without dropping later valid turns', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [
|
||||
// parts is an object, not an array (corrupt) — must not throw the loop
|
||||
JSON.stringify({ id: 'bad-parts', role: 'assistant', parts: { type: 'text', text: 'x' }, metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1, usage: { inputTokens: 10, outputTokens: 5 } } }),
|
||||
// out-of-range timestamp — must not throw; timestamp falls back to ''
|
||||
JSON.stringify({ id: 'bad-ts', role: 'assistant', parts: [{ type: 'text', text: 'x' }], metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1.7e18, usage: { inputTokens: 10, outputTokens: 5 } } }),
|
||||
assistantMessage({ id: 'good', input: 100, output: 20 }),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'p', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(3) // none of the malformed lines aborted the parse
|
||||
expect(calls.find(c => c.deduplicationKey === 'mux:ws-abc:good')?.inputTokens).toBe(100)
|
||||
expect(calls.find(c => c.deduplicationKey === 'mux:ws-abc:bad-ts')?.timestamp).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mux provider - display names', () => {
|
||||
const provider = createMuxProvider('/tmp')
|
||||
|
||||
it('has correct name and displayName', () => {
|
||||
expect(provider.name).toBe('mux')
|
||||
expect(provider.displayName).toBe('Mux')
|
||||
})
|
||||
|
||||
it('strips the provider prefix and humanizes the model', () => {
|
||||
expect(provider.modelDisplayName('anthropic:claude-opus-4-8')).toBe('Opus 4.8')
|
||||
expect(provider.modelDisplayName('anthropic:claude-sonnet-4-6')).toBe('Sonnet 4.6')
|
||||
})
|
||||
|
||||
it('returns the bare id for unknown / prefixless models', () => {
|
||||
expect(provider.modelDisplayName('ollama:some-random-model')).toBe('some-random-model')
|
||||
expect(provider.modelDisplayName('some-random-model')).toBe('some-random-model')
|
||||
})
|
||||
|
||||
it('normalizes tool names to the canonical set', () => {
|
||||
expect(provider.toolDisplayName('bash')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('file_read')).toBe('Read')
|
||||
expect(provider.toolDisplayName('file_edit_insert')).toBe('Edit')
|
||||
expect(provider.toolDisplayName('task')).toBe('Agent')
|
||||
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue