Repository navigation
Conversation
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>
|
@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 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 already marks these turns
Where to look in the Cursor CLI package, and a script to check your storePackage:
To check a store:
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 2. What the regex gets wrongI ran
3. Suggested change
A patch on top of your branchIt applies with 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
5. Tests and manual checksTests (all in the patch):
Manual:
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. |
Summary
UserMessageXML (<timestamp>,<system_notification>, optional stock<user_query>). The Cursor history parser rendered those as the user's own bubbles.<system-reminder>; Grok honors_meta.hideFromScrollback. This applies the same class of filter inparsers/cursor.rs.<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)<timestamp>, and image-on-injection casesTestworkflowMade with Cursor