Skip to content

fix(parsers): fill a blank Cursor model from the assistant message - #1104

Open
gongcavalry wants to merge 6 commits into
spacering-net:mainfrom
gongcavalry:pr/cursor-model-backfill
Open

gongcavalry wants to merge 6 commits into
spacering-net:mainfrom
gongcavalry:pr/cursor-model-backfill

Conversation

@gongcavalry

@gongcavalry gongcavalry commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Cursor ACP stores on current builds do not put lastUsedModel in the session metadata, so the sidebar chip stays blank.

Fix

  • Live turns copy the model from the in-memory selector (config_options) after the turn status is written. This path does not open the store.
  • History reads content[].providerOptions.cursor.modelName from assistant messages: root field 1 in order, then an inline field 4. The latest non-empty name wins. The fingerprint byte walk is removed.
  • Backfill of rows nobody has opened uses that same parser. The negative cache uses the newer of store.db and store.db-wal.
  • A write happens only when the model column is still empty.

Tests

Temp directories only. These tests do not read ~/.cursor.

  • field-1 modelName, a model switch, inline field 4, and text / tool-call entries that omit modelName
  • a failed lookup that stays cached when store.db is rewritten but its mtime is restored, then retries after only store.db-wal changes
  • a turn end that seeds the row from config_options and does not overwrite a chip that is already filled

…gerprint

Cursor sessions imported from disk often carry no model name. Recover it
by walking the session's composer/bubble fingerprint, and remember the
result so later turns of the same session keep the attribution.

Co-authored-by: Cursor <cursoragent@cursor.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The model backfill can persist incorrect session models, reinsert hidden conversations into client state, and delay search results and turn-completion updates with synchronous store scans.

5 open issues
What changed in this PR

This PR recovers missing model names from Cursor session stores so Codeg can show model attribution in conversation lists and details.

Changes:

  • Resolve models from fingerprints reachable from the latest session root, while preferring explicit model metadata.
  • Persist recovered models during conversation listing and turn completion, broadcasting updates to clients.
  • Add a parser regression test that distinguishes linked fingerprints from stale, unlinked ones.
File Description
src-tauri/​src/​parsers/​cursor.rs Add fingerprint-based model recovery and a regression test.
src-tauri/​src/​db/​service/​conversation_service.rs Query Cursor conversations with missing models.
src-tauri/​src/​commands/​conversations.rs Backfill and persist recovered models, then broadcast updates.
src-tauri/​src/​acp/​lifecycle.rs Trigger model recovery when a Cursor turn completes.

Review effort: Max

Comment thread src-tauri/src/acp/lifecycle.rs Outdated
Comment on lines 356 to 369
// Cursor does not put the model on the conversation row. The
// store has it once the turn is written; copy it onto the chip
// before the status broadcast so this same update shows the letter.
crate::commands::conversations::remember_cursor_session_model(
db_conn,
&emitter,
cid,
agent_type,
external_id.as_deref(),
)
.await;
if claimed {
write_turn_status(db_conn, &state_arc, &emitter, cid, stop_reason).await?;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72703f7. The turn status is now written and broadcast BEFORE the optional model lookup, and the model arrives through its own upsert right after — a Cursor row with no model no longer delays the turn-completion event.

Comment thread src-tauri/src/commands/conversations.rs Outdated
// current stores have no `lastUsedModel`. Fill the blank chip from the
// fingerprint still linked in that session, then list. Once a row has a
// model this finds nothing and does not open the stores again.
if let Err(error) = backfill_cursor_models(conn, emitter).await {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72703f7. The backfill no longer runs on the list request's critical path: it is scheduled as a single-flight background task, and unsuccessful lookups are negative-cached by store mtime — a session whose store yields no model is not walked again until the store file actually changes.

Comment thread src-tauri/src/commands/conversations.rs Outdated
.with_detail(error.to_string())
})?;
for (id, model) in found {
if conversation_service::seed_model_if_empty(conn, id, &model)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72703f7 (both sites). The seed is now seed_model_if_empty_for_session: one UPDATE that also matches the external session id and the Cursor agent type, keeping the deletion and empty-model guards, so a row rebound during the blocking lookup turns the write into a no-op instead of inheriting the previous session's model.

let rows = conversation::Entity::find()
.filter(conversation::Column::DeletedAt.is_null())
.filter(conversation::Column::AgentType.eq(AgentType::Cursor.as_wire().into_owned()))
.filter(conversation::Column::ExternalId.is_not_null())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72703f7. cursor_sessions_missing_model now applies the same live-folder and non-loop filters as refresh_codex_auto_titles, so a successful backfill can no longer broadcast an upsert that reinserts a hidden row into another client's workspace state.

Comment thread src-tauri/src/parsers/cursor.rs Outdated
Comment on lines +551 to +557
i += 1;
let start = i;
while window.get(i).is_some_and(|byte| *byte != b'"' && *byte != b'\\') {
i += 1;
}
let id = std::str::from_utf8(window.get(start..i)?).ok()?;
(!id.is_empty() && id.len() <= 100).then(|| id.to_string())

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72703f7. The value is now decoded as a complete JSON string (decode_json_string): escapes (\", \\, \/, \uXXXX, …) are fully decoded, and anything incomplete — a backslash or the end of the window before the closing quote, a malformed escape, a lone surrogate — is rejected, so a truncated id can never be persisted. Unit tests cover the escape and rejection cases.

… decode

- lifecycle: broadcast the turn status BEFORE the optional Cursor model
  lookup; the status event carries no model and must not wait on a store
  walk of up to 4,000 blobs.
- list: run the model backfill in a single-flight background task instead
  of on the list request's critical path (search hits this endpoint after
  every 300 ms debounce), and negative-cache unresolved sessions until
  their store file changes.
- Seed the model with an UPDATE that also matches the external session id
  and Cursor agent type, so a row rebound during the blocking lookup never
  inherits the previous session's model.
- Exclude soft-deleted folders and loop conversations from the missing-
  model query, mirroring refresh_codex_auto_titles, so backfill upserts
  cannot reinsert hidden rows into client state.
- Decode the fingerprint's "model" as a complete JSON string (escapes
  included) and reject incomplete values — a backslash or the end of the
  scan window no longer yields a truncated model id.

