feat(providers): add cline-cli provider for Cline CLI sessions

The Cline CLI (npm `cline`, 3.x) stores sessions as
<sessions>/<id>/<id>.json + <id>.messages.json. The existing `cline`
provider only discovers tasks/<id>/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
This commit is contained in:
Rick Culpepper (claude) 2026-08-03 13:44:49 -05:00 committed by ozymandiashh
parent 44a94f52cf
commit 448d470049
13 changed files with 1021 additions and 3 deletions

View file

@ -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/<id>/<id>.json` + `<id>.messages.json`, a layout the existing Cline provider never scanned — it requires `tasks/<id>/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".

View file

@ -673,6 +673,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
| **Pi / OMP** | `~/.pi/agent/sessions/<sanitized-cwd>/*.jsonl` (Pi), `~/.omp/agent/sessions/<sanitized-cwd>/*.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/<project>/chats/<chatId>/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/<session-id>/` (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 `<session-id>.json` for session metadata and the rolled-up `usage`, and `<session-id>.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/<task-id>/` (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/<workdir-hash>/<session-id>/` or `~/.kimi/sessions/<workdir-hash>/<session-id>/` | 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. |

View file

@ -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` |

View file

@ -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` | `<data>/sessions` |
| data | `CLINE_DATA_DIR` | `<root>/data` |
| root | `CLINE_DIR` | `~/.cline` |
A directory is a session only when it contains `<sessionId>/<sessionId>.json`. `probeRoots()` reports the resolved sessions dir, so `codeburn doctor` distinguishes "CLI not installed" from "override pointing somewhere else".
## Storage format
```
sessions/<sessionId>/
<sessionId>.json metadata + rolled-up usage
<sessionId>.messages.json per-message metrics
```
`<sessionId>.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`).
`<sessionId>.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:<sessionId>:<messageId>`.
## 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 `<sessionId>.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: `<id>.json` plus `<id>.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.

View file

@ -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`

View file

@ -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,

414
src/providers/cline-cli.ts Normal file
View file

@ -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:
//
// <sessions>/<sessionId>/<sessionId>.json metadata + rolled-up usage
// <sessions>/<sessionId>/<sessionId>.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 ?? <data>/sessions
// data := CLINE_DATA_DIR ?? <root>/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<string, string> = {
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<string, unknown> {
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<unknown> {
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<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
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<ProbeRoot[]> {
return [{ path: sessionsDir(), label: 'Cline CLI sessions' }]
},
async discoverSessions(): Promise<SessionSource[]> {
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<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const clineCli = createClineCliProvider()

View file

@ -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<Provider | null> {
}
}
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.

View file

@ -173,6 +173,7 @@ const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000
export const PROVIDER_ENV_VARS: Record<string, string[]> = {
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<string, string> = {
// 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

View file

@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', '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', () => {

View file

@ -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', () => {

View file

@ -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<string, number>
model?: string
ts?: number
toolUse?: { name: string; input: Record<string, unknown> }
}
async function writeSession(sessionsDir: string, sessionId: string, opts?: {
messages?: MessageSpec[]
usage?: Record<string, number>
totalCost?: number
model?: string
workspaceRoot?: string
cwd?: string
startedAt?: string
endedAt?: string
messagesPath?: string
omitMeta?: boolean
omitMessagesFile?: boolean
}): Promise<string> {
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<string, unknown> = {}
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<string, unknown> = {
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<ParsedProviderCall[]> {
const provider = createClineCliProvider(sessionsDir)
const sources = await provider.discoverSessions()
const seenKeys = new Set<string>()
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<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('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)
})
})

View file

@ -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',