diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index 0394923..8963d21 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -480,9 +480,7 @@ function loadComposerInputTokens(db: SqliteDatabase): Map { try { const rows = db.query<{ composer_id: string; used: number | null; ctx: number | null }>(COMPOSER_TOKENS_QUERY) for (const r of rows) { - // Distinguish null (field absent) from 0 (valid token count). - // `used ?? ctx` would fall through on 0, suppressing a real zero. - const tokens = r.used !== null ? r.used : (r.ctx ?? 0) + const tokens = r.used ?? r.ctx ?? 0 if (r.composer_id && tokens > 0) map.set(r.composer_id, tokens) } } catch { @@ -641,6 +639,9 @@ function parseBubbles( let inputTokens = row.input_tokens ?? 0 let outputTokens = row.output_tokens ?? 0 + // The conversation's tools/bash attach to the single call that carries its + // real input (its first turn), so they are counted exactly once. + let creditedHere = false // Current Cursor leaves tokenCount at {0,0}. Use the conversation's real // context size (promptTokenBreakdown) for input, credited once per @@ -656,6 +657,7 @@ function parseBubbles( } else { inputTokens = real creditedComposers.add(conversationId) + creditedHere = true } } else { inputTokens = Math.ceil(textLen / CHARS_PER_TOKEN) @@ -692,7 +694,7 @@ function parseBubbles( const languages = extractLanguages(blobToText(row.code_blocks)) const hasCode = languages.length > 0 - const agentTurn = agentTools.get(conversationId) + const agentTurn = creditedHere ? agentTools.get(conversationId) : undefined const cursorTools: string[] = [ ...(hasCode ? ['cursor:edit', ...languages.map(l => `lang:${l}`)] : []), ...(agentTurn?.tools ?? []), diff --git a/tests/providers/cursor-real-tokens.test.ts b/tests/providers/cursor-real-tokens.test.ts index c5c18cb..f2e9bac 100644 --- a/tests/providers/cursor-real-tokens.test.ts +++ b/tests/providers/cursor-real-tokens.test.ts @@ -198,13 +198,15 @@ describe.skipIf(skipReason !== null)('cursor real context tokens (#575)', () => expect(credited).toBeDefined() }) - it('attributes tools and bash commands from agentKv to the correct conversation', async () => { + it('attributes aggregated agentKv tools once in a multi-bubble conversation', async () => { const composerId = 'eeee1111-2222-3333-4444-555566667777' const requestId = 'req-001' const dbPath = buildDb((db) => { insertComposerData(db, { composerId, totalUsedTokens: 10000 }) insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'do stuff', requestId }) insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'doing stuff', model: 'gpt-5' }) + insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'do more stuff', requestId: 'req-002' }) + insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'doing more stuff', model: 'gpt-5' }) // agentKv with tool calls insertAgentKv(db, { blobId: 'akv-1', role: 'user', @@ -228,6 +230,12 @@ describe.skipIf(skipReason !== null)('cursor real context tokens (#575)', () => expect(callWithTools!.tools).toContain('Read') expect(callWithTools!.tools).toContain('Shell') expect(callWithTools!.bashCommands).toContain('npm test') + + const allTools = calls.flatMap(c => c.tools) + const allBashCommands = calls.flatMap(c => c.bashCommands) + expect(allTools.filter(t => t === 'Read').length).toBe(1) + expect(allTools.filter(t => t === 'Shell').length).toBe(1) + expect(allBashCommands.filter(cmd => cmd === 'npm test').length).toBe(1) }) it('uses conversation model for pricing when input is on a user bubble', async () => {