Skip to content

fix(cursor): hide injected background-task notices from user bubbles - #972

Open
goon-13 wants to merge 1 commit into
spacering-net:mainfrom
goon-13:fix/cursor-hide-system-notification
Open

goon-13 wants to merge 1 commit into
spacering-net:mainfrom
goon-13:fix/cursor-hide-system-notification

Conversation

@goon-13

@goon-13 goon-13 commented Oct 15, 2026

Copy link
Copy Markdown

Summary

  • Cursor ACP writes finished background-shell notifications as UserMessage XML (<timestamp>, <system_notification>, optional stock <user_query>). The Cursor history parser rendered those as the user's own bubbles.
  • Claude already strips <system-reminder>; Grok honors _meta.hideFromScrollback. This applies the same class of filter in parsers/cursor.rs.
  • A closed <timestamp> is removed only when it sits on the same injection as <system_notification>, so a real prompt that wraps a date is kept. Unclosed mentions of the tag name are kept. Injection-only text drops the user bubble; images and the assistant reply stay.

Test plan

  • cargo test --no-default-features --features test-utils --lib parsers::cursor::tests (34 passed)
  • Fixture uses the live Cursor store payloads (notification, notification + stock follow-up query)
  • Mention-only, mixed prose, standalone <timestamp>, and image-on-injection cases
  • GitHub Actions Test workflow

Made with Cursor

Cursor writes finished-shell notifications as UserMessage XML. Claude and
Grok already drop the same class; the Cursor history parser did not.

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

Copy link
Copy Markdown
Collaborator

@goon-13 hi, and thank you for this PR! 🙏

The problem is real, and your fixture shows it clearly: when a background shell finishes, Cursor opens a turn of its own, and codeg's history rendered that turn's text (the <timestamp>, the <system_notification> with its <task> block, and the stock <user_query> follow-up) as if the user had typed it. The description says the fixture comes from real store payloads, which is exactly what this kind of fix needs. The behavior around the fix is right too: the assistant's reply stays, an image on the turn survives, and a turn with nothing left to show loses its user bubble instead of rendering an empty one. codeg's Grok parser drops Grok's hidden reminder chunks in the same spirit. CI is green on all seven jobs.

That said, I'm not going to merge it in its current form. The core reason is that Cursor already marks these turns in its store, and Cursor's own terminal UI uses that mark to keep their text out of user messages. codeg should read the mark too, instead of recognizing the notice by its wording. Below is everything in one place (the evidence for the mark, what the text matching gets wrong, a suggested change with a ready patch, and tests) to make the next round easier. If you think I got something wrong, please push back 🙂

Summary

  • §1 Main issue: Cursor marks these turns in the store, so matching the text isn't needed, and it isn't safe.
  • §2 What the regex gets wrong on real prompts.
  • §3 Suggested change, with a patch on top of your branch.
  • §4 Checked and fine.
  • §5 Tests and manual checks.

1. Main issue: Cursor already marks these turns

  • The mark exists. The new parser comment says Cursor's ACP store has neither flag (cursor.rs L858-L867), but the store does carry one. In the Cursor CLI codeg pins (2026.10.01-14929f9), agent.v1.UserMessage has is_simulated_msg (field 5), simulated_msg_reason (field 9) and simulated_message_metadata (field 15, holding title and task_id). Reason 3 is BACKGROUND_TASK_COMPLETION. An earlier pin, 2026.09.15-d2fe57e, has the same fields.
  • Cursor's terminal UI hides the notice text by that reason. When it loads a stored conversation, it reads field 9 from each turn's user message and turns a BACKGROUND_TASK_COMPLETION turn into a completion entry instead of a user message: "Finished" plus the task's title, or a count when several tasks finished together. Its user-message list and rollback helper leave the notice text out, and its rewind list skips messages flagged as simulated (field 5). Not every Cursor path does this: its ACP session/load replay sends the stored text without checking the reason. That replay doesn't reach codeg's history, though. codeg drains it for built-in agents (connection.rs L6818-L6829; transcript_dir_for is None for them, L1530-L1534), and its parser registry picks CursorParser for Cursor (mod.rs L402).
  • The stored bytes are what the CLI was given. The CLI's key-value handler passes the blobData of each setBlobArgs message to its blob store, and the SQLite implementation inserts those bytes into blobs without decoding them. The live userMessageAppended handler also recognizes BACKGROUND_TASK_COMPLETION and accepts it when no turn is pending.
  • The wording is a fragile dependency. The CLI reports a finished task through a structured backgroundTaskCompletionAction, whose completions carry the task id, kind, status, title, detail and tool call id. I found none of the notice's sentences in the CLI's JavaScript. That's consistent with Cursor's server writing the text, though it doesn't prove where the text is built. Either way, matching the wording breaks if the template changes, and the template isn't in the code this PR can see.
  • One gap. I haven't verified field 9 on a real background-task notice, because none of the Cursor stores on my machine has one. The schema and the terminal UI's code support this approach, and checking a copy of the store your fixture came from would confirm it. The details below include a small script for that. If a real completion notice turns out to have no reason, or a different one, please tell me, because that would change my recommendation.
