diff --git a/CHANGELOG.md b/CHANGELOG.md index e94fcd98a..2f86d1dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - **Bundled pricing is refreshed, and the refresh can no longer drop a model's pricing or re-price a model it still covers.** 5,942 primary entries against the 0.9.24 bundle (1,215 added, 182 repriced by upstream's own data; among the additions, the Grok Build family makes `grok-4.6-build` priceable so the Grok parser's authoritative-model rule now prefers the real modelUsage id), with the fallback moving from 203 to 212 entries. Three bundler rules make that true. Coverage carry is exact-key only: the runtime resolver (`getModelCosts`) looks the queried id up verbatim, peels segments and strips variant suffixes, but never adds a vendor prefix — so a `~x-ai/grok-latest` primary does not answer a bare `grok-latest` query, and the first version of this carry, which also accepted a vendor-prefixed form, dropped exactly that way: 96 previously-priced fallback ids resolved to null while their model kept only a prefixed key. The completeness guard in the prefixed pass is strictly slot-filling: a richer upstream row may fill cache-write/cache-read slots an entry lacks, but only when its input and output rates are identical to the entry already present (every filled slot must also survive verbatim) — completeness alone was swapping in a different row's rates (grok-3 $3/$15 became $1.25/$2.50, mistral-large-latest $8/$24 became $0.50/$1.50; 43 input/output and 34 cache rates moved that way), which a refresh has no authority to do, and a sparser alias still cannot displace the publisher's entry (a `nebius/MiniMaxAI/MiniMax-M3` row without cache-read rates can never replace the MiniMax entry that carries them). And what a refresh would otherwise drop stays priced: the previous fallback's entries and the previous primary rows the new upstream data dropped or renamed (this cycle, twelve ids — the gpt-image-2 family, the Bedrock marengo embeds, the friendliai llama-3.1 rows) carry forward verbatim into the fallback tier. Verified by resolving every id from either bundle through the real resolver: nothing that priced on 0.9.24 prices as null now, and no rate changed except where upstream repriced the row itself. +- **Cursor Agent counts every assistant message instead of one turn per user message.** Agentic loops emit dozens of assistant messages per user message and the parser kept only the first (on one real corpus: 247 of 4,946 messages, ~4% of assistant text). Each message now carries the last user message forward, tool_use inputs join the output text, and input tokens use the full user text while the display stays truncated at 500 chars. The parse version bump re-parses cached sessions. ### Fixed (desktop) - **The Models table shows every model that ran, including the ones under a cent.** The CLI's `models` command defaults `minCost` to $0.01, and the desktop bridge passed neither `--min-cost` nor `--unpriced`, so the table silently dropped every row below a cent — which by construction excluded every unpriced row too (a `0 >= 0.01` filter), leaving #1443's dimming and add-alias affordances unreachable in the shipped app. `codeburn:getModels` now passes `--min-cost 0` (and the demo bridge mirrors it), so sub-cent and unpriced rows arrive and render with their existing dim treatment; on a real lifetime corpus that recovers 10 rows and 2,160 calls the default filter hid (43 → 53 rows, verified in both themes). A row priced between $0.00 and $0.01 renders as "$0.00" without dimming — it is genuinely priced, just below the display floor. Fixes #1465. diff --git a/docs/providers/cursor-agent.md b/docs/providers/cursor-agent.md index d77775b28..a4a927afa 100644 --- a/docs/providers/cursor-agent.md +++ b/docs/providers/cursor-agent.md @@ -32,6 +32,7 @@ Per `::` (`cursor-agent.ts:379`). - A file with a UUID-shaped name is treated as the conversation ID directly (`cursor-agent.ts:142-143`); other names are derived from the parent directory. - Token counts are estimated from char count (`CHARS_PER_TOKEN = 4`, `cursor-agent.ts:35`, `:81-84`). The legacy text format never reports real tokens. +- Every assistant message counts as a turn: agentic loops emit dozens of assistant messages per user message, and each carries the last user message forward. Tool_use inputs are serialized into the output text; input tokens use the full user text while the displayed message stays truncated at 500 chars. - The text parser is regex-driven and brittle. It is easier to fix a Composer 2 (JSONL) bug than a legacy (text) bug. ## When fixing a bug here diff --git a/src/providers/cursor-agent.ts b/src/providers/cursor-agent.ts index 3a20d7438..bd2c5e69f 100644 --- a/src/providers/cursor-agent.ts +++ b/src/providers/cursor-agent.ts @@ -31,6 +31,7 @@ type AssistantTurn = { type ParsedTurn = { userMessage: string + userTextFull: string assistant: AssistantTurn } @@ -217,7 +218,7 @@ async function appendTranscriptSources( } } -function extractUserQuery(userBlock: string): string { +function extractUserQuery(userBlock: string, maxLength: number = MAX_USER_TEXT_LENGTH): string { const chunks: string[] = [] let cursor = 0 @@ -235,18 +236,19 @@ function extractUserQuery(userBlock: string): string { } const combined = chunks.filter(Boolean).join(' ').replace(/\s+/g, ' ').trim() - return combined.slice(0, MAX_USER_TEXT_LENGTH) + return combined.slice(0, maxLength) } function parseJsonlTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolean } { const lines = raw.split(/\r?\n/).filter(l => l.trim()) if (lines.length === 0) return { turns: [], recognized: false } - const turns: ParsedTurn[] = [] - let currentUserMessage = '' + let lastUserDisplay = '' + let lastUserFull = '' + let seenUser = false for (const line of lines) { - let entry: { role?: string; message?: { content?: Array<{ type?: string; text?: string; name?: string }> } } + let entry: { role?: string; message?: { content?: Array<{ type?: string; text?: string; name?: string; input?: unknown }> } } try { entry = JSON.parse(line) } catch { @@ -258,11 +260,14 @@ function parseJsonlTranscript(raw: string): { turns: ParsedTurn[]; recognized: b .filter(c => c.type === 'text') .map(c => c.text ?? '') const combined = texts.join(' ') - currentUserMessage = extractUserQuery(combined) || combined.slice(0, MAX_USER_TEXT_LENGTH) + const full = extractUserQuery(combined, Number.POSITIVE_INFINITY) || combined + lastUserFull = full + lastUserDisplay = full.slice(0, MAX_USER_TEXT_LENGTH) + seenUser = true continue } - if (entry.role === 'assistant' && currentUserMessage) { + if (entry.role === 'assistant' && seenUser) { const content = normalizeContentBlocks(entry.message?.content) const bodyParts: string[] = [] const tools: string[] = [] @@ -272,18 +277,25 @@ function parseJsonlTranscript(raw: string): { turns: ParsedTurn[]; recognized: b bodyParts.push(block.text) } else if (block.type === 'tool_use' && block.name) { tools.push(`cursor:${block.name.toLowerCase()}`) + if (block.input !== undefined) { + try { + bodyParts.push(JSON.stringify(block.input)) + } catch { + // Unserializable tool input contributes its name only (above). + } + } } } turns.push({ - userMessage: currentUserMessage, + userMessage: lastUserDisplay, + userTextFull: lastUserFull, assistant: { body: bodyParts.join('\n').trim(), reasoning: '', tools, }, }) - currentUserMessage = '' } } @@ -295,6 +307,7 @@ function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolea let recognized = false const pendingUsers: string[] = [] + let lastUserMessage: string | null = null const turns: ParsedTurn[] = [] let active: 'none' | 'user' | 'assistant' = 'none' @@ -303,7 +316,7 @@ function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolea const flushUser = () => { if (userLines.length === 0) return - const userQuery = extractUserQuery(userLines.join('\n')) + const userQuery = extractUserQuery(userLines.join('\n'), Number.POSITIVE_INFINITY) if (userQuery.length > 0) pendingUsers.push(userQuery) userLines = [] } @@ -336,11 +349,13 @@ function parseTranscript(raw: string): { turns: ParsedTurn[]; recognized: boolea output += `${line}\n` } - if (pendingUsers.length > 0) { - const userMessage = pendingUsers.shift()! + const userMessage = pendingUsers.length > 0 ? pendingUsers.shift()! : lastUserMessage + if (userMessage !== null) { + lastUserMessage = userMessage const tools = Array.from(toolsByTurn.keys()) turns.push({ - userMessage, + userMessage: userMessage.slice(0, MAX_USER_TEXT_LENGTH), + userTextFull: userMessage, assistant: { body: output.trim(), reasoning: reasoning.trim(), @@ -450,7 +465,7 @@ function createParser( for (let turnIndex = 0; turnIndex < parsed.turns.length; turnIndex++) { const turn = parsed.turns[turnIndex]! - const inputTokens = estimateTokens(turn.userMessage.length) + const inputTokens = estimateTokens(turn.userTextFull.length) const outputTokens = estimateTokens(turn.assistant.body.length) const reasoningTokens = estimateTokens(turn.assistant.reasoning.length) const deduplicationKey = `cursor-agent:${conversationId}:${turnIndex}` diff --git a/src/session-cache.ts b/src/session-cache.ts index f0c2bfc8a..741b2e90d 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -381,7 +381,12 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // Compose all four — a take-ours merge would drop #1075, #1079, or #1092. codex: 'mcp-attribution-v5-est-cost-active-timing-mcp-wait-rich-capture-v1-cross-provider-pr-v1-session-meta-model-v1-session-meta-fields-v1-codex-pricing-v1-codex-tps-v1-codex-mcp-skills-v1-activity-price-v1', cursor: 'composer-anchored-crediting-v1-est-cost', - 'cursor-agent': 'workspaceless-transcript-v1', + // full-turn-accounting-v1: every assistant message counts as a turn + // (previously only the first after each user message survived), tool_use + // inputs join the output text, and input tokens use the full user text + // instead of the 500-char display truncation. Cached sessions hold a + // fraction of their turns, so they must re-parse. + 'cursor-agent': 'workspaceless-transcript-v1-full-turn-accounting-v1', // source-provenance-v1 (#944): CLI sessions were misread as VS Code // transcripts (both carry producer 'copilot-agent'), skipping the shutdown // input/cache rollup; this bump re-parses them so the missing tokens land. diff --git a/tests/providers/cursor-agent.test.ts b/tests/providers/cursor-agent.test.ts index 6354927c1..8271718ea 100644 --- a/tests/providers/cursor-agent.test.ts +++ b/tests/providers/cursor-agent.test.ts @@ -297,6 +297,90 @@ describe('cursor-agent provider', () => { expect(callsFirst[0]!.sessionId).toBe(callsSecond[0]!.sessionId) expect(callsFirst[0]!.deduplicationKey).toBe(callsSecond[0]!.deduplicationKey) }) + + it('counts every assistant message after one user message (jsonl)', async () => { + const baseDir = await makeBaseDir() + const sessionDir = join(baseDir, 'projects', 'multi-proj', 'agent-transcripts', FIXED_UUID) + await mkdir(sessionDir, { recursive: true }) + await writeFile( + join(sessionDir, `${FIXED_UUID}.jsonl`), + '{"role":"user","message":{"content":[{"type":"text","text":"do it"}]}}\n' + + '{"role":"assistant","message":{"content":[{"type":"text","text":"step one"}]}}\n' + + '{"role":"assistant","message":{"content":[{"type":"text","text":"step two"}]}}\n' + + '{"role":"assistant","message":{"content":[{"type":"text","text":"step three"}]}}\n', + ) + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const calls = await collectCalls(provider, source) + + expect(calls).toHaveLength(3) + expect(calls.map(c => c.deduplicationKey)).toEqual([ + `cursor-agent:${FIXED_UUID}:0`, + `cursor-agent:${FIXED_UUID}:1`, + `cursor-agent:${FIXED_UUID}:2`, + ]) + expect(calls.every(c => c.userMessage === 'do it')).toBe(true) + }) + + it('counts tool_use inputs in output tokens (jsonl)', async () => { + const baseDir = await makeBaseDir() + const sessionDir = join(baseDir, 'projects', 'tool-proj', 'agent-transcripts', FIXED_UUID) + await mkdir(sessionDir, { recursive: true }) + const toolInput = { path: '/some/very/long/path/to/a/file/that/adds/chars.txt' } + await writeFile( + join(sessionDir, `${FIXED_UUID}.jsonl`), + '{"role":"user","message":{"content":[{"type":"text","text":"read it"}]}}\n' + + `{"role":"assistant","message":{"content":[{"type":"text","text":"ok"},{"type":"tool_use","name":"Read","input":${JSON.stringify(toolInput)}}]}}\n`, + ) + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const calls = await collectCalls(provider, source) + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['cursor:read']) + expect(calls[0]!.outputTokens).toBe( + estimateTokensFromChars(('ok\n' + JSON.stringify(toolInput)).trim().length), + ) + }) + + it('accounts full user text while keeping display truncated (jsonl)', async () => { + const baseDir = await makeBaseDir() + const sessionDir = join(baseDir, 'projects', 'long-proj', 'agent-transcripts', FIXED_UUID) + await mkdir(sessionDir, { recursive: true }) + const longText = 'x'.repeat(2000) + await writeFile( + join(sessionDir, `${FIXED_UUID}.jsonl`), + `{"role":"user","message":{"content":[{"type":"text","text":"${longText}"}]}}\n` + + '{"role":"assistant","message":{"content":[{"type":"text","text":"done"}]}}\n', + ) + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const calls = await collectCalls(provider, source) + + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(estimateTokensFromChars(longText.length)) + expect(calls[0]!.userMessage).toHaveLength(500) + }) + + it('counts every assistant block after one user block (txt)', async () => { + const baseDir = await makeBaseDir() + const transcriptDir = join(baseDir, 'projects', 'txt-multi', 'agent-transcripts') + await mkdir(transcriptDir, { recursive: true }) + await writeFile( + join(transcriptDir, `${FIXED_UUID}.txt`), + 'user:\ngo\nA:\nfirst\nA:\nsecond\n', + ) + + const provider = createCursorAgentProvider(baseDir) + const source = (await provider.discoverSessions())[0]! + const calls = await collectCalls(provider, source) + + expect(calls).toHaveLength(2) + expect(calls.every(c => c.userMessage === 'go')).toBe(true) + }) }) skipUnlessSqlite('cursor-agent sqlite metadata', () => {