Co-authored-by: Cursor <cursoragent@cursor.com>
gongcavalry added a commit to gongcavalry/codeg that referenced this pull request Nov 7, 2026
Copilot 评审后的原子标题守卫和 Cursor 模型回补已在 PR 分支上 CI 全绿,本机日常包仍是评审前版本。三路合并进 bc,重启后与已回捐的修复一致。

Co-authored-by: Cursor <cursoragent@cursor.com>
@xintaofei

Copy link
Copy Markdown
Collaborator

@gongcavalry thanks a lot for this, and for turning around the first review so quickly! The blank model chip on Cursor rows is a real gap. I confirmed it locally: none of the Cursor ACP stores on my machine has lastUsedModel, so the parser never returns a model and the row stays empty. Several parts of the follow-up commit are exactly what we want, too:

  • the seed that also matches external_id and the agent type in the same UPDATE, so a rebound row can never inherit the old session's model;
  • the deleted-folder and loop filters on the backfill query;
  • the string decoder that rejects truncated values and malformed escapes;
  • writing the turn status before any model lookup.

My conclusion, though, is that the source of the model needs to change before this can merge. Changing only the marker would not be enough. I've tried to put everything in this one comment so you have the full picture in one place.

Summary

  1. §1 I could not find systemPromptFingerprint in any real Cursor store or Cursor CLI build.
  2. §2 Real stores record the model in a structured field on each assistant message, which the PR doesn't read yet.
  3. §3 For sessions that run inside codeg, codeg already knows the model at turn end, with no disk reads.
  4. §4 Suggested shape, reusing most of what you wrote.
  5. §5 Smaller notes, tests, and a question for you.

§1 The fingerprint marker isn't in the stores I can inspect

resolved_session_model falls back to scanning raw blob bytes for systemPromptFingerprint followed by a "model" key (prompt_fingerprint_model). To find it, it walks up to 4,000 blobs reachable from the root (fingerprint_model_from_root).

  • None of my five Cursor ACP stores contains the string "fingerprint" in any case. I read them together with their -wal files.
  • The Cursor CLI build codeg pins (2026.11.05-86047df) and the 2026.10.19-6bbf2c4 build codeg had installed don't contain systemPromptFingerprint anywhere in their bundles. Both still declare lastUsedModel in the store metadata schema.
  • The test builds its blobs from hand-written JSON, so it doesn't show what a real store looks like.

So on every store I can inspect, the new fallback returns None, and the chip stays blank. It's quite possible your stores look different. Maybe a newer CLI or server writes this, or the CLI stores it as opaque server data. If so, see the question in §5. My stores are from July to September, and I couldn't record a fresh session because my Cursor CLI isn't logged in right now.