Where to look in the Cursor CLI package, and a script to check your store

Package: https://downloads.cursor.com/lab/2026.10.01-14929f9/darwin/arm64/agent-cli-package.tar.gz, the macOS arm64 build of the version codeg pins. The files are minified, so search for these strings.

  • dist-package/index.js
    • Field table: UserMessage|1 text 9|…|5 is_simulated_msg 8?|…|9 simulated_msg_reason #2?|…|15 simulated_message_metadata #5?|…
    • Metadata: UserMessage.SimulatedMessageMetadata|1 title 9?|2 task_id 9?|…
    • Enum: "SimulatedMsgReason",[[0,"UNSPECIFIED"],[1,"PLAN_EXECUTION"],[2,"COMMIT_REMINDER"],[3,"BACKGROUND_TASK_COMPLETION"],…
    • Completion record: BackgroundTaskCompletion|1 task_id 9|2 kind #0|3 status #1|4 title 9|5 detail 9?|…|10 tool_call_id 9?|…
    • Key-value handler, in ../agent-kv/dist/index.js: case"setBlobArgs":{const i=e.message.value.blobId,a=e.message.value.blobData;, followed by await this.blobStore.setBlob(s,i,a)
  • dist-package/5625.index.js, in ../cursor-sdk-local-runtime/dist/run-store/sqlite-blob-store.js: CREATE TABLE IF NOT EXISTS blobs (id TEXT PRIMARY KEY, data BLOB), and in setBlob, INSERT OR REPLACE INTO blobs (id, data) VALUES (?, ?) with the bytes as given. That is the store.db layout codeg reads.
  • dist-package/1962.index.js
    • Loading a stored conversation, in ../agent-ui-state/dist/index.js: const r=e.userMessage?.text??"",o=e.userMessage?.simulatedMsgReason, then isBackgroundTaskCompletion:o===s.c_.BACKGROUND_TASK_COMPLETION||void 0 and backgroundTaskCompletionMetadata:o===s.c_.BACKGROUND_TASK_COMPLETION?F(e.userMessage?.simulatedMessageMetadata):void 0
    • Rendering: "background-task-completion"===e.type? in ./src/conversation/static-item.tsx and ./src/conversation/dynamic-item.tsx, drawn by ./src/components/background-task-completion-message.tsx (label:"Finished", then the title or the task count)
    • User-message list, ./src/utils/user-messages.ts: if(!0===e.isBackgroundTaskCompletion)break;
    • Rollback, ./src/utils/ui-rollback.ts: !0===e.isBackgroundTaskCompletion||!0===e.isPlanExecution||e.simulatedMsgReason===s.c_.GOAL_CONTINUATION?null:e.userMessage
    • Rewind, ./src/history/conversation-rewind-projection.ts: return!0!==e.isSimulatedMsg&&void 0===e.executePlanInfo&&e.conversationStateBlobId.length>0
    • Live update, in ../agent-ui-state/dist/index.js: a=n.simulatedMsgReason===s.c_.BACKGROUND_TASK_COMPLETION, then if(!e.pendingTurn&&!a&&!i)throw new r("No pending turn")
    • Reporting a finished task, in ./src/headless.ts (also in ./src/ui.tsx, and in ./src/acp/subagent-completion-drain.ts inside 3990.index.js): new c.ConversationAction({action:{case:"backgroundTaskCompletionAction"
  • dist-package/3990.index.js, in ./src/acp/agent-session.ts: async replayAgentTurn(e,t,s){const a=e.userMessage?.text??"";a.trim().length>0&&await this.sendSessionUpdate({sessionUpdate:"user_message_chunk", the ACP replay that doesn't check the reason.
  • No dist-package/*.js file contains "The following task has finished", "If you were already aware", "Briefly inform the user about the task result", or a <system_notification> tag. The bare name system_notification appears only in 7923.index.js, in a list of tags Cursor strips from its own agent transcripts.

To check a store:

  1. Stop anything that may write to the session (Cursor and codeg) before copying it.
  2. Find the session folder: <config-root>/acp-sessions/<session-id>/, where the config root is CURSOR_CONFIG_DIR if set, otherwise $XDG_CONFIG_HOME/cursor if set, otherwise ~/.cursor (the same order codeg's parser uses, cursor.rs L25-L53).
  3. Copy that folder somewhere new, keeping store.db together with store.db-wal and store.db-shm if they exist. Leave the original untouched.
  4. Save the script below as check_notice_flag.py and run python3 check_notice_flag.py "<copy>/store.db" (on Windows, py -3 and a native path). It opens the database read-only and fails if the path doesn't exist.
import sqlite3
import sys
from pathlib import Path


def varint(buf, i):
    shift = out = 0
    while True:
        byte = buf[i]
        i += 1
        out |= (byte & 0x7F) << shift
        if byte < 0x80:
            return out, i
        shift += 7


def fields(buf):
    i = 0
    while i < len(buf):
        key, i = varint(buf, i)
        number, wire_type = key >> 3, key & 7
        if wire_type == 0:
            value, i = varint(buf, i)
        elif wire_type == 2:
            size, i = varint(buf, i)
            value, i = buf[i:i + size], i + size
        elif wire_type == 1:
            value, i = buf[i:i + 8], i + 8
        elif wire_type == 5:
            value, i = buf[i:i + 4], i + 4
        else:
            raise ValueError("not a protobuf message")
        yield number, value


db_path = Path(sys.argv[1]).expanduser().resolve(strict=True)
db = sqlite3.connect(db_path.as_uri() + "?mode=ro", uri=True)
for blob_id, data in db.execute("SELECT id, data FROM blobs"):
    if b"<system_notification>" not in data:
        continue
    try:
        message = dict(fields(data))
    except (IndexError, ValueError):
        continue  # not a protobuf message, e.g. the model-request JSON copy
    text = message.get(1)
    if isinstance(text, bytes) and b"<system_notification>" in text:
        print(blob_id, "is_simulated_msg =", message.get(5),
              "simulated_msg_reason =", message.get(9))

On a synthetic store laid out the same way, the flagged message prints is_simulated_msg = 1 simulated_msg_reason = 3, and a message without those fields prints None for both (an explicit false or unspecified value would print 0). This is a quick scan of every blob whose inline text (field 1) contains <system_notification>, not a walk of the current conversation: it can list blobs no longer referenced, and it misses a message whose text is stored separately (text_blob_id). So no output is inconclusive. For a real completion notice, simulated_msg_reason = 3 is the value the patch below checks.

2. What the regex gets wrong

I ran cursor_injection_regex and cursor_visible_user_text, copied from the PR, with the regex version in Cargo.lock (1.12.3). In the table, "a notice" means your CURSOR_BG_TASK_NOTICE fixture.

What the user sent What the bubble shows
Why did this fail?, a newline, then a pasted notice Why did this fail?
Only a pasted notice No bubble
<timestamp>9:00</timestamp> my notes , then a notice No bubble
Four spaces, indented code, and a trailing newline indented code
A notice, then <user_query>Tell the user what happened with the task.</user_query> <user_query>Tell the user what happened with the task.</user_query>
  • Real prompts lose text. The pattern removes any complete <system_notification>…</system_notification> block, with or without a timestamp, and any <user_query> block that starts with the stock prefix, whoever wrote them. A pasted notice and an injected one can have identical text, so text matching alone can't tell them apart, while the completion reason can. Since codeg has been showing exactly this XML in user bubbles, someone copying it into a question is plausible.
  • The timestamp group can eat prose. Under (?s), the lazy <timestamp>.*?</timestamp> (L873) can run past the first closing tag to a later one that a notice follows, and everything in between goes.
  • Every prompt is trimmed, not only the ones the pattern changed (L884-L892). That changes leading indentation and trailing newlines on ordinary prompts. codeg's shared post-parse sanitizer, by contrast, leaves text untouched when it has nothing to strip (mod.rs L498-L515). The trim also reaches turn timing: the parser pairs each stored prompt with codeg's turn-timing journal by hashing the parsed text and stops at the first mismatch (L1220-L1240), while the send path hashes the text it sent (connection.rs L11297-L11307). A prompt stored with surrounding whitespace would stop matching, and older turns would lose their journal clocks with it. codeg's composer already trims typed prompts (to-prompt-blocks.ts L38), so this is narrow, but the fix doesn't need to change ordinary text at all.
  • Rewording brings the bubble back. The follow-up branch requires the fixed prefix "Briefly inform the user about the task result" (L875-L876). If that wording changes, the follow-up shows up as a raw <user_query> bubble, as in the last row above.

3. Suggested change

  • Read field 9 of the decoded UserMessage instead of matching text, in the two places the PR already touches: build_turns (L1016-L1024) and the title fallback first_user_text (L348-L356). For a turn whose reason is 3, skip the text block and keep everything else, which is what your PR already does when stripping leaves nothing. Grok's parser also keys on metadata: it skips user_message_chunk updates marked hideFromScrollback (grok.rs L705-L719). Claude's parser uses a regex (claude.rs L15-L35) for tags Claude Code writes into the text of user records. Here Cursor marks the whole message, so the reason is the more precise signal.
  • Leave the decoded text of every unmarked message as it is, with no trimming.
  • Keep the change to reason 3. Cursor's terminal UI renders other reasons, such as DIFF_TAB_COMMIT, USER_QUICK_ACTION and MARKDOWN_PROMPT_BUTTON, as ordinary user messages. GOAL_CONTINUATION (31) and PLAN_EXECUTION (1) have their own rendering there and can be considered separately.
  • Drop the OnceLock and Regex imports the regex needed.
A patch on top of your branch

It applies with git apply on fix/cursor-hide-system-notification at cca834a. It keeps both notice fixtures and the image scenario, and reworks the PR's five new tests into four: reason-based hiding, unmarked text kept as is (including a quoted notice and surrounding whitespace), the title fallback, and the image. With it, the module's 33 tests pass (cargo test --no-default-features --features server-bin --lib parsers::cursor), and so does cargo clippy --no-default-features --features server-bin,test-utils --lib --tests -- -D warnings. With detection disabled, the three hiding tests fail, so they do guard the behavior.

diff --git a/src-tauri/src/parsers/cursor.rs b/src-tauri/src/parsers/cursor.rs
index d2a8824a9..8dde33687 100644
--- a/src-tauri/src/parsers/cursor.rs
+++ b/src-tauri/src/parsers/cursor.rs
@@ -1,10 +1,8 @@
 use std::collections::HashSet;
 use std::ffi::OsString;
 use std::path::{Path, PathBuf};
-use std::sync::OnceLock;
 
 use chrono::{DateTime, TimeZone, Utc};
-use regex::Regex;
 use rusqlite::{Connection, OpenFlags};
 use serde_json::Value;
 
@@ -351,8 +349,11 @@ fn first_user_text(conn: &Connection, state: &DecodedState) -> Option<String> {
     let agent_turn = wire::first_message(&turn_bytes, 1)?;
     let user_ref = wire::first_bytes(&agent_turn, 1)?;
     let msg = read_user_message(conn, &user_ref);
+    if is_background_task_completion(&msg) {
+        return None;
+    }
     let text = decode_user_text(conn, &msg)?;
-    cursor_visible_user_text(&text)
+    (!text.trim().is_empty()).then_some(text)
 }
 
 // ---------------------------------------------------------------------------
@@ -855,42 +856,22 @@ fn read_user_message(conn: &Connection, user_ref: &[u8]) -> Vec<u8> {
     read_blob(conn, user_ref).unwrap_or_else(|| user_ref.to_vec())
 }
 
-/// Cursor injects a finished background shell as a `UserMessage`:
-/// `<timestamp>…</timestamp><system_notification>…<task>…</task></system_notification>`
-/// plus an optional `<user_query>Briefly inform the user…</user_query>`.
-/// Claude strips `<system-reminder>`; Grok honors `_meta.hideFromScrollback`.
-/// Cursor's ACP store has neither flag, so the same class is dropped here.
-/// A real prompt that only *mentions* the tag name is left alone — the
-/// patterns require a closed wrapper (and, for `user_query`, Cursor's stock
-/// follow-up sentence). `<timestamp>` is only removed when it sits on the
-/// same injection as `<system_notification>`; a lone date wrapper in user
-/// prose is kept.
-fn cursor_injection_regex() -> &'static Regex {
-    static RE: OnceLock<Regex> = OnceLock::new();
-    RE.get_or_init(|| {
-        Regex::new(concat!(
-            r"(?s)",
-            r"(?:<timestamp>.*?</timestamp>\s*)?",
-            r"<system_notification>.*?</system_notification>",
-            r"(?:\s*<user_query>\s*Briefly inform the user about the task result.*?</user_query>)?",
-            r"|<user_query>\s*Briefly inform the user about the task result.*?</user_query>",
-        ))
-        .expect("cursor injection regex")
+/// `UserMessage.simulated_msg_reason` (field 9) is the `SimulatedMsgReason`
+/// Cursor stamps on a turn it starts by itself. `3` is
+/// `BACKGROUND_TASK_COMPLETION`: the turn that delivers a finished background
+/// task's notice (`<timestamp>`, `<system_notification>`, and the stock
+/// `<user_query>` follow-up). Cursor's terminal UI renders that turn as a
+/// "Finished" entry instead of a user message, so the history skips its text
+/// too, keyed on this field rather than on the notice's wording.
+const SIMULATED_MSG_REASON_BACKGROUND_TASK_COMPLETION: u64 = 3;
+
+/// Whether a decoded `UserMessage` is Cursor's background-task notice.
+fn is_background_task_completion(msg: &[u8]) -> bool {
+    wire::Fields::new(msg).any(|(no, val)| {
+        no == 9 && val.u64() == Some(SIMULATED_MSG_REASON_BACKGROUND_TASK_COMPLETION)
     })
 }
 
-/// Visible user prose after stripping Cursor's injected wrappers.
-/// `None` if the turn was only an injection (do not render a user bubble).
-fn cursor_visible_user_text(text: &str) -> Option<String> {
-    let cleaned = cursor_injection_regex().replace_all(text, "");
-    let trimmed = cleaned.trim();
-    if trimmed.is_empty() {
-        None
-    } else {
-        Some(trimmed.to_string())
-    }
-}
-
 /// User prompt text from a decoded `UserMessage`: `text` (1), else the
 /// large-text blob (`text_blob_id`, 18), else `rich_text` (8).
 fn decode_user_text(conn: &Connection, msg: &[u8]) -> Option<String> {
@@ -1017,10 +998,14 @@ fn build_turns(
             if let Some(user_ref) = wire::first_bytes(&agent_turn, 1) {
                 let msg = read_user_message(conn, &user_ref);
                 let mut user_blocks: Vec<ContentBlock> = Vec::new();
-                if let Some(text) = decode_user_text(conn, &msg)
-                    .and_then(|text| cursor_visible_user_text(&text))
-                {
-                    user_blocks.push(ContentBlock::Text { text });
+                // Cursor's own notice for a finished background task is not
+                // something the user said; its reply still renders below.
+                if !is_background_task_completion(&msg) {
+                    if let Some(text) = decode_user_text(conn, &msg) {
+                        if !text.trim().is_empty() {
+                            user_blocks.push(ContentBlock::Text { text });
+                        }
+                    }
                 }
                 // Text first, then attachments — matching the composer's
                 // send order. An image-only prompt still produces the turn.
@@ -2798,7 +2783,16 @@ mod tests {
     fn put_text_only_turn(store: &mut StoreBuilder, user_text: &str, reply: &str) -> Vec<u8> {
         let mut user_msg = Vec::new();
         field_str(1, user_text, &mut user_msg);
-        let user_id = store.put_blob(&user_msg);
+        put_turn_with_user_message(store, &user_msg, reply)
+    }
+
+    /// One agent turn from raw `UserMessage` bytes plus a text-only reply.
+    fn put_turn_with_user_message(
+        store: &mut StoreBuilder,
+        user_msg: &[u8],
+        reply: &str,
+    ) -> Vec<u8> {
+        let user_id = store.put_blob(user_msg);
         let mut assistant = Vec::new();
         field_str(1, reply, &mut assistant);
         let mut step_text = Vec::new();
@@ -3820,17 +3814,37 @@ detail: signal=SIGTERM
 </system_notification>
 <user_query>Briefly inform the user about the task result and perform any follow-up actions (if needed). If there's no follow-ups needed, don't explicitly say that.</user_query>";
 
-    /// One ACP session whose turns are (user text, assistant text) pairs.
-    fn build_acp_text_turns(root: &Path, session_id: &str, pairs: &[(&str, &str)]) {
+    /// `UserMessage` bytes for the turn Cursor opens to deliver a finished
+    /// background task: the notice text (1) plus the fields Cursor sets on it,
+    /// `is_simulated_msg` (5), `simulated_msg_reason` =
+    /// BACKGROUND_TASK_COMPLETION (9) and `simulated_message_metadata`
+    /// (15: {1 title, 2 task_id}).
+    fn background_task_completion_message(notice: &str) -> Vec<u8> {
+        let mut metadata = Vec::new();
+        field_str(1, "Search files mentioning 全面复盘", &mut metadata);
+        field_str(2, "870807", &mut metadata);
+        let mut user_msg = Vec::new();
+        field_str(1, notice, &mut user_msg);
+        field_varint(5, 1, &mut user_msg);
+        field_varint(
+            9,
+            SIMULATED_MSG_REASON_BACKGROUND_TASK_COMPLETION,
+            &mut user_msg,
+        );
+        field_bytes(15, &metadata, &mut user_msg);
+        user_msg
+    }
+
+    /// One ACP session over turn blobs the closure stores.
+    fn build_acp_session(
+        root: &Path,
+        session_id: &str,
+        turns: impl FnOnce(&mut StoreBuilder) -> Vec<Vec<u8>>,
+    ) {
         let session_dir = root.join("acp-sessions").join(session_id);
         std::fs::create_dir_all(&session_dir).unwrap();
         let mut store = StoreBuilder::create(&session_dir.join("store.db"));
-
-        let mut turn_ids = Vec::new();
-        for (user_text, assistant_text) in pairs {
-            turn_ids.push(put_text_only_turn(&mut store, user_text, assistant_text));
-        }
-
+        let turn_ids = turns(&mut store);
         let mut state = Vec::new();
         for turn_id in &turn_ids {
             field_bytes(8, turn_id, &mut state);
@@ -3840,21 +3854,15 @@ detail: signal=SIGTERM
         store.put_meta(&serde_json::json!({
             "agentId": session_id,
             "latestRootBlobId": hex_encode(&root_id),
-            "name": "Find Previous Article",
             "createdAt": 1_784_000_000_000_u64,
         }));
-        std::fs::write(
-            session_dir.join("meta.json"),
-            r#"{"schemaVersion":1,"cwd":"/Users/me/acp-proj","title":"Find Previous Article"}"#,
-        )
-        .unwrap();
     }
 
-    fn user_texts(detail: &ConversationDetail) -> Vec<&str> {
+    fn role_texts(detail: &ConversationDetail, user: bool) -> Vec<&str> {
         detail
             .turns
             .iter()
-            .filter(|t| matches!(t.role, TurnRole::User))
+            .filter(|t| matches!(t.role, TurnRole::User) == user)
             .flat_map(|t| t.blocks.iter())
             .filter_map(|b| match b {
                 ContentBlock::Text { text } => Some(text.as_str()),
@@ -3864,139 +3872,101 @@ detail: signal=SIGTERM
     }
 
     #[test]
-    fn hides_cursor_background_task_notification() {
+    fn hides_background_task_completion_turns() {
         let tmp = tempfile::tempdir().unwrap();
-        build_acp_text_turns(
-            tmp.path(),
-            "acp-bg-notice",
-            &[
-                ("之前持久化过一篇公众号文章", "文章还在原目录。"),
-                (CURSOR_BG_TASK_NOTICE, "那两次全盘搜索已经停掉了。"),
-                (CURSOR_BG_TASK_NOTICE_WITH_QUERY, "不影响结论。"),
-            ],
-        );
+        build_acp_session(tmp.path(), "acp-bg-notice", |store| {
+            vec![
+                put_text_only_turn(store, "之前持久化过一篇公众号文章", "文章还在原目录。"),
+                put_turn_with_user_message(
+                    store,
+                    &background_task_completion_message(CURSOR_BG_TASK_NOTICE),
+                    "那两次全盘搜索已经停掉了。",
+                ),
+                put_turn_with_user_message(
+                    store,
+                    &background_task_completion_message(CURSOR_BG_TASK_NOTICE_WITH_QUERY),
+                    "不影响结论。",
+                ),
+            ]
+        });
         let parser = CursorParser::with_base_dir(tmp.path().join("chats"));
         let detail = parser.get_conversation("acp-bg-notice").unwrap();
-
-        let users = user_texts(&detail);
-        assert_eq!(users, ["之前持久化过一篇公众号文章"]);
-        assert!(users.iter().all(|t| !t.contains("system_notification")));
-        assert!(users.iter().all(|t| !t.contains("<timestamp>")));
-        assert!(users.iter().all(|t| !t.contains("<user_query>")));
-
-        let assistants: Vec<_> = detail
-            .turns
-            .iter()
-            .filter(|t| matches!(t.role, TurnRole::Assistant))
-            .collect();
-        assert_eq!(assistants.len(), 3);
-        assert!(matches!(
-            &assistants[1].blocks[0],
-            ContentBlock::Text { text } if text == "那两次全盘搜索已经停掉了。"
-        ));
-        assert!(matches!(
-            &assistants[2].blocks[0],
-            ContentBlock::Text { text } if text == "不影响结论。"
-        ));
+        assert_eq!(role_texts(&detail, true), ["之前持久化过一篇公众号文章"]);
+        assert_eq!(
+            role_texts(&detail, false),
+            [
+                "文章还在原目录。",
+                "那两次全盘搜索已经停掉了。",
+                "不影响结论。"
+            ]
+        );
     }
 
+    /// A prompt without the background-completion reason is the user's own
+    /// text, even when it quotes a notice (pasted to ask about it), so its
+    /// decoded text is kept as is, surrounding whitespace included.
     #[test]
-    fn keeps_real_user_text_that_mentions_system_notification() {
+    fn keeps_unstamped_prompts_verbatim() {
         let tmp = tempfile::tempdir().unwrap();
-        build_acp_text_turns(
-            tmp.path(),
-            "acp-bg-mention",
-            &[(
-                "这段 <system_notification> 是什么意思",
-                "那是 Cursor 的内部通知。",
-            )],
-        );
+        let pasted = format!("Why did this fail?\n{CURSOR_BG_TASK_NOTICE}");
+        build_acp_session(tmp.path(), "acp-bg-pasted", |store| {
+            vec![
+                put_text_only_turn(store, &pasted, "It was stopped by SIGTERM."),
+                put_text_only_turn(store, CURSOR_BG_TASK_NOTICE, "That is a Cursor notice."),
+                put_text_only_turn(store, "    indented\nand a trailing newline\n", "ok"),
+            ]
+        });
         let parser = CursorParser::with_base_dir(tmp.path().join("chats"));
-        let detail = parser.get_conversation("acp-bg-mention").unwrap();
+        let detail = parser.get_conversation("acp-bg-pasted").unwrap();
         assert_eq!(
-            user_texts(&detail),
-            ["这段 <system_notification> 是什么意思"]
+            role_texts(&detail, true),
+            [
+                pasted.as_str(),
+                CURSOR_BG_TASK_NOTICE,
+                "    indented\nand a trailing newline\n",
+            ]
         );
     }
 
+    /// The summary's title fallback decodes turn 0 alone: a stamped notice
+    /// there must not become the conversation title.
     #[test]
-    fn keeps_user_prose_around_a_stripped_injection() {
+    fn skips_background_task_completion_in_title_fallback() {
         let tmp = tempfile::tempdir().unwrap();
-        let mixed = format!("先看这个\n{CURSOR_BG_TASK_NOTICE}\n再继续");
-        build_acp_text_turns(
-            tmp.path(),
-            "acp-bg-mixed",
-            &[(&mixed, "好的。")],
-        );
+        build_acp_session(tmp.path(), "acp-bg-title", |store| {
+            vec![
+                put_turn_with_user_message(
+                    store,
+                    &background_task_completion_message(CURSOR_BG_TASK_NOTICE),
+                    "搜索已停止。",
+                ),
+                put_text_only_turn(store, "继续", "好的。"),
+            ]
+        });
         let parser = CursorParser::with_base_dir(tmp.path().join("chats"));
-        let detail = parser.get_conversation("acp-bg-mixed").unwrap();
-        let users = user_texts(&detail);
-        assert_eq!(users.len(), 1);
-        assert!(users[0].contains("先看这个"));
-        assert!(users[0].contains("再继续"));
-        assert!(!users[0].contains("system_notification"));
-        assert!(!users[0].contains("<timestamp>"));
+        let summaries = parser.list_conversations().unwrap();
+        assert_eq!(summaries.len(), 1);
+        assert_eq!(summaries[0].title, None);
+        let detail = parser.get_conversation("acp-bg-title").unwrap();
+        assert_eq!(detail.summary.title.as_deref(), Some("继续"));
     }
 
     #[test]
-    fn cursor_visible_user_text_drops_only_closed_injections() {
-        assert_eq!(cursor_visible_user_text(CURSOR_BG_TASK_NOTICE), None);
-        assert_eq!(cursor_visible_user_text(CURSOR_BG_TASK_NOTICE_WITH_QUERY), None);
-        assert_eq!(
-            cursor_visible_user_text("这段 <system_notification> 是什么意思"),
-            Some("这段 <system_notification> 是什么意思".into())
-        );
-        assert_eq!(
-            cursor_visible_user_text("比较 <timestamp>2026-10-15</timestamp> 和现在"),
-            Some("比较 <timestamp>2026-10-15</timestamp> 和现在".into())
-        );
-        assert_eq!(
-            cursor_visible_user_text("   \n"),
-            None
-        );
-    }
-
-    #[test]
-    fn keeps_image_when_user_text_is_only_an_injection() {
+    fn keeps_image_on_a_background_task_completion_turn() {
         use base64::{engine::general_purpose::STANDARD, Engine as _};
         let tmp = tempfile::tempdir().unwrap();
-        let session_dir = tmp.path().join("acp-sessions").join("acp-bg-img");
-        std::fs::create_dir_all(&session_dir).unwrap();
-        let mut store = StoreBuilder::create(&session_dir.join("store.db"));
-
         let png_bytes: &[u8] = b"\x89PNG\r\n\x1a\nfakepixels";
-        let png_blob_id = store.put_blob(png_bytes);
-        let mut image = Vec::new();
-        field_bytes(1, &png_blob_id, &mut image);
-        field_str(7, "image/png", &mut image);
-        let mut attachment = Vec::new();
-        field_bytes(1, &image, &mut attachment);
-
-        let mut user_msg = Vec::new();
-        field_str(1, CURSOR_BG_TASK_NOTICE, &mut user_msg);
-        field_bytes(3, &attachment, &mut user_msg);
-        let user_id = store.put_blob(&user_msg);
-        let mut assistant = Vec::new();
-        field_str(1, "图还在。", &mut assistant);
-        let mut step_text = Vec::new();
-        field_bytes(1, &assistant, &mut step_text);
-        let step_id = store.put_blob(&step_text);
-        let mut agent_turn = Vec::new();
-        field_bytes(1, &user_id, &mut agent_turn);
-        field_bytes(2, &step_id, &mut agent_turn);
-        let mut turn = Vec::new();
-        field_bytes(1, &agent_turn, &mut turn);
-        let turn_id = store.put_blob(&turn);
-        let mut state = Vec::new();
-        field_bytes(8, &turn_id, &mut state);
-        field_varint(26, 1_784_000_000_000, &mut state);
-        let root_id = store.put_blob(&state);
-        store.put_meta(&serde_json::json!({
-            "agentId": "acp-bg-img",
-            "latestRootBlobId": hex_encode(&root_id),
-            "createdAt": 1_784_000_000_000_u64,
-        }));
-
+        build_acp_session(tmp.path(), "acp-bg-img", |store| {
+            let png_blob_id = store.put_blob(png_bytes);
+            let mut image = Vec::new();
+            field_bytes(1, &png_blob_id, &mut image);
+            field_str(7, "image/png", &mut image);
+            let mut attachment = Vec::new();
+            field_bytes(1, &image, &mut attachment);
+            let mut user_msg = background_task_completion_message(CURSOR_BG_TASK_NOTICE);
+            field_bytes(3, &attachment, &mut user_msg);
+            vec![put_turn_with_user_message(store, &user_msg, "图还在。")]
+        });
         let parser = CursorParser::with_base_dir(tmp.path().join("chats"));
         let detail = parser.get_conversation("acp-bg-img").unwrap();
         let user = detail
@@ -4012,5 +3982,6 @@ detail: signal=SIGTERM
             }
             other => panic!("expected image only, got {other:?}"),
         }
+        assert_eq!(role_texts(&detail, false), ["图还在。"]);
     }
 }

4. Checked and fine

  • Ordinary prompts are stored raw. In the Cursor stores on my machine, a normal prompt's UserMessage.text is just what the user typed. The <timestamp>/<user_query> wrapping the model sees lives only in a separate model-request JSON blob, which the parser doesn't read. So the regex doesn't fire on normal prompts by accident: it needs a complete <system_notification> block, or a <user_query> block with the stock prefix, in the text itself. The trim is the exception, since it applies to every prompt.
  • The parser is the right place for the fix, for the reasons in §1: codeg builds Cursor history from the store, not from Cursor's replay.
  • Keeping the reply when the bubble goes is right, and the patch's hiding and image tests keep covering it.
  • The PR's own tests pass in CI.

5. Tests and manual checks

Tests (all in the patch):

  • A notice turn with field 9 = 3, with and without the follow-up query: no user bubble, and the reply stays.
  • The same notice text without the reason, alone or after a question, and a prompt with leading indentation and a trailing newline: all kept as is.
  • A marked notice as the first turn: the title fallback skips it.
  • A marked notice with an image: the image stays.

Manual:

  • In codeg, ask Cursor to start a background task and make sure it actually produces an automatic completion notice. For example, ask it to run sleep 30; echo done in the background (on a POSIX shell). A run that produces no notice doesn't exercise the fix. After the follow-up reply arrives, close the conversation's tab and open it again, or restart codeg. No bubble should show the notice, the follow-up reply should be there, and earlier prompts should be unchanged.
  • Run the script from §1 on a copy of that session's store, made as described there, and confirm simulated_msg_reason = 3 on the notice turn.

Thanks again! Your fixture and the handling around images and replies carry straight over to the reason-based version. If anything here is unclear, or you see it differently, just reply here. I'm happy to look at the next round.

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.

2 participants