From 448d470049b4e0474cf3274733b202fd14c0906b Mon Sep 17 00:00:00 2001 From: "Rick Culpepper (claude)" Date: Mon, 3 Aug 2026 13:44:49 -0500 Subject: [PATCH] feat(providers): add cline-cli provider for Cline CLI sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Cline CLI (npm `cline`, 3.x) stores sessions as //.json + .messages.json. The existing `cline` provider only discovers tasks//ui_messages.json, so every CLI session was silently reported as $0.00 — no warning, not even under --verbose. Adds `cline-cli` as its own provider rather than a third root on `cline`, leaving the shared Cline-family parser (Roo Code, KiloCode, IBM Bob) untouched. It mirrors the CLI's own root resolution (CLINE_SESSION_DATA_DIR -> CLINE_DATA_DIR -> CLINE_DIR -> ~/.cline), implements probeRoots() so `doctor` can tell "not installed" from "wrong override", emits one call per assistant message's `metrics` block, and falls back to the session rollup when a session carries none. The fallback reads `usage`, not `aggregateUsage`, which folds in spawned subagents that are themselves separate session directories. Two supporting changes, both required for CLI costs to report correctly: - parser.ts re-priced cline-cli calls from tokens because the provider was not on the reported-cost allowlist, inflating a real 12-session local sample from $1.11 to $3.92. - session-cache.ts gains the matching PROVIDER_ENV_VARS entry (so a changed override invalidates) and a `reported-cost-v1` parse version (so sessions cached before the allowlist fix re-parse once instead of being re-priced forever). Cost is treated as metered only when actually present and non-negative, so a metered $0 stays reported while a missing or negative cost falls back to token pricing — applied identically on the per-message and rollup paths. Timestamps promote a seconds-resolution value rather than silently landing in 1970, matching the guard kiro.ts uses. CLINE_DIR / CLINE_DATA_DIR / CLINE_SESSION_DATA_DIR are added to the test env-isolation list so a developer's real sessions cannot bleed into fixtures. The VS Code variant discovery bug reported alongside this in #874 is deliberately NOT fixed here — it shipped in #882. Verified against 18 real local sessions: 142 calls, 4,934,762 input / 224,561 output tokens, and a cost matching the CLI's own metered total to the cent. `codeburn doctor` reports "Cline CLI OK". Refs: #874 --- CHANGELOG.md | 1 + README.md | 1 + docs/providers/README.md | 1 + docs/providers/cline-cli.md | 58 ++++ docs/providers/cline.md | 2 + src/parser.ts | 2 +- src/providers/cline-cli.ts | 414 +++++++++++++++++++++++ src/providers/index.ts | 3 +- src/session-cache.ts | 5 + tests/provider-registry.test.ts | 2 +- tests/provider-turn-grouping.test.ts | 51 +++ tests/providers/cline-cli.test.ts | 481 +++++++++++++++++++++++++++ tests/setup/env-isolation.ts | 3 + 13 files changed, 1021 insertions(+), 3 deletions(-) create mode 100644 docs/providers/cline-cli.md create mode 100644 src/providers/cline-cli.ts create mode 100644 tests/providers/cline-cli.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dd4d65..97d5195 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - **Combined-device scope in the desktop Dashboard**, mirroring the menu bar. A Local / Combined toggle aggregates paired-device usage in the Overview hero and the menu bar badge, degrading gracefully to the local figure when a peer is unreachable; the badge then shows a dimmed `reachable/total` marker so a momentary drop to the local number reads as "a peer is unreachable" rather than a glitch. (#866, #867, thanks @marcreynolds) ### Added (CLI) +- **Cline CLI provider.** The Cline command-line agent (npm `cline`, 3.x) stores sessions as `~/.cline/data/sessions//.json` + `.messages.json`, a layout the existing Cline provider never scanned — it requires `tasks//ui_messages.json` — so every CLI session was silently reported as $0.00, with no warning even under `--verbose`. Added as its own `cline-cli` provider so the shared Cline-family parser (Roo Code, KiloCode, IBM Bob) is untouched; it mirrors the CLI's own root resolution (`CLINE_SESSION_DATA_DIR` → `CLINE_DATA_DIR` → `CLINE_DIR` → `~/.cline`) and reports its probed root through `codeburn doctor`. Per-message cost is metered by the CLI, so `cline-cli` joins the reported-cost pass-through allowlist rather than being re-priced from tokens. (#874) - **Codex throughput tracking**: per-model Tok/s in the dashboard and report, active time excludes tool wait. (#805, thanks @ihearttokyo) - `codeburn sync push --attribution` (opt-in): sends git attribution spans — the session→commit correlation from `codeburn yield` (`codeburn.session.attribution` and `codeburn.commit` span types with normalized repo remote, commit SHAs, merged/reverted state, and PR links). Nothing new is sent without the flag; local-only repos and Windows filesystem paths are never emitted as repo identities, and sessions whose project path no longer resolves never inherit the push-time working directory's repo. See docs/sync/README.md "Git attribution". diff --git a/README.md b/README.md index 7ee86ba..ab44d71 100644 --- a/README.md +++ b/README.md @@ -673,6 +673,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta | **Pi / OMP** | `~/.pi/agent/sessions//*.jsonl` (Pi), `~/.omp/agent/sessions//*.jsonl` (OMP) | Each assistant message carries usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` blocks. Tool names normalize to the standard set (`bash` → `Bash`, `dispatch_agent` → `Agent`); bash commands come from `toolCall.arguments.command`. | | **Codebuff** (formerly Manicode) | `~/.config/manicode/projects//chats//chat-messages.json` (honors `CODEBUFF_DATA_DIR`; walks `manicode-dev` / `manicode-staging`) | Bills in credits, so each completed assistant message is costed at the public rate of $0.01/credit via `msg.credits`. When an upstream provider's stashed RunState records token-level usage (`message.metadata.runState.sessionState.mainAgentState.messageHistory[*].providerOptions`), the real tokens and LiteLLM cost take precedence. Native tool names (`read_files`, `str_replace`, `run_terminal_command`, `spawn_agents`) normalize to `Read`, `Edit`, `Bash`, `Agent`. | | **Cline / Roo Code / KiloCode** | VS Code `globalStorage` across VS Code, VS Code Insiders, and VSCodium (Cline at `saoudrizwan.claude-dev`, plus `~/.cline/data`) | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. | +| **Cline CLI** | `~/.cline/data/sessions//` (honors `CLINE_SESSION_DATA_DIR`, `CLINE_DATA_DIR`, `CLINE_DIR`) | The Cline command-line agent, whose layout is unrelated to the VS Code extension's. Reads `.json` for session metadata and the rolled-up `usage`, and `.messages.json` for the per-message `metrics` block (input, output, cacheRead, cacheWrite, cost) that becomes one call each. | | **CodeWhale** | `~/.codewhale/sessions/*.json` plus unmigrated legacy `~/.deepseek/sessions/*.json`; `$CODEWHALE_HOME/sessions` is an exact override | Emits one cumulative record per saved session. CodeWhale exposes only `total_tokens`, so CodeBurn preserves that aggregate in the input column rather than inventing an input/output split. Cost is the exact stored parent-session plus subagent USD total; model pricing is used only when the cost snapshot is absent. Tool blocks, shell commands, skills, and subagent types are retained. | | **IBM Bob** | `User/globalStorage/ibm.bob-code/tasks//` (GA `IBM Bob` and preview `Bob-IDE` app folders) | Reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model. | | **Kimi Code CLI** | `$KIMI_SHARE_DIR/sessions///` or `~/.kimi/sessions///` | Reads `wire.jsonl` `StatusUpdate.token_usage` records, mapping `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns; includes subagents under each session's `subagents/` folder. | diff --git a/docs/providers/README.md b/docs/providers/README.md index f4d2aa4..3aae178 100644 --- a/docs/providers/README.md +++ b/docs/providers/README.md @@ -12,6 +12,7 @@ For the architectural picture, see `../architecture.md`. |---|---|---|---| | [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) | | [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` | +| [Cline CLI](cline-cli.md) | JSON | `src/providers/cline-cli.ts` | `tests/providers/cline-cli.test.ts` | | [CodeWhale](codewhale.md) | JSON | `src/providers/codewhale.ts` | `tests/providers/codewhale.test.ts` | | [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` | | [Copilot](copilot.md) | JSONL + SQLite (OTel) + Nitrite .db (JetBrains) | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` | diff --git a/docs/providers/cline-cli.md b/docs/providers/cline-cli.md new file mode 100644 index 0000000..69a7b7d --- /dev/null +++ b/docs/providers/cline-cli.md @@ -0,0 +1,58 @@ +# Cline CLI + +The Cline command-line agent (npm `cline`, 3.x). Separate from the [Cline](cline.md) provider, which reads the VS Code extension's task tree. + +- **Source:** `src/providers/cline-cli.ts` +- **Loading:** eager (`src/providers/index.ts`) +- **Test:** `tests/providers/cline-cli.test.ts` + +## Where it reads from + +One root, resolved exactly as the CLI resolves it — each level independently overridable: + +| Level | Env var | Default | +|---|---|---| +| sessions | `CLINE_SESSION_DATA_DIR` | `/sessions` | +| data | `CLINE_DATA_DIR` | `/data` | +| root | `CLINE_DIR` | `~/.cline` | + +A directory is a session only when it contains `/.json`. `probeRoots()` reports the resolved sessions dir, so `codeburn doctor` distinguishes "CLI not installed" from "override pointing somewhere else". + +## Storage format + +``` +sessions// + .json metadata + rolled-up usage + .messages.json per-message metrics +``` + +`.json` carries `session_id`, `provider`, `model`, `cwd`, `workspace_root`, `started_at` / `ended_at`, `messages_path`, and a `metadata.usage` rollup (`inputTokens`, `outputTokens`, `cacheReadTokens`, `cacheWriteTokens`, `totalCost`). + +`.messages.json` holds `{ version, updated_at, agent, sessionId, messages[], system_prompt }`. Assistant messages carry Anthropic-style content blocks (`thinking` / `text` / `tool_use`) plus: + +```jsonc +"modelInfo": { "id": "z-ai/glm-5.2", "provider": "cline-pass" }, +"metrics": { "inputTokens": 6937, "outputTokens": 213, + "cacheReadTokens": 0, "cacheWriteTokens": 0, "cost": 0.002108502 } +``` + +One `metrics` block becomes one parsed call. Dedup key: `cline-cli::`. + +## Caching + +None at the provider level; the metadata file is the cached source path and the normal parser/cache layers apply. + +## Quirks + +- **`provider` in the session file is the upstream LLM route** (e.g. `cline-pass`), not the tool. The codeburn provider name is always `cline-cli`. +- **Model strings are not normalized by the CLI.** The same model appears as `z-ai/glm-5.2`, `cline-pass/glm-5.2`, and `GLM-5.2` across sessions, so pricing lookups may need a `model-alias`. +- **Cost is reported per message**, so `costIsEstimated` is false on the normal path; it falls back to `calculateCost` only when a message omits `cost`. +- **Rollup fallback.** A session whose messages carry no metrics (interrupted, or an older layout) emits a single call from `metadata.usage`. This reads `usage`, deliberately *not* `aggregateUsage` / `aggregatedAgentsCost`, which fold in spawned subagents that are themselves separate session directories and would double count. +- **`messages_path` is absolute** and goes stale when a session directory is copied between machines, so the co-located `.messages.json` is preferred and `messages_path` is only the fallback. +- **Tool names differ from the extension's.** `run_commands`, `read_files`, `search_codebase`, `editor`, `apply_patch`, `fetch_web_content`, `skills`, `spawn_agent`, and the `team_*` family. `run_commands` carries a JSON-encoded array of command lines in a single string field. + +## When fixing a bug here + +1. Reproduce with a minimal session directory: `.json` plus `.messages.json`. +2. Run `tests/providers/cline-cli.test.ts`. +3. This provider shares no code with `vscode-cline-parser.ts` — changes here cannot affect Cline, Roo Code, KiloCode, or IBM Bob. diff --git a/docs/providers/cline.md b/docs/providers/cline.md index ddd2332..ec8e9d2 100644 --- a/docs/providers/cline.md +++ b/docs/providers/cline.md @@ -2,6 +2,8 @@ Cline VS Code extension and Cline home-data task storage. +Sessions from the Cline **command-line** agent use an unrelated layout and are handled by [Cline CLI](cline-cli.md); this provider does not see them. + - **Source:** `src/providers/cline.ts` - **Loading:** eager (`src/providers/index.ts:2`) - **Test:** `tests/providers/cline.test.ts` diff --git a/src/parser.ts b/src/parser.ts index 4fd7e14..004f25c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -2388,7 +2388,7 @@ function providerCallToCachedCall(call: ParsedProviderCall): CachedCall { webSearchRequests: call.webSearchRequests, cacheCreationOneHourTokens: 0, }, - costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale' || call.provider === 'quickdesk') ? call.costUSD : undefined, + costUSD: (call.provider === 'mistral-vibe' || call.provider === 'antigravity' || call.provider === 'devin' || call.provider === 'vercel-gateway' || call.provider === 'hermes' || call.provider === 'kiro' || call.provider === 'codewhale' || call.provider === 'quickdesk' || call.provider === 'cline-cli') ? call.costUSD : undefined, isEstimated: call.costIsEstimated || undefined, speed: call.speed, timestamp: call.timestamp, diff --git a/src/providers/cline-cli.ts b/src/providers/cline-cli.ts new file mode 100644 index 0000000..80f55e9 --- /dev/null +++ b/src/providers/cline-cli.ts @@ -0,0 +1,414 @@ +import { readdir } from 'fs/promises' +import { homedir } from 'os' +import { basename, join } from 'path' + +import { extractBashCommands } from '../bash-utils.js' +import { readSessionFile } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import type { ToolCall } from '../types.js' +import type { ParsedProviderCall, ProbeRoot, Provider, SessionParser, SessionSource } from './types.js' + +// The Cline CLI (npm `cline`, 3.x) stores sessions in a layout unrelated to the +// VS Code extension's tasks/ui_messages.json tree that `cline.ts` reads: +// +// //.json metadata + rolled-up usage +// //.messages.json per-message metrics +// +// Kept as its own provider rather than a third root on `cline` so the shared +// Cline-family parser (also serving Roo Code and KiloCode) stays untouched. + +const PROVIDER_NAME = 'cline-cli' +const DISPLAY_NAME = 'Cline CLI' +const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 + +// Mirrors the CLI's own resolution chain, each level individually overridable: +// sessions := CLINE_SESSION_DATA_DIR ?? /sessions +// data := CLINE_DATA_DIR ?? /data +// root := CLINE_DIR ?? ~/.cline +function clineRootDir(): string { + return process.env['CLINE_DIR']?.trim() || join(homedir(), '.cline') +} + +function clineDataDir(): string { + return process.env['CLINE_DATA_DIR']?.trim() || join(clineRootDir(), 'data') +} + +export function getClineCliSessionsDir(): string { + return process.env['CLINE_SESSION_DATA_DIR']?.trim() || join(clineDataDir(), 'sessions') +} + +const toolNameMap: Record = { + run_commands: 'Bash', + read_files: 'Read', + editor: 'Edit', + apply_patch: 'Edit', + search_codebase: 'Grep', + fetch_web_content: 'WebFetch', + skills: 'Skill', + spawn_agent: 'Agent', + team_spawn_teammate: 'Agent', + team_run_task: 'Agent', + ask_question: 'AskUser', +} + +function mapToolName(rawTool: string): string { + return toolNameMap[rawTool] ?? rawTool +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value.trim() : undefined +} + +function safeNonNegativeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0 +} + +function safeTokenCount(value: unknown): number { + return Math.floor(Math.min(safeNonNegativeNumber(value), Number.MAX_SAFE_INTEGER)) +} + +// A cost counts as metered only when it is actually present and non-negative. +// `0` is a legitimate metered value (a free/cached call) and must stay reported, +// so this is a presence check, not a truthiness check. +function isReportedCost(value: unknown): boolean { + return typeof value === 'number' && Number.isFinite(value) && value >= 0 +} + +type ClineCliMetrics = { + inputTokens: number + outputTokens: number + cacheReadTokens: number + cacheWriteTokens: number + cost: number + costReported: boolean +} + +function parseMetrics(value: unknown): ClineCliMetrics | null { + if (!isRecord(value)) return null + const metrics: ClineCliMetrics = { + inputTokens: safeTokenCount(value['inputTokens']), + outputTokens: safeTokenCount(value['outputTokens']), + cacheReadTokens: safeTokenCount(value['cacheReadTokens']), + cacheWriteTokens: safeTokenCount(value['cacheWriteTokens']), + cost: safeNonNegativeNumber(value['cost']), + // A negative cost is not a credit we can represent — treat it as absent and + // fall back to token pricing, rather than reporting a clamped $0 as metered. + costReported: isReportedCost(value['cost']), + } + const hasTokens = metrics.inputTokens > 0 || metrics.outputTokens > 0 + || metrics.cacheReadTokens > 0 || metrics.cacheWriteTokens > 0 + return hasTokens || metrics.cost > 0 ? metrics : null +} + +function projectName(workspace: string | undefined): string { + if (!workspace) return DISPLAY_NAME + const parts = workspace.replace(/[\\/]+$/, '').split(/[\\/]/).filter(Boolean) + return parts.at(-1) ?? DISPLAY_NAME +} + +function isoTimestamp(value: unknown, fallback: string): string { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + // The CLI writes epoch milliseconds, but a seconds-resolution value would + // otherwise silently land in 1970. Promote it and reject what stays + // implausible, matching the guard kiro.ts uses on the same hazard. + const ms = value < MIN_REASONABLE_TIMESTAMP_MS ? value * 1000 : value + const date = new Date(ms) + if (!Number.isNaN(date.getTime()) && date.getTime() >= MIN_REASONABLE_TIMESTAMP_MS) { + return date.toISOString() + } + } + const parsed = nonEmptyString(value) + if (parsed) { + const date = new Date(parsed) + if (!Number.isNaN(date.getTime())) return date.toISOString() + } + return fallback +} + +// `run_commands` carries its commands as a JSON-encoded array in a string +// field; anything else is treated as a single command line. +function commandsFrom(input: unknown): string[] { + if (!isRecord(input)) return [] + const raw = input['commands'] ?? input['command'] + if (Array.isArray(raw)) return raw.filter((c): c is string => typeof c === 'string') + const text = nonEmptyString(raw) + if (!text) return [] + if (text.startsWith('[')) { + try { + const parsed = JSON.parse(text) as unknown + if (Array.isArray(parsed)) return parsed.filter((c): c is string => typeof c === 'string') + } catch { + // Not JSON after all - fall through and treat the whole string as one command. + } + } + return [text] +} + +function firstString(input: unknown, keys: string[]): string | undefined { + if (!isRecord(input)) return undefined + for (const key of keys) { + const value = nonEmptyString(input[key]) + if (value) return value + } + return undefined +} + +type CollectedTools = { + tools: string[] + bashCommands: string[] + toolSequence: ToolCall[][] + skills: string[] + subagentTypes: string[] + webSearchRequests: number +} + +function collectTools(content: unknown): CollectedTools { + const collected: CollectedTools = { + tools: [], bashCommands: [], toolSequence: [], skills: [], subagentTypes: [], webSearchRequests: 0, + } + if (!Array.isArray(content)) return collected + + const turnTools: ToolCall[] = [] + for (const block of content) { + if (!isRecord(block) || block['type'] !== 'tool_use') continue + const rawName = nonEmptyString(block['name']) + if (!rawName) continue + const mapped = mapToolName(rawName) + const input = block['input'] + const toolCall: ToolCall = { tool: mapped } + + const file = firstString(input, ['path', 'file_path', 'paths', 'file']) + if (file) toolCall.file = file + + if (mapped === 'Bash') { + const commands = commandsFrom(input) + const [first] = commands + if (first) toolCall.command = first + for (const command of commands) collected.bashCommands.push(...extractBashCommands(command)) + } + if (mapped === 'Skill') { + const skill = firstString(input, ['name', 'skill', 'skill_name']) + if (skill) collected.skills.push(skill) + } + if (mapped === 'Agent') { + const subagentType = firstString(input, ['agent', 'agent_type', 'type', 'name']) + if (subagentType) collected.subagentTypes.push(subagentType) + } + if (mapped === 'WebFetch') collected.webSearchRequests++ + + collected.tools.push(mapped) + turnTools.push(toolCall) + } + + if (turnTools.length > 0) collected.toolSequence.push(turnTools) + return collected +} + +function textFromContent(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + for (const block of content) { + if (!isRecord(block) || block['type'] !== 'text') continue + const text = nonEmptyString(block['text']) + if (text) return text + } + return '' +} + +function firstUserMessage(messages: unknown[]): string { + for (const message of messages) { + if (!isRecord(message) || message['role'] !== 'user') continue + const text = textFromContent(message['content']) + // Tool results come back as role:user too; they carry no text block. + if (text) return text + } + return '' +} + +async function readJson(path: string): Promise { + const raw = await readSessionFile(path) + if (raw === null) return null + try { + return JSON.parse(raw) as unknown + } catch { + return null + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const meta = await readJson(source.path) + if (!isRecord(meta)) return + + const sessionId = nonEmptyString(meta['session_id']) ?? basename(source.path).replace(/\.json$/, '') + const metadata = isRecord(meta['metadata']) ? meta['metadata'] : {} + const workspace = nonEmptyString(meta['workspace_root']) ?? nonEmptyString(meta['cwd']) + const sessionModel = nonEmptyString(meta['model']) ?? 'unknown' + const startedAt = isoTimestamp(meta['started_at'], new Date(0).toISOString()) + // Always populated by discoverSessions; projectName falls back to the + // display name, so there is nothing left to default to here. + const project = source.project + + // Prefer the co-located messages file over the recorded absolute path, + // which is stale once a session directory is copied between machines. + const sibling = join(source.path.replace(/\.json$/, '') + '.messages.json') + let doc = await readJson(sibling) + if (!isRecord(doc)) { + const recorded = nonEmptyString(meta['messages_path']) + if (recorded) doc = await readJson(recorded) + } + + const messages = isRecord(doc) && Array.isArray(doc['messages']) ? doc['messages'] : [] + const userMessage = firstUserMessage(messages) + let emitted = 0 + + for (const [index, message] of messages.entries()) { + if (!isRecord(message) || message['role'] !== 'assistant') continue + const metrics = parseMetrics(message['metrics']) + if (!metrics) continue + + const modelInfo = isRecord(message['modelInfo']) ? message['modelInfo'] : {} + const model = nonEmptyString(modelInfo['id']) ?? sessionModel + const messageId = nonEmptyString(message['id']) ?? String(index) + const deduplicationKey = `${PROVIDER_NAME}:${sessionId}:${messageId}` + if (seenKeys.has(deduplicationKey)) continue + seenKeys.add(deduplicationKey) + + const { tools, bashCommands, toolSequence, skills, subagentTypes, webSearchRequests } + = collectTools(message['content']) + + emitted++ + yield { + provider: PROVIDER_NAME, + model, + inputTokens: metrics.inputTokens, + outputTokens: metrics.outputTokens, + cacheCreationInputTokens: metrics.cacheWriteTokens, + cacheReadInputTokens: metrics.cacheReadTokens, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests, + costUSD: metrics.costReported + ? metrics.cost + : calculateCost(model, metrics.inputTokens, metrics.outputTokens, metrics.cacheWriteTokens, metrics.cacheReadTokens, 0), + costIsEstimated: !metrics.costReported, + tools, + bashCommands, + skills: skills.length > 0 ? skills : undefined, + subagentTypes: subagentTypes.length > 0 ? subagentTypes : undefined, + timestamp: isoTimestamp(message['ts'], startedAt), + speed: 'standard', + deduplicationKey, + turnId: `${sessionId}:${messageId}`, + toolSequence: toolSequence.length > 0 ? toolSequence : undefined, + userMessage, + sessionId, + project, + projectPath: workspace, + workingDirectory: nonEmptyString(meta['cwd']), + } + } + + if (emitted > 0) return + + // No per-message metrics: fall back to the session rollup so an + // interrupted or older session still reports its spend. Deliberately + // reads `usage` and not `aggregateUsage`, which folds in spawned + // subagents that are themselves separate session directories. + const rollup = parseMetrics(isRecord(metadata['usage']) ? metadata['usage'] : null) + if (!rollup) return + const deduplicationKey = `${PROVIDER_NAME}:${sessionId}:rollup` + if (seenKeys.has(deduplicationKey)) return + seenKeys.add(deduplicationKey) + + // Same presence-not-truthiness rule as the per-message path: a metered + // $0 rollup stays reported instead of being re-estimated from tokens. + const rawRollupCost = (isRecord(metadata['usage']) ? metadata['usage']['totalCost'] : undefined) + ?? metadata['totalCost'] + const rollupCostReported = isReportedCost(rawRollupCost) + const rollupCost = safeNonNegativeNumber(rawRollupCost) + + yield { + provider: PROVIDER_NAME, + model: sessionModel, + inputTokens: rollup.inputTokens, + outputTokens: rollup.outputTokens, + cacheCreationInputTokens: rollup.cacheWriteTokens, + cacheReadInputTokens: rollup.cacheReadTokens, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: rollupCostReported + ? rollupCost + : calculateCost(sessionModel, rollup.inputTokens, rollup.outputTokens, rollup.cacheWriteTokens, rollup.cacheReadTokens, 0), + costIsEstimated: !rollupCostReported, + tools: [], + bashCommands: [], + timestamp: isoTimestamp(meta['ended_at'], startedAt), + speed: 'standard', + deduplicationKey, + turnId: `${sessionId}:rollup`, + userMessage, + sessionId, + project, + projectPath: workspace, + workingDirectory: nonEmptyString(meta['cwd']), + } + }, + } +} + +export function createClineCliProvider(overrideDir?: string): Provider { + const sessionsDir = (): string => overrideDir ?? getClineCliSessionsDir() + + return { + name: PROVIDER_NAME, + displayName: DISPLAY_NAME, + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return mapToolName(rawTool) + }, + + async probeRoots(): Promise { + return [{ path: sessionsDir(), label: 'Cline CLI sessions' }] + }, + + async discoverSessions(): Promise { + const dir = sessionsDir() + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []) + const sources: SessionSource[] = [] + + for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) { + if (!entry.isDirectory()) continue + const sessionId = entry.name + const metaPath = join(dir, sessionId, `${sessionId}.json`) + const meta = await readJson(metaPath) + if (!isRecord(meta)) continue + + const workspace = nonEmptyString(meta['workspace_root']) ?? nonEmptyString(meta['cwd']) + sources.push({ + path: metaPath, + project: projectName(workspace), + provider: PROVIDER_NAME, + }) + } + + return sources + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const clineCli = createClineCliProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index 07dc390..8c53801 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -1,5 +1,6 @@ import { claude } from './claude.js' import { cline } from './cline.js' +import { clineCli } from './cline-cli.js' import { codewhale } from './codewhale.js' import { codebuff } from './codebuff.js' import { codex } from './codex.js' @@ -190,7 +191,7 @@ async function loadZed(): Promise { } } -const coreProviders: Provider[] = [claude, cline, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] +const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, 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. diff --git a/src/session-cache.ts b/src/session-cache.ts index 0a4c4c2..9405ce4 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -173,6 +173,7 @@ const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 export const PROVIDER_ENV_VARS: Record = { claude: ['CLAUDE_CONFIG_DIRS', 'CLAUDE_CONFIG_DIR'], + 'cline-cli': ['CLINE_SESSION_DATA_DIR', 'CLINE_DATA_DIR', 'CLINE_DIR'], codewhale: ['CODEWHALE_HOME'], codex: ['CODEX_HOME'], hermes: ['HERMES_HOME'], @@ -208,6 +209,10 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // the new optional fields. claude: 'advisor-usage-v1-skills-rich-capture-v1-cross-provider-pr-v1', cline: 'worktree-project-grouping-v1', + // reported-cost-v1: the CLI reports its own per-message cost, so entries + // cached before cline-cli joined the reported-cost allowlist in parser.ts + // hold costUSD: undefined and get re-priced from tokens on every read. + 'cline-cli': 'reported-cost-v1', codewhale: 'aggregate-session-v1-est-cost', // Bump when the Codex parser changes attribution so unchanged, already-cached // session files re-parse (session-cache.json serves them without invoking the diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 5f5f201..71d2305 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -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', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', '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', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { diff --git a/tests/provider-turn-grouping.test.ts b/tests/provider-turn-grouping.test.ts index e729c5d..4d01495 100644 --- a/tests/provider-turn-grouping.test.ts +++ b/tests/provider-turn-grouping.test.ts @@ -237,6 +237,57 @@ describe('provider turn grouping', () => { delete process.env['KIRO_HOME'] } }) + + it('preserves Cline CLI reported cost through cache conversion instead of re-pricing from tokens', async () => { + const sessionsDir = join(home, '.cline', 'data', 'sessions') + const sessionId = '1785701058566_vnwtz' + const dir = join(sessionsDir, sessionId) + await mkdir(dir, { recursive: true }) + process.env['CLINE_SESSION_DATA_DIR'] = sessionsDir + + // A large token count paired with a deliberately tiny reported cost: any + // token-based re-pricing would land orders of magnitude above $0.0123, + // so passing means the CLI's own per-message cost survived the round trip. + await writeFile(join(dir, `${sessionId}.json`), JSON.stringify({ + version: 1, + session_id: sessionId, + source: 'cli', + status: 'completed', + provider: 'cline-pass', + model: 'z-ai/glm-5.2', + cwd: '/Users/test/project-a', + workspace_root: '/Users/test/project-a', + started_at: '2026-05-16T10:00:00.000Z', + ended_at: '2026-05-16T10:01:00.000Z', + metadata: {}, + })) + await writeFile(join(dir, `${sessionId}.messages.json`), JSON.stringify({ + version: 1, + agent: 'lead', + sessionId, + messages: [ + { id: 'u1', role: 'user', content: [{ type: 'text', text: 'do the thing' }], ts: Date.parse('2026-05-16T10:00:00.000Z') }, + { + id: 'a1', + role: 'assistant', + content: [{ type: 'text', text: 'done' }], + ts: Date.parse('2026-05-16T10:00:30.000Z'), + modelInfo: { id: 'z-ai/glm-5.2', provider: 'cline-pass' }, + metrics: { inputTokens: 500000, outputTokens: 20000, cacheReadTokens: 100000, cacheWriteTokens: 0, cost: 0.0123 }, + }, + ], + })) + + try { + const parseAllSessions = await loadParser() + const projects = await parseAllSessions(dayRange(), 'cline-cli') + const session = projects[0]!.sessions[0]! + + expect(session.totalCostUSD).toBeCloseTo(0.0123, 8) + } finally { + delete process.env['CLINE_SESSION_DATA_DIR'] + } + }) }) describe('provider turn range filtering', () => { diff --git a/tests/providers/cline-cli.test.ts b/tests/providers/cline-cli.test.ts new file mode 100644 index 0000000..a197e9e --- /dev/null +++ b/tests/providers/cline-cli.test.ts @@ -0,0 +1,481 @@ +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 { clineCli, createClineCliProvider, getClineCliSessionsDir } from '../../src/providers/cline-cli.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +type MessageSpec = { + role: 'user' | 'assistant' + text?: string + metrics?: Record + model?: string + ts?: number + toolUse?: { name: string; input: Record } +} + +async function writeSession(sessionsDir: string, sessionId: string, opts?: { + messages?: MessageSpec[] + usage?: Record + totalCost?: number + model?: string + workspaceRoot?: string + cwd?: string + startedAt?: string + endedAt?: string + messagesPath?: string + omitMeta?: boolean + omitMessagesFile?: boolean +}): Promise { + const dir = join(sessionsDir, sessionId) + await mkdir(dir, { recursive: true }) + const metaPath = join(dir, `${sessionId}.json`) + const messagesPath = join(dir, `${sessionId}.messages.json`) + + if (!opts?.omitMeta) { + const metadata: Record = {} + if (opts?.usage) metadata['usage'] = opts.usage + if (opts?.totalCost !== undefined) metadata['totalCost'] = opts.totalCost + + await writeFile(metaPath, JSON.stringify({ + version: 1, + session_id: sessionId, + source: 'cli', + status: 'completed', + provider: 'cline-pass', + model: opts?.model ?? 'z-ai/glm-5.2', + cwd: opts?.cwd ?? '/Users/dev/work/my-repo', + workspace_root: opts?.workspaceRoot ?? opts?.cwd ?? '/Users/dev/work/my-repo', + started_at: opts?.startedAt ?? '2026-08-02T20:04:18.628Z', + ended_at: opts?.endedAt ?? '2026-08-02T20:08:27.768Z', + metadata, + messages_path: opts?.messagesPath ?? messagesPath, + })) + } + + if (!opts?.omitMessagesFile) { + const messages = (opts?.messages ?? []).map((spec, index) => { + const content: unknown[] = [] + if (spec.text) content.push({ type: 'text', text: spec.text }) + if (spec.toolUse) content.push({ type: 'tool_use', id: `call_${index}`, ...spec.toolUse }) + + const message: Record = { + id: `msg_${index}`, + role: spec.role, + content, + ts: spec.ts ?? 1785701064304 + index * 1000, + } + if (spec.metrics) message['metrics'] = spec.metrics + if (spec.model) message['modelInfo'] = { id: spec.model, provider: 'cline-pass' } + return message + }) + + await writeFile(messagesPath, JSON.stringify({ + version: 1, updated_at: opts?.endedAt, agent: 'lead', sessionId, messages, system_prompt: 'sp', + })) + } + + return dir +} + +async function collect(sessionsDir: string): Promise { + const provider = createClineCliProvider(sessionsDir) + const sources = await provider.discoverSessions() + const seenKeys = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call) + } + return calls +} + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cline-cli-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +describe('cline-cli provider - identity', () => { + it('registers under its own provider name', () => { + expect(clineCli.name).toBe('cline-cli') + expect(clineCli.displayName).toBe('Cline CLI') + }) + + it('maps CLI tool names onto codeburn canonical names', () => { + expect(clineCli.toolDisplayName('run_commands')).toBe('Bash') + expect(clineCli.toolDisplayName('read_files')).toBe('Read') + expect(clineCli.toolDisplayName('search_codebase')).toBe('Grep') + expect(clineCli.toolDisplayName('apply_patch')).toBe('Edit') + expect(clineCli.toolDisplayName('spawn_agent')).toBe('Agent') + // Unknown tools pass through rather than being dropped. + expect(clineCli.toolDisplayName('team_mission_log')).toBe('team_mission_log') + }) +}) + +describe('cline-cli provider - sessions dir resolution', () => { + beforeEach(() => { + delete process.env['CLINE_DIR'] + delete process.env['CLINE_DATA_DIR'] + delete process.env['CLINE_SESSION_DATA_DIR'] + }) + + it('defaults to ~/.cline/data/sessions', () => { + expect(getClineCliSessionsDir()).toBe(join(process.env['HOME'] ?? '', '.cline', 'data', 'sessions')) + }) + + it('honors CLINE_DIR', () => { + process.env['CLINE_DIR'] = '/custom/root' + expect(getClineCliSessionsDir()).toBe(join('/custom/root', 'data', 'sessions')) + }) + + it('honors CLINE_DATA_DIR over CLINE_DIR', () => { + process.env['CLINE_DIR'] = '/custom/root' + process.env['CLINE_DATA_DIR'] = '/custom/data' + expect(getClineCliSessionsDir()).toBe(join('/custom/data', 'sessions')) + }) + + it('honors CLINE_SESSION_DATA_DIR over everything else', () => { + process.env['CLINE_DIR'] = '/custom/root' + process.env['CLINE_DATA_DIR'] = '/custom/data' + process.env['CLINE_SESSION_DATA_DIR'] = '/custom/sessions' + expect(getClineCliSessionsDir()).toBe('/custom/sessions') + }) + + it('reports the resolved root for doctor', async () => { + process.env['CLINE_SESSION_DATA_DIR'] = '/custom/sessions' + expect(await clineCli.probeRoots?.()).toEqual([{ path: '/custom/sessions', label: 'Cline CLI sessions' }]) + }) +}) + +describe('cline-cli provider - discovery', () => { + it('discovers one source per session directory', async () => { + await writeSession(tmpDir, 'sess-a') + await writeSession(tmpDir, 'sess-b') + + const sources = await createClineCliProvider(tmpDir).discoverSessions() + + expect(sources).toHaveLength(2) + expect(sources.map(s => s.provider)).toEqual(['cline-cli', 'cline-cli']) + expect(sources[0]?.path).toBe(join(tmpDir, 'sess-a', 'sess-a.json')) + }) + + it('names the project from the workspace root', async () => { + await writeSession(tmpDir, 'sess-a', { workspaceRoot: '/Users/dev/work/awesome-repo' }) + + const [source] = await createClineCliProvider(tmpDir).discoverSessions() + + expect(source?.project).toBe('awesome-repo') + }) + + it('skips directories without a session metadata file', async () => { + await mkdir(join(tmpDir, 'not-a-session'), { recursive: true }) + await writeSession(tmpDir, 'sess-a') + + const sources = await createClineCliProvider(tmpDir).discoverSessions() + + expect(sources).toHaveLength(1) + }) + + it('skips a session whose metadata file is corrupt', async () => { + const dir = join(tmpDir, 'sess-bad') + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'sess-bad.json'), '{ not json') + + expect(await createClineCliProvider(tmpDir).discoverSessions()).toHaveLength(0) + }) + + it('returns nothing when the sessions dir does not exist', async () => { + expect(await createClineCliProvider(join(tmpDir, 'missing')).discoverSessions()).toHaveLength(0) + }) +}) + +describe('cline-cli provider - parsing', () => { + it('emits one call per assistant message carrying metrics', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [ + { role: 'user', text: 'do the thing' }, + { role: 'assistant', text: 'ok', metrics: { inputTokens: 100, outputTokens: 10, cacheReadTokens: 5, cacheWriteTokens: 2, cost: 0.01 } }, + { role: 'user', text: '' }, + { role: 'assistant', text: 'done', metrics: { inputTokens: 200, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, cost: 0.02 } }, + ], + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(2) + expect(calls.map(c => c.inputTokens)).toEqual([100, 200]) + expect(calls.map(c => c.outputTokens)).toEqual([10, 20]) + expect(calls[0]?.cacheReadInputTokens).toBe(5) + expect(calls[0]?.cacheCreationInputTokens).toBe(2) + expect(calls.map(c => c.costUSD)).toEqual([0.01, 0.02]) + expect(calls.every(c => c.costIsEstimated === false)).toBe(true) + expect(calls.every(c => c.provider === 'cline-cli')).toBe(true) + }) + + it('carries session identity, project and timestamps onto each call', async () => { + await writeSession(tmpDir, 'sess-a', { + workspaceRoot: '/Users/dev/work/awesome-repo', + cwd: '/Users/dev/work/awesome-repo/sub', + messages: [{ role: 'assistant', text: 'hi', ts: 1785701064304, metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.sessionId).toBe('sess-a') + expect(call?.project).toBe('awesome-repo') + expect(call?.projectPath).toBe('/Users/dev/work/awesome-repo') + expect(call?.workingDirectory).toBe('/Users/dev/work/awesome-repo/sub') + expect(call?.timestamp).toBe(new Date(1785701064304).toISOString()) + }) + + it('prefers the per-message model over the session model', async () => { + await writeSession(tmpDir, 'sess-a', { + model: 'session-model', + messages: [ + { role: 'assistant', text: 'a', model: 'z-ai/glm-5.2', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }, + { role: 'assistant', text: 'b', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }, + ], + }) + + const calls = await collect(tmpDir) + + expect(calls.map(c => c.model)).toEqual(['z-ai/glm-5.2', 'session-model']) + }) + + it('extracts tools and bash commands from tool_use blocks', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [ + { + role: 'assistant', text: 'running', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 }, + toolUse: { name: 'run_commands', input: { commands: JSON.stringify(['git status', 'ls -la']) } }, + }, + { + role: 'assistant', text: 'reading', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 }, + toolUse: { name: 'read_files', input: { path: '/tmp/a.ts' } }, + }, + ], + }) + + const calls = await collect(tmpDir) + + expect(calls[0]?.tools).toEqual(['Bash']) + expect(calls[0]?.bashCommands).toContain('git') + expect(calls[0]?.bashCommands).toContain('ls') + expect(calls[1]?.tools).toEqual(['Read']) + expect(calls[1]?.toolSequence?.[0]?.[0]).toEqual({ tool: 'Read', file: '/tmp/a.ts' }) + }) + + it('treats a non-JSON commands string as a single command', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [{ + role: 'assistant', text: 'x', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 }, + toolUse: { name: 'run_commands', input: { commands: 'git status' } }, + }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.bashCommands).toContain('git') + }) + + it('uses the first user text as the session user message, skipping tool results', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [ + { role: 'user', text: 'the real prompt' }, + { role: 'assistant', text: 'ok', metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }, + ], + }) + + const [call] = await collect(tmpDir) + + expect(call?.userMessage).toBe('the real prompt') + }) + + it('deduplicates repeated parses via the shared seenKeys set', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 5, outputTokens: 1, cost: 0.1 } }], + }) + + const provider = createClineCliProvider(tmpDir) + const [source] = await provider.discoverSessions() + const seenKeys = new Set() + + 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('estimates cost when the message reports none', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 1000, outputTokens: 100 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.costIsEstimated).toBe(true) + expect(call?.costUSD).toBeGreaterThan(0) + }) + + it('keeps a metered $0 cost reported instead of re-estimating it', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 1000, outputTokens: 100, cost: 0 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.costUSD).toBe(0) + expect(call?.costIsEstimated).toBe(false) + }) + + it('treats a negative cost as absent rather than reporting a clamped $0', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 1000, outputTokens: 100, cost: -5 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.costIsEstimated).toBe(true) + expect(call?.costUSD).toBeGreaterThan(0) + }) + + it('promotes a seconds-resolution timestamp instead of landing in 1970', async () => { + const seconds = Math.floor(Date.parse('2026-08-02T20:04:18.000Z') / 1000) + await writeSession(tmpDir, 'sess-a', { + messages: [{ role: 'assistant', text: 'a', ts: seconds, metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.timestamp).toBe('2026-08-02T20:04:18.000Z') + }) + + it('falls back to the session start when a message carries no timestamp', async () => { + await writeSession(tmpDir, 'sess-a', { + startedAt: '2026-08-02T20:04:18.628Z', + messages: [{ role: 'assistant', text: 'a', ts: 0, metrics: { inputTokens: 1, outputTokens: 1, cost: 0.1 } }], + }) + + const [call] = await collect(tmpDir) + + expect(call?.timestamp).toBe('2026-08-02T20:04:18.628Z') + }) + + it('survives a messages file whose messages field is not an array', async () => { + const dir = join(tmpDir, 'sess-a') + await writeSession(tmpDir, 'sess-a', { messages: [] }) + await writeFile(join(dir, 'sess-a.messages.json'), JSON.stringify({ version: 1, messages: { nope: true } })) + + expect(await collect(tmpDir)).toHaveLength(0) + }) + + it('survives a corrupt messages file without dropping the session rollup', async () => { + const dir = join(tmpDir, 'sess-a') + await writeSession(tmpDir, 'sess-a', { + usage: { inputTokens: 100, outputTokens: 10, totalCost: 0.05 }, + messages: [], + }) + await writeFile(join(dir, 'sess-a.messages.json'), '{ not json') + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(1) + expect(calls[0]?.inputTokens).toBe(100) + }) + + it('ignores assistant messages with no usage at all', async () => { + await writeSession(tmpDir, 'sess-a', { + messages: [ + { role: 'assistant', text: 'no metrics here' }, + { role: 'assistant', text: 'zeroed', metrics: { inputTokens: 0, outputTokens: 0, cost: 0 } }, + ], + }) + + expect(await collect(tmpDir)).toHaveLength(0) + }) + + it('reads the co-located messages file when messages_path is stale', async () => { + await writeSession(tmpDir, 'sess-a', { + messagesPath: '/nonexistent/other-machine/sess-a.messages.json', + messages: [{ role: 'assistant', text: 'a', metrics: { inputTokens: 7, outputTokens: 1, cost: 0.1 } }], + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(1) + expect(calls[0]?.inputTokens).toBe(7) + }) +}) + +describe('cline-cli provider - rollup fallback', () => { + it('falls back to the session rollup when no message carries metrics', async () => { + await writeSession(tmpDir, 'sess-a', { + omitMessagesFile: true, + usage: { inputTokens: 5483, outputTokens: 133, cacheReadTokens: 50, cacheWriteTokens: 0, totalCost: 0.0081984 }, + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(1) + expect(calls[0]?.inputTokens).toBe(5483) + expect(calls[0]?.outputTokens).toBe(133) + expect(calls[0]?.cacheReadInputTokens).toBe(50) + expect(calls[0]?.costUSD).toBeCloseTo(0.0081984, 7) + expect(calls[0]?.costIsEstimated).toBe(false) + }) + + it('does not double count when per-message metrics already covered the session', async () => { + await writeSession(tmpDir, 'sess-a', { + usage: { inputTokens: 300, outputTokens: 30, totalCost: 0.03 }, + messages: [ + { role: 'assistant', text: 'a', metrics: { inputTokens: 100, outputTokens: 10, cost: 0.01 } }, + { role: 'assistant', text: 'b', metrics: { inputTokens: 200, outputTokens: 20, cost: 0.02 } }, + ], + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(2) + expect(calls.reduce((sum, c) => sum + c.inputTokens, 0)).toBe(300) + }) + + it('keeps a metered $0 rollup reported instead of re-estimating it', async () => { + await writeSession(tmpDir, 'sess-a', { + omitMessagesFile: true, + usage: { inputTokens: 1000, outputTokens: 100, totalCost: 0 }, + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(1) + expect(calls[0]?.costUSD).toBe(0) + expect(calls[0]?.costIsEstimated).toBe(false) + }) + + it('estimates a rollup that reports no cost at all', async () => { + await writeSession(tmpDir, 'sess-a', { + omitMessagesFile: true, + usage: { inputTokens: 1000, outputTokens: 100 }, + }) + + const calls = await collect(tmpDir) + + expect(calls).toHaveLength(1) + expect(calls[0]?.costIsEstimated).toBe(true) + expect(calls[0]?.costUSD).toBeGreaterThan(0) + }) + + it('emits nothing for a session with neither message metrics nor a rollup', async () => { + await writeSession(tmpDir, 'sess-a', { omitMessagesFile: true }) + + expect(await collect(tmpDir)).toHaveLength(0) + }) +}) diff --git a/tests/setup/env-isolation.ts b/tests/setup/env-isolation.ts index 751db6d..ea1c32a 100644 --- a/tests/setup/env-isolation.ts +++ b/tests/setup/env-isolation.ts @@ -49,6 +49,9 @@ const CLEARED = [ // Provider session-discovery dirs 'CLAUDE_CONFIG_DIR', 'CLAUDE_CONFIG_DIRS', + 'CLINE_DIR', + 'CLINE_DATA_DIR', + 'CLINE_SESSION_DATA_DIR', 'CODEX_HOME', 'CODEWHALE_HOME', 'CRUSH_GLOBAL_DATA',