The DFS order also doesn't tell us which turn a fingerprint belongs to. The walk keeps whichever hit comes last in traversal order, which isn't necessarily the latest turn.

§2 Where the model actually lives

In the ACP stores I inspected, each assistant message is a JSON blob, and the model sits in a structured field at content[].providerOptions.cursor.modelName:

"providerOptions": {"cursor": {"modelName": "composer-2.5"}}

My stores have composer-2.5 and cursor-grok-4.5-high there. Only the reasoning and redacted-reasoning entries carry it. text and tool-call entries leave it out, so a reader should walk the content array and skip entries without a non-empty modelName. The root record lists the messages as repeated field 1, each a 32-byte reference to a message blob. In one of my stores, the root also carried the newest assistant message inline in field 4, so a reader should handle both.

Reading this field with serde_json gives the model of a specific reply, in a known order, with no byte scanning.

How I checked

Copy each ~/.cursor/acp-sessions/<id>/store.db to a scratch directory as s.db, and its store.db-wal next to it as s.db-wal, then:

sqlite3 s.db "select data from blobs" | LC_ALL=C grep -aoE '.{0,30}[Mm]odel.{0,80}' | sort -u
sqlite3 s.db "select count(*) from blobs where instr(lower(cast(data as text)), 'fingerprint') > 0"   # 0 in all five

For the CLI builds, I extracted https://downloads.cursor.com/lab/2026.11.05-86047df/darwin/arm64/agent-cli-package.tar.gz and ran grep -rlE 'systemPromptFingerprint|system_prompt_fingerprint|SystemPromptFingerprint' dist-package/. It printed nothing. The same grep for lastUsedModel hits the store metadata schema.

§3 Sessions run in codeg: the model is already in memory

When a turn ends, the lifecycle handler already holds the session's SessionState, and config_options holds Cursor's model selector there. current_model_id_from_opts already reads the model from it, and it's the value the composer shows. The same logic gives record_turn_end its per-turn model for agents whose transcript codeg writes itself. That function skips Cursor, because Cursor keeps its own store, but the logic can be reused to seed Cursor's row.

remember_cursor_session_model instead reopens the store on disk and repeats the walk.

Also, once the parser can name a model, the existing path saves it on its own. After a turn, the panel's syncTurnMetadata refetches the conversation (here). That fetch fills an empty row model from the parsed summary and broadcasts the sidebar upsert. Codex rows got their chip this way. Here is why the parsed model is preferred.

§4 Suggested shape

  1. Turn end, live sessions. In the lifecycle handler, take the model from snap.config_options, in the same read where you already copy agent_type and external_id. Use the same logic as current_model_id_from_opts, which you can make pub(crate) or move next to SessionState. Skip the seed when there is no model selector. Then, after the lock is released and the turn status is written as today, call your seed_model_if_empty_for_session and emit the upsert. This needs no spawn_blocking and no store access.
  2. Parser fallback. After lastUsedModel, read content[].providerOptions.cursor.modelName from the latest assistant message that has one. Follow the root's field-1 references in order, plus the inline field 4. Parse each message as JSON instead of scanning bytes. If you also want per-turn models here, each turn must take the model from its own assistant reply. Giving every turn the latest session model would relabel earlier turns after a switch. That part is optional and can wait for a separate change.
  3. Backfill for rows nobody has opened. Keeping a background backfill for old rows is fine. Once item 2 is in, it can call the same parser function. Keep the single-flight guard, the filters and the atomic seed. Please also look at the note on the store's modified time in §5.
  4. Drop the fingerprint walk. That also removes the extra walk of up to 4,000 blobs per lookup. It runs in both build_summary and build_detail whenever lastUsedModel is absent. That field is missing in all five stores I inspected, so in practice their summary and detail parses repeat the walk.

§5 Smaller notes, tests, and a question

  • Store modified time. store_mtime_for_session looks only at store.db. Cursor writes in WAL mode, so new data can sit in store.db-wal while store.db's modified time doesn't change. A cached failure then stays cached until a checkpoint touches store.db or codeg restarts. Comparing the newer of the two files' modified times lets WAL-only updates trigger a retry.

  • Tests. A fixture shaped like a real store would make this solid:

    • a root whose field 1 references JSON message blobs with providerOptions.cursor.modelName;
    • a model switch between two replies, where the latest reply wins;
    • the inline field-4 message;
    • a reply whose text and tool-call entries have no modelName;
    • a failed lookup that retries after only store.db-wal changes;
    • a turn end where config_options names the model and the row gets seeded.

    Please test in a way that never reads the real ~/.cursor, like the existing with_base_dir tests do.

  • Question. If you do have stores that contain systemPromptFingerprint, could you share the Cursor CLI version (cursor-agent --version) and a redacted excerpt of one such blob? Then we can see whether it's worth keeping as an extra fallback, with a real fixture. I'd also like to know which value you saw there. The accounts/fireworks/models/kimi-k3 in your test looks like a backend serving path rather than the model id the user picks in Cursor. The chip should show the id the user picked.

