Skip to content
Closed
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.

13 changes: 10 additions & 3 deletions src/providers/cline-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,13 +265,18 @@ 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
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

// Track BEFORE the dedup check: the rollup gate must see metrics-bearing
// messages even when every one is later suppressed by the shared dedup,
// or a duplicated session_id double-counts through the rollup - #894.
hadMetrics = true

const modelInfo = isRecord(message['modelInfo']) ? message['modelInfo'] : {}
const model = nonEmptyString(modelInfo['id']) ?? sessionModel
const messageId = nonEmptyString(message['id']) ?? String(index)
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,10 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
}
}

if (emitted > 0) return
// A session with per-message metrics never falls back to the rollup,
// even when every message was deduped away (#894): the calls were
// already accounted once under this session_id by an earlier directory.
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
37 changes: 36 additions & 1 deletion tests/providers/cline-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ async function writeSession(sessionsDir: string, sessionId: string, opts?: {
startedAt?: string
endedAt?: string
messagesPath?: string
// The on-disk directory keeps `sessionId`; the metadata file records this
// value instead - lets a test model a copied session directory whose
// internal id collides, #894.
sessionIdInMeta?: string
omitMeta?: boolean
omitMessagesFile?: boolean
}): Promise<string> {
Expand All @@ -42,7 +46,7 @@ async function writeSession(sessionsDir: string, sessionId: string, opts?: {

await writeFile(metaPath, JSON.stringify({
version: 1,
session_id: sessionId,
session_id: opts?.sessionIdInMeta ?? sessionId,
source: 'cli',
status: 'completed',
provider: 'cline-pass',
Expand Down Expand Up @@ -432,6 +436,37 @@ describe('cline-cli provider - rollup fallback', () => {
expect(calls[0]?.costIsEstimated).toBe(false)
})

it('declines the rollup when every per-message metric was deduped away (#894)', async () => {
const dir = join(tmpDir, 'sessions')
// Two on-disk directories carrying the SAME internal session_id, as after
// a copied session directory: identical message ids, and the COPY (which
// discovery visits second) also holds a metadata.usage rollup. The first
// directory accounts the calls; the copy's messages are all suppressed by
// the shared dedup, and its rollup must NOT resurrect the same spend
// under `<id>:rollup`.
await writeSession(dir, 'sess-a', {
sessionIdInMeta: 'shared-id',
messages: [
{ role: 'user', text: 'do the thing' },
{ role: 'assistant', text: 'done', metrics: { inputTokens: 100, outputTokens: 20, cost: 0.01 } },
],
})
await writeSession(dir, 'sess-b', {
sessionIdInMeta: 'shared-id',
usage: { inputTokens: 100, outputTokens: 20, totalCost: 0.01 },
messages: [
{ role: 'user', text: 'do the thing' },
{ role: 'assistant', text: 'done', metrics: { inputTokens: 100, outputTokens: 20, cost: 0.01 } },
],
})

const calls = await collect(dir)
expect(calls).toHaveLength(1)
expect(calls[0]!.deduplicationKey).toBe('cline-cli:shared-id:msg_1')
expect(calls.some(c => c.deduplicationKey.endsWith(':rollup'))).toBe(false)
expect(calls.reduce((s, c) => s + c.costUSD, 0)).toBeCloseTo(0.01, 10)
})

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 },
Expand Down