diff --git a/node_modules b/node_modules deleted file mode 120000 index 43c299b1..00000000 --- a/node_modules +++ /dev/null @@ -1 +0,0 @@ -/Users/husamsoboh/codeburn/node_modules \ No newline at end of file diff --git a/src/providers/cline-cli.ts b/src/providers/cline-cli.ts index 80f55e9b..842792ca 100644 --- a/src/providers/cline-cli.ts +++ b/src/providers/cline-cli.ts @@ -265,13 +265,18 @@ function createParser(source: SessionSource, seenKeys: Set): 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) @@ -282,7 +287,6 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const { tools, bashCommands, toolSequence, skills, subagentTypes, webSearchRequests } = collectTools(message['content']) - emitted++ yield { provider: PROVIDER_NAME, model, @@ -314,7 +318,10 @@ function createParser(source: SessionSource, seenKeys: Set): 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 diff --git a/tests/providers/cline-cli.test.ts b/tests/providers/cline-cli.test.ts index a197e9e2..5c0d9140 100644 --- a/tests/providers/cline-cli.test.ts +++ b/tests/providers/cline-cli.test.ts @@ -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 { @@ -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', @@ -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 `: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 },