I'd suggest reworking along these lines before we merge. If I've misread anything, or your stores really do look different, please push back, and a store sample would settle it quickly. Thanks again for picking this up!

@xintaofei

Copy link
Copy Markdown
Collaborator

codeg work task 490 is done — it made no changes, so nothing was pushed to this pull request.

gongcavalry and others added 2 commits November 10, 2026 11:24
The fingerprint walk does not match the stores this review can inspect, and its order is not the turn order. Live turns already have the selected model in memory. History uses the latest assistant modelName, and only an empty chip is filled.

Co-authored-by: Cursor <cursoragent@cursor.com>
The WAL retry test rewrote store.db before touching the wal, so a cache that ignored mtime would still pass. Assert the chip stays empty until the wal mtime changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
@gongcavalry gongcavalry changed the title feat(parsers): backfill a Cursor session's missing model from its fingerprint fix(parsers): fill a blank Cursor model from the assistant message Nov 10, 2026
@gongcavalry

Copy link
Copy Markdown
Contributor Author

Reworked the model source along the lines in the review. The fingerprint walk is gone, including decode_json_string. That decoder had no other caller in the Cursor parser. Codex keeps its own string decoder.

Live turns. The lifecycle handler copies config_options in the same lock snapshot as agent_type and external_id, writes the turn status first, then calls seed_model_if_empty_for_session. No spawn_blocking and no store read. No model selector means no seed.

History. lastUsedModel still wins when the metadata has it. Otherwise the parser JSON-decodes assistant messages: root field 1 in order, then an inline field 4. The last non-empty content[].providerOptions.cursor.modelName wins. text and tool-call entries that omit it are skipped, and a later assistant with no modelName does not erase an earlier one. Missing blobs and bad JSON are skipped. This does not walk the blob graph. Per-turn models are unchanged: each turn still uses lastUsedModel when that field exists, and we do not label earlier turns with the latest session model.

Backfill. Same parser, still single-flight, still filtered, still the atomic seed. The negative cache uses the newer of store.db and store.db-wal. A stat that returns no mtime is not cached. Both the turn-end seed and the backfill write only when the column is empty, so a later switch does not overwrite a chip that is already filled.

On the fingerprint question. cursor-agent --version here is 2026.11.05-86047df. Of 68 ACP sessions, 3 could not be opened read-only (attempt to write a readonly database) and are excluded. The other 65 do contain a systemPromptFingerprint JSON object with a model string. 64 of those also have a structured providerOptions.cursor.modelName. Store mtimes (newer of the db and the wal) run from 2026-09-18 to 2026-11-10, so these are not all stores written by the current CLI. The fingerprint values are coarser than the picker id: 41 sessions say grok-4.7 there, while modelName on those sessions is the picker id (grok-4.7-high, grok-4.7-xhigh-fast, and so on). One example is fingerprint grok-4.7 against modelName grok-4.7-high. modelName is not always a picker id either: 23 sessions have modelName set to accounts/fireworks/models/kimi-k3 itself. I am not treating the fingerprint as something the server wrote. It is not kept as a fallback. One session had a fingerprint and no modelName, which is not worth a walk of up to 4,000 blobs whose order is not the turn order.

A redacted root, with ids and message text removed:

  • field 1, repeated: 32-byte refs to JSON messages, in order. Roles are system, user, and assistant.
  • field 4, optional: the newest assistant message inline. This showed up in 3 of the stores that opened, and it can be the only assistant message.
{
  "role": "assistant",
  "content": [
    {
      "type": "reasoning",
      "providerOptions": {
        "cursor": { "modelName": "accounts/fireworks/models/kimi-k3" }
      }
    },
    { "type": "text" },
    { "type": "tool-call" }
  ]
}

text and tool-call omit modelName. Meta key "0" is hex-encoded JSON whose latestRootBlobId is 64 hex characters. These stores do not carry lastUsedModel in that object.

