mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-06 15:14:37 +00:00
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:
parent
44a94f52cf
commit
448d470049
13 changed files with 1021 additions and 3 deletions
|
|
@ -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".
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
58
docs/providers/cline-cli.md
Normal file
58
docs/providers/cline-cli.md
Normal 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.
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
414
src/providers/cline-cli.ts
Normal 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()
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
481
tests/providers/cline-cli.test.ts
Normal file
481
tests/providers/cline-cli.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue