fix: strip XML wrapper from replayed user messages (#150)

* fix: strip XML wrapper from replayed user messages

When loading a previous session, user messages were displayed with raw
XML tags (<persona-instructions>, <user-message>, etc.) because the
goose binary stores the full wrapped prompt in its session history.

Add extract_user_message() to parse out just the <user-message> content
before emitting it to the frontend during session replay.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: move dispatcher tests to separate file to stay under line limit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* style: apply cargo fmt and trim to file size limit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove blank line to satisfy file size check

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: anchor XML close tag to end of string to avoid truncating user input

Addresses review feedback: if a user's message literally contains
"</user-message>", the previous find()-based approach would truncate.
Now we require the close tag to be at the end of the string, matching
the backend's wrapping format exactly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Taylor Ho
2026-04-10 11:10:29 -10:00
committed by GitHub
parent c5798cd822
commit 86cd8ab975
2 changed files with 44 additions and 1 deletions
@@ -19,6 +19,17 @@ use crate::services::acp::payloads::{
ToolResultPayload, ToolTitlePayload,
};
fn extract_user_message(raw: &str) -> &str {
const OPEN: &str = "<user-message>\n";
const CLOSE: &str = "\n</user-message>";
if let Some(start) = raw.find(OPEN) {
let inner = start + OPEN.len();
if raw[inner..].ends_with(CLOSE) {
return &raw[inner..raw.len() - CLOSE.len()];
}
}
raw
}
fn model_options_from_select_options(options: &SessionConfigSelectOptions) -> Vec<ModelOption> {
match options {
SessionConfigSelectOptions::Ungrouped(values) => values
@@ -386,13 +397,14 @@ impl Client for SessionEventDispatcher {
}
}
let display_text = extract_user_message(&text.text);
let message_id = uuid::Uuid::new_v4().to_string();
let _ = self.app_handle.emit(
"acp:replay_user_message",
serde_json::json!({
"sessionId": local_session_id,
"messageId": message_id,
"text": text.text,
"text": display_text,
}),
);
}
@@ -502,3 +514,6 @@ impl Client for SessionEventDispatcher {
Ok(())
}
}
#[cfg(test)]
#[path = "dispatcher_tests.rs"]
mod tests;
@@ -0,0 +1,28 @@
use super::*;
#[test]
fn extract_user_message_strips_xml_wrapper() {
let wrapped = "<persona-instructions>\nYou are a helpful assistant.\n</persona-instructions>\n\n<user-message>\nhello\n</user-message>";
assert_eq!(extract_user_message(wrapped), "hello");
}
#[test]
fn extract_user_message_multiline() {
let wrapped = "<persona-instructions>\nstuff\n</persona-instructions>\n\n<user-message>\nline one\nline two\n</user-message>";
assert_eq!(extract_user_message(wrapped), "line one\nline two");
}
#[test]
fn extract_user_message_no_wrapper() {
assert_eq!(extract_user_message("plain text"), "plain text");
}
#[test]
fn extract_user_message_preserves_inner_delimiter() {
// User literally typed "</user-message>" in their message — must not truncate.
let wrapped = "<persona-instructions>\nstuff\n</persona-instructions>\n\n<user-message>\ncheck this tag: </user-message> cool right?\n</user-message>";
assert_eq!(
extract_user_message(wrapped),
"check this tag: </user-message> cool right?"
);
}