Skip to content
Open
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: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/providers/cursor-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Per `<provider>:<conversationId>:<turnIndex>` (`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
Expand Down
43 changes: 29 additions & 14 deletions src/providers/cursor-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type AssistantTurn = {

type ParsedTurn = {
userMessage: string
userTextFull: string
assistant: AssistantTurn
}

Expand Down Expand Up @@ -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

Expand All @@ -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 {
Expand All @@ -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[] = []
Expand All @@ -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 = ''
}
}

Expand All @@ -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'
Expand All @@ -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 = []
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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}`
Expand Down
7 changes: 6 additions & 1 deletion src/session-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,12 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
// 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.
Expand Down
84 changes: 84 additions & 0 deletions tests/providers/cursor-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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":"<user_query>do it</user_query>"}]}}\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":"<user_query>read it</user_query>"}]}}\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":"<user_query>${longText}</user_query>"}]}}\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:\n<user_query>go</user_query>\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', () => {
Expand Down
Loading