From c6427871135520da3a1ed2ebbbaeca9583f2f488 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Tue, 4 Aug 2026 01:57:27 +0200 Subject: [PATCH] fix(cline-cli): decline the rollup when per-message calls were all deduped The rollup fallback was gated on the post-dedup emitted counter, so a session whose per-message calls were all suppressed by the shared dedup (a duplicated session directory reusing a session_id) fell through to the metadata.usage rollup and double-counted its cost. Gate on a hadMetrics flag set before the dedup check instead. --- node_modules | 1 - src/providers/cline-cli.ts | 10 ++++++--- tests/providers/cline-cli.test.ts | 35 +++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) delete mode 120000 node_modules diff --git a/node_modules b/node_modules deleted file mode 120000 index 43c299b..0000000 --- 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 80f55e9..f4db02b 100644 --- a/src/providers/cline-cli.ts +++ b/src/providers/cline-cli.ts @@ -265,12 +265,17 @@ function createParser(source: SessionSource, seenKeys: Set): 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 @@ -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,7 @@ function createParser(source: SessionSource, seenKeys: Set): 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 diff --git a/tests/providers/cline-cli.test.ts b/tests/providers/cline-cli.test.ts index a197e9e..a32bef1 100644 --- a/tests/providers/cline-cli.test.ts +++ b/tests/providers/cline-cli.test.ts @@ -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 = {} + 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,