fix(tui): cut a backtrack's saved history at the undone turn's prompt

Undoing a turn older than the saved transcript's cursor refused with "Cannot
identify an exact saved-history boundary for this backtrack". It searched for
a message count whose whole projection equalled the kept turns' — which a real
transcript cannot match: it carries the per-turn `<turn_meta>` preamble
(rebuilt from runtime facts when a turn is installed, never recorded on an
item) and tool results as the route's compaction left them, while the items
keep the prompt and the raw output.

The prompts are recorded verbatim and every turn begins with its user message,
so the boundary is now found by walking the transcript's user text messages:
the kept turns' prompts in order, then the undone turn's. The projection match
is still tried first, for transcripts the records do reproduce. A transcript
that does not line up refuses instead of cutting where it cannot account for
the history; a turn with no recorded prompt (an image-only one) says so.

Reproduced from the VS Code client on engine 0.10.0: two turns where the first
wrote a file, exactly the shape whose write pair and trimmed tool result break
the projection. The regression test reports that error without this fix.

Signed-off-by: Ben Gao <[email protected]>
This commit is contained in:
Ben Gao committed 2026-09-24 09:45:48 +08:00
1 parent 43771182e9
commit 47aa0fdb9f
2 files changed
+352 -22

No files matched your search

+114 -22
View File
@@ -772,27 +772,88 @@ fn session_messages_sha256(messages: &[Message]) -> Result<String> {
.collect())
}
/// Compare only fields represented by legacy seeding. A match identifies a
/// prefix boundary; the saved messages themselves retain every raw block.
/// The prompt text a user-role message contributes to a history comparison,
/// or `None` when it carries none.
///
/// Tool results are user-role messages with no text. The per-turn
/// `<turn_meta>` preamble is rebuilt from runtime facts when a turn is
/// installed and never recorded on the turn's items, so it is not part of the
/// conversation a reconstruction can identify — both sides of every comparison
/// drop it, and `extract_user_prompt` is the repository's one rule for what a
/// user's prompt is once that envelope is removed (it trims the block's edges
/// too, and applies the same way on both sides).
fn projected_user_text(message: &Message) -> Option<String> {
if message.role.as_str() != "user" {
return None;
}
let text = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } if !text.trim().is_empty() => Some(text.as_str()),
_ => None,
})
.map(crate::session_manager::extract_user_prompt)
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n");
(!text.is_empty()).then_some(text)
}
fn projected_user_texts(messages: &[Message]) -> Vec<String> {
messages.iter().filter_map(projected_user_text).collect()
}
/// The message index in a saved transcript where the undone turn begins.
///
/// A backtrack may only keep the messages that belong to the turns before the
/// undone one, and the transcript alone cannot say where that is: it is the
/// model-visible history, carrying the per-turn `<turn_meta>` preamble and tool
/// results as the route's compaction left them, while the turn records keep the
/// prompt and the raw output. The prompts *are* recorded verbatim, and a turn
/// begins with its user message, so walking the transcript's user text
/// messages — the kept turns' prompts in order, then the undone turn's —
/// locates the boundary exactly.
///
/// `None` refuses: the caller must not cut a history it cannot account for, and
/// a transcript that drifted from the records (edited, purged, or belonging to
/// another conversation) fails the prompt sequence rather than matching by
/// coincidence.
fn saved_history_boundary(
messages: &[Message],
kept_prompts: &[String],
target_prompt: &str,
) -> Option<usize> {
let mut kept = kept_prompts.iter();
let mut expected = kept.next();
for (index, message) in messages.iter().enumerate() {
let Some(text) = projected_user_text(message) else {
continue;
};
match expected {
Some(next) if next == &text => expected = kept.next(),
// The first user text that is not part of the kept prefix is where
// the undone turn begins — and it has to be that turn's own prompt.
_ => return (text == target_prompt).then_some(index),
}
}
None
}
/// The conversation identity two histories are compared by: user prompts (their
/// `<turn_meta>` envelope removed), assistant text, thinking and tool calls,
/// and tool results.
///
/// Everything a turn record cannot reproduce stays out — that preamble, image
/// blocks, and the bytes the route's compaction left in a tool result — so a
/// match identifies a prefix boundary rather than a byte-for-byte replay. The
/// saved messages themselves retain every raw block.
fn session_recovery_projection(messages: &[Message]) -> Vec<Value> {
let mut projection = Vec::new();
for message in messages {
let role = message.role.as_str();
if role == "user" {
let text = message
.content
.iter()
.filter_map(|block| match block {
ContentBlock::Text { text, .. } if !text.trim().is_empty() => {
Some(text.as_str())
}
_ => None,
})
.collect::<Vec<_>>()
.join("\n");
if !text.is_empty() {
projection.push(json!(["user", text]));
}
if let Some(text) = projected_user_text(message) {
projection.push(json!(["user", text]));
}
for block in &message.content {
match block {
@@ -8645,12 +8706,43 @@ impl RuntimeThreadManager {
let retained_messages = if covered <= target_turn_idx {
messages.len()
} else {
let expected = session_recovery_projection(
&self.reconstruct_messages_from_turns(&source_turns[..target_turn_idx])?,
);
(0..=messages.len())
.find(|count| session_recovery_projection(&messages[..*count]) == expected)
let kept_messages =
self.reconstruct_messages_from_turns(&source_turns[..target_turn_idx])?;
let kept_projection = session_recovery_projection(&kept_messages);
// An exact projection match is the strongest proof: message for
// message, this prefix *is* the kept history. It holds for a
// transcript the records reproduce, and is tried first so those
// shapes keep their exact boundary.
if let Some(count) = (0..=messages.len()).find(|count| {
session_recovery_projection(&messages[..*count]) == kept_projection
}) {
count
} else {
// It cannot hold for a real conversation: the model-visible
// transcript carries the per-turn `<turn_meta>` preamble and
// tool results as the route's compaction left them, neither
// of which the records keep. The prompt is recorded
// verbatim, so it still names the message the undone turn
// begins at — see `saved_history_boundary`.
let target_prompt = projected_user_texts(
&self.reconstruct_messages_from_turns(
&source_turns[target_turn_idx..=target_turn_idx],
)?,
)
.into_iter()
.next()
.with_context(|| {
format!(
"Turn {target_turn_id} records no user prompt to align the saved history with; the source thread was preserved"
)
})?;
saved_history_boundary(
&messages,
&projected_user_texts(&kept_messages),
&target_prompt,
)
.context("Cannot identify an exact saved-history boundary for this backtrack; the source thread was preserved")?
}
};
forked.saved_session_checkpoint = Some(SavedSessionCheckpoint {
covered_turn_id: kept_turns
+238
View File
@@ -434,6 +434,244 @@ mod recovery {
Ok(())
}
/// A backtrack over a turn that used a file tool must still find the
/// boundary it keeps.
///
/// The saved transcript is the model-visible history, so it carries the
/// per-turn `<turn_meta>` preamble (rebuilt from runtime facts, never
/// recorded on an item) and tool results as the route's compaction left
/// them (items keep the raw output). Neither is reproducible from the
/// records, so a two-turn conversation whose first turn wrote a file could
/// not be cut at all: undo answered "Cannot identify an exact saved-history
/// boundary for this backtrack" and left the turn in place — reported from
/// the VS Code client on engine 0.10.0, where reverting one file of the
/// same conversation worked.
#[tokio::test]
async fn backtrack_over_a_file_tool_turn_finds_the_saved_history_boundary() -> Result<()> {
let _env = crate::test_support::lock_test_env();
let dir = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", dir.path());
let manager = RuntimeThreadManager::open(
config(),
dir.path().to_path_buf(),
test_manager_config(dir.path().join("runtime")),
)?;
let thread = manager
.create_thread(CreateThreadRequest::default())
.await?;
let base = Utc::now();
let at = |seconds: i64| Some(base + chrono::Duration::seconds(seconds));
// A file tool records itself as a `FileChange` item that carries the
// call identity — the same shape the transcript's tool_use/tool_result
// pair came from.
fn tool_metadata(call: &str, name: &str, input: &str) -> Value {
json!({
"tool_use_id": call,
"tool_name": name,
"tool_input": input,
"tool_result_for": call,
"is_error": false,
})
}
let write_call = "call_write_test";
let bash_call = "call_bash_test";
let write_item = |id: &str, turn: &str, order: i64, path: &str, call: &str| {
let mut item = sample_item(turn, id, TurnItemLifecycleStatus::Completed);
item.kind = TurnItemKind::FileChange;
item.started_at = at(order);
item.ended_at = item.started_at;
item.detail = Some(format!("Successfully wrote 5 bytes to {path}"));
item.metadata = Some(tool_metadata(
call,
"write",
&format!("{{\"path\":\"{path}\",\"content\":\"x\\n\"}}"),
));
item
};
let text_item = |id: &str, turn: &str, order: i64, kind: TurnItemKind, text: &str| {
let mut item = sample_item(turn, id, TurnItemLifecycleStatus::Completed);
item.kind = kind;
item.started_at = at(order);
item.ended_at = item.started_at;
item.summary = text.to_string();
item.detail = Some(text.to_string());
item
};
let turn_one = "turn_writes_file";
let turn_two = "turn_after";
let items = vec![
text_item(
"item_u1",
turn_one,
0,
TurnItemKind::UserMessage,
"write test.txt",
),
text_item(
"item_r1",
turn_one,
1,
TurnItemKind::AgentReasoning,
"I will write it.",
),
write_item("item_f1", turn_one, 2, "test.txt", write_call),
{
let mut item = sample_item(turn_one, "item_t1", TurnItemLifecycleStatus::Completed);
item.kind = TurnItemKind::ToolCall;
item.started_at = at(3);
item.ended_at = item.started_at;
// The raw output keeps the newline the route's compaction trims
// before the model ever sees it.
item.detail = Some("test.txt\n".to_string());
item.metadata = Some(tool_metadata(
bash_call,
"bash",
"{\"command\":\"ls -l test.txt\"}",
));
item
},
text_item(
"item_a1",
turn_one,
4,
TurnItemKind::AgentMessage,
"created test.txt",
),
text_item(
"item_u2",
turn_two,
5,
TurnItemKind::UserMessage,
"write test2.txt",
),
write_item("item_f2", turn_two, 6, "test2.txt", "call_write_second"),
text_item(
"item_a2",
turn_two,
7,
TurnItemKind::AgentMessage,
"created test2.txt",
),
];
for item in &items {
manager.store.save_item(item)?;
}
for (turn_id, order) in [(turn_one, 0), (turn_two, 10)] {
let mut turn = sample_turn(&thread.id, turn_id, RuntimeTurnStatus::Completed);
turn.started_at = at(order);
turn.ended_at = turn.started_at;
manager.store.save_turn(&turn)?;
}
let mut stored = manager.get_thread(&thread.id).await?;
stored.latest_turn_id = Some(turn_two.to_string());
manager.store.save_thread(&stored)?;
// The model-visible transcript: prompts (each with the per-turn
// preamble), the write pair the items cannot reproduce, and the bash
// result as the route left it.
let transcript: Vec<Message> = serde_json::from_value(json!([
{"role":"user","content":[
{"type":"text","text":"write test.txt"},
{"type":"text","text":"<turn_meta>\nCurrent local date: 2026-09-24\n</turn_meta>"}]},
{"role":"assistant","content":[
{"type":"thinking","thinking":"I will write it."},
{"type":"tool_use","id":write_call,"name":"write","input":{"path":"test.txt","content":"x\n"}}]},
{"role":"user","content":[
{"type":"tool_result","tool_use_id":write_call,"content":"Successfully wrote 5 bytes to test.txt"}]},
{"role":"assistant","content":[
{"type":"thinking","thinking":"verify"},
{"type":"tool_use","id":bash_call,"name":"bash","input":{"command":"ls -l test.txt"}}]},
{"role":"user","content":[
{"type":"tool_result","tool_use_id":bash_call,"content":"test.txt"}]},
{"role":"assistant","content":[
{"type":"text","text":"created test.txt"}]},
{"role":"user","content":[
{"type":"text","text":"write test2.txt"},
{"type":"text","text":"<turn_meta>\nCurrent local date: 2026-09-24\n</turn_meta>"}]},
{"role":"assistant","content":[
{"type":"text","text":"created test2.txt"}]}
]))?;
let saved = crate::session_manager::create_saved_session_with_id_and_mode(
Uuid::new_v4().to_string(),
&transcript,
&thread.model,
dir.path(),
0,
None,
Some("agent"),
);
let sessions = crate::session_manager::SessionManager::new(
crate::session_manager::default_sessions_dir()?,
)?;
{
let _admission = manager.session_checkpoint_guard().await;
sessions.save_session(&saved)?;
manager
.set_thread_session_checkpoint(&thread.id, &saved)
.await?;
}
let prepared = manager.prepare_fork_at_user_message(&thread.id, 0).await?;
let (_, prefix, _) = prepared
.own_session
.as_ref()
.context("a prepared backtrack carries the prefix it keeps")?;
assert_eq!(
prefix.as_slice(),
&transcript[..6],
"the fork keeps exactly the messages before the undone turn"
);
let (fork, _, _, _) = manager.publish_prepared_fork(prepared).await?;
assert_eq!(manager.restore_thread_messages(&fork)?, transcript[..6]);
// Undoing the second turn back (`depth` 1) keeps nothing: the first
// turn's own prompt is the anchor, so the boundary is the first message.
let first_turn = manager.prepare_fork_at_user_message(&thread.id, 1).await?;
let (_, prefix, _) = first_turn
.own_session
.as_ref()
.context("a prepared backtrack carries the prefix it keeps")?;
assert!(prefix.is_empty(), "undoing the first turn keeps no history");
// A transcript that no longer lines up with the turn records must refuse
// rather than cut where it cannot account for the history: the undone
// turn's prompt is the anchor here, not a coincidence to match.
let mut drifted = transcript.clone();
drifted[6] = serde_json::from_value(json!({
"role":"user","content":[
{"type":"text","text":"a prompt no turn records"},
{"type":"text","text":"<turn_meta>\nCurrent local date: 2026-09-24\n</turn_meta>"}]}))?;
let drifted_session = crate::session_manager::create_saved_session_with_id_and_mode(
saved.metadata.id.clone(),
&drifted,
&thread.model,
dir.path(),
0,
None,
Some("agent"),
);
{
let _admission = manager.session_checkpoint_guard().await;
sessions.save_session(&drifted_session)?;
manager
.set_thread_session_checkpoint(&thread.id, &drifted_session)
.await?;
}
let error = match manager.prepare_fork_at_user_message(&thread.id, 0).await {
Ok(_) => panic!("a drifted transcript must not be cut"),
Err(error) => error,
};
assert!(
error
.to_string()
.contains("Cannot identify an exact saved-history boundary"),
"the refusal names the boundary it could not find: {error}"
);
Ok(())
}
async fn control_case(interrupt: bool, follow_up: bool) -> Result<()> {
let _env = crate::test_support::lock_test_env();
let dir = tempfile::tempdir()?;