Tests use temp directories only (with_base_dir and a CURSOR_CONFIG_DIR pointed at a temp dir). They do not read ~/.cursor.

…t first

- Turn end now reads the model this turn recorded in the store and falls
  back to the selector only when the store names none. In the parameterized
  picker codeg asks for, Cursor's selector holds the base model
  (`grok-4.7`) while each reply records the variant that ran
  (`grok-4.7-high`), so a live row and a backfilled row used to differ.
  Cursor writes the reply's blobs before it answers the prompt, so the
  store already has this turn. A row that already has a model skips the
  lookup.
- The assistant-model lookup checks the inline field-4 message, then the
  field-1 refs newest first, and stops at the first model. That gives the
  same answer as reading every message, but usually reads one or two blobs.
- Reword the ref cap's doc comment, which still mentioned the fingerprint
  walk.
…ides

The Cursor model seed now also requires a live folder and a non-loop row
in the same UPDATE. That's the filter the backfill already selects with.
Both callers broadcast an upsert when the seed writes, and clients insert
any upserted row they don't have. Before this, a folder soft-deleted while
the store was read, or a loop conversation finishing a Cursor turn, could
come back into another client's workspace list.

The backfill's single-flight flag is now cleared by a drop guard, so an
error, a panic or a dropped task can't leave it set and stop every later
pass.
@xintaofei

Copy link
Copy Markdown
Collaborator

@gongcavalry thank you for the rework, and for the detailed numbers from your 65 stores! They settled the fingerprint question better than anything I could check here. The new version follows the plan closely. It reads the structured modelName, handles the inline field 4, keeps an earlier model when later replies name none, looks at the WAL file's mtime, and the tests stay in temp directories. With this branch, all five of my local Cursor stores now resolve the right model.

I pushed two commits on top of your branch so we don't need another round. Here is what they change and why.

1. Turn end now reads the store first (36669edc1)

This one changes my own earlier advice, sorry about that. I had suggested taking the turn-end model from the selector, with no store read. That gives a different id from the backfill:

  • codeg asks Cursor for the parameterized picker (here). In that mode, the model selector holds only the base model, such as grok-4.7. Effort and Fast are separate options.
  • Stored assistant messages can name the model variant that ran, such as grok-4.7-high. Your Grok stores had those variant ids in modelName too. Your Kimi stores used a provider model path, and one readable store had no modelName.
  • The first value wins on the row. So a Grok conversation run in codeg would show grok-4.7, while a backfilled one shows grok-4.7-high.

seed_cursor_model_after_turn now reads the store with the same parser as the backfill, and uses the selector only when the store names no model. A row that already has a model skips the lookup. The turn status is still written first.

Reading the store at that point is safe. In the cursor-agent bundle, setBlob ends in a synchronous SQLite write, and the metadata with latestRootBlobId is saved on a microtask. Both happen before the prompt response reaches codeg, so the store already has this turn.

Two smaller changes are in the same commit. assistant_model_from_root now checks the inline field-4 message first, then the field-1 refs from the newest, and stops at the first model. That gives the same answer as reading every message, but usually reads one or two blobs. The cap's doc comment no longer mentions the fingerprint walk. Your turn-end test now uses the base id the selector really holds. It is rewritten to cover store precedence, selector fallback and keeping an existing model, and it points CURSOR_CONFIG_DIR at a temp dir.

2. Rows the workspace hides are not seeded (3f150b421)

This came out of a review of the whole PR. Your backfill query already skips soft-deleted folders and loop conversations. The seed itself didn't check those again, and both callers broadcast an upsert when it writes. An upsert can insert a root row the client doesn't have. So a folder deleted while a store was being read, or a loop conversation finishing a Cursor turn, could come back into another client's workspace list.

seed_model_if_empty_for_session now adds the same two conditions inside the same UPDATE. A no-op means no upsert, on both paths. A test covers a live row, a row whose folder is deleted after it was picked, and a loop row.

The same commit adds a drop guard for the backfill's running flag. Before, if the task ended with a panic or was dropped, the flag stayed set and no later backfill would run.

Where it stands

I reviewed the whole PR again with these commits, and I have no further changes to ask for. Locally, clippy is clean in server and desktop mode, and the targeted tests pass in both: the Cursor parser, the turn-end seed, the backfill retry and the seed. CI on 36669edc1 passed every job, and it is running on the latest commit now.

Nothing further is needed from you right now. If either commit doesn't match what you see in your stores, especially the store-first order at turn end, please say so and I'll adjust.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants