Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion node_modules

This file was deleted.

10 changes: 7 additions & 3 deletions src/providers/cline-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,12 +265,17 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars

const messages = isRecord(doc) && Array.isArray(doc['messages']) ? doc['messages'] : []
const userMessage = firstUserMessage(messages)
let emitted = 0
// Whether the session carried any per-message metrics at all. Set before
// the dedup check below so a session whose calls were all deduped (e.g. a
// duplicated session directory reusing a session_id) still declines the
// rollup fallback rather than double-counting its cost through it.
let hadMetrics = false

for (const [index, message] of messages.entries()) {
if (!isRecord(message) || message['role'] !== 'assistant') continue
const metrics = parseMetrics(message['metrics'])
if (!metrics) continue
hadMetrics = true

const modelInfo = isRecord(message['modelInfo']) ? message['modelInfo'] : {}
const model = nonEmptyString(modelInfo['id']) ?? sessionModel
Expand All @@ -282,7 +287,6 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
const { tools, bashCommands, toolSequence, skills, subagentTypes, webSearchRequests }
= collectTools(message['content'])

emitted++
yield {
provider: PROVIDER_NAME,
model,
Expand Down Expand Up @@ -314,7 +318,7 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
}
}

if (emitted > 0) return
if (hadMetrics) return

// No per-message metrics: fall back to the session rollup so an
// interrupted or older session still reports its spend. Deliberately
Expand Down
35 changes: 35 additions & 0 deletions tests/providers/cline-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,41 @@ describe('cline-cli provider - rollup fallback', () => {
expect(calls.reduce((sum, c) => sum + c.inputTokens, 0)).toBe(300)
})

it('does not fire the rollup when a duplicated session_id deduped every per-message call', async () => {
// A session directory copied on disk: two dirs sharing the same internal
// session_id and message ids, the second also carrying a metadata.usage
// rollup. The shared dedup suppresses the copy's per-message calls; the
// rollup must not then fire and re-count the session. Regression for #894.
for (const [dirName, withRollup] of [['aaa', false], ['bbb', true]] as const) {
const dir = join(tmpDir, dirName)
await mkdir(dir, { recursive: true })
const metadata: Record<string, unknown> = {}
if (withRollup) metadata['usage'] = { inputTokens: 100, outputTokens: 10, totalCost: 0.01 }
await writeFile(join(dir, `${dirName}.json`), JSON.stringify({
version: 1, session_id: 'shared', source: 'cli', status: 'completed',
provider: 'cline-pass', model: 'z-ai/glm-5.2',
cwd: '/Users/dev/work/my-repo', workspace_root: '/Users/dev/work/my-repo',
started_at: '2026-08-02T20:04:18.628Z', ended_at: '2026-08-02T20:08:27.768Z',
metadata, messages_path: join(dir, `${dirName}.messages.json`),
}))
await writeFile(join(dir, `${dirName}.messages.json`), JSON.stringify({
version: 1, sessionId: 'shared', messages: [{
id: 'msg_0', role: 'assistant', content: [{ type: 'text', text: 'a' }],
ts: 1785701064304, metrics: { inputTokens: 100, outputTokens: 10, cost: 0.01 },
modelInfo: { id: 'z-ai/glm-5.2', provider: 'cline-pass' },
}],
}))
}

const calls = await collect(tmpDir)

// Exactly one call (the first copy's msg_0); the copy is deduped and its
// rollup declined, so cost stays $0.01 rather than doubling to $0.02.
expect(calls).toHaveLength(1)
expect(calls.some(c => c.deduplicationKey === 'cline-cli:shared:rollup')).toBe(false)
expect(calls.reduce((sum, c) => sum + c.costUSD, 0)).toBeCloseTo(0.01, 7)
})

it('keeps a metered $0 rollup reported instead of re-estimating it', async () => {
await writeSession(tmpDir, 'sess-a', {
omitMessagesFile: true,
Expand Down