fix(tui): let undo roll back files for the turn it is undoing (#6483)

This commit is contained in:
gaord authored and GitHub committed 2026-09-25 00:10:43 -07:00
1 parent c6dfdd862c
commit 8a835d7c4d
6 files changed
+543 -23

No files matched your search

+1
View File
@@ -3369,6 +3369,7 @@ impl Engine {
let total_tokens = self.session.total_usage.input_tokens
+ self.session.total_usage.output_tokens;
let snapshot = SessionSnapshot {
session_id: self.session.id.clone(),
messages: self.session.messages.to_vec(),
total_tokens,
model: self.session.model.clone(),
+9
View File
@@ -20,6 +20,15 @@ pub const USER_SHELL_TOOL_ID_PREFIX: &str = "user_shell_";
/// Returned by `Op::GetSessionSnapshot` via a oneshot channel.
#[derive(Debug, Clone)]
pub struct SessionSnapshot {
/// The live conversation id this engine session is running under.
///
/// It is the id every workspace snapshot this conversation takes
/// (`tool:` / `pre-turn:`) is tagged with, so a save of the conversation
/// has to persist it as the session document's own id: a document minted
/// under a different id leaves the thread bound to a session that owns no
/// snapshots, and `/undo` and the file-revert endpoint both select by that
/// binding (see `patch_undo_workspace_files`).
pub session_id: String,
pub messages: Vec<Message>,
pub total_tokens: u64,
pub model: String,
+15 -1
View File
@@ -759,6 +759,11 @@ async fn persist_thread_cost(
/// Unlike `POST /v1/sessions` (which reconstructs messages from stored turn
/// items), this endpoint asks the engine for its live session snapshot so
/// token counts and message ordering are authoritative.
///
/// `session_id` names the document to write. Omitted, the document is the
/// conversation's own id (the one its workspace snapshots are tagged with),
/// so a thread never ends up bound to a session that owns none of them — see
/// [`crate::core::ops::SessionSnapshot::session_id`].
pub(super) async fn save_current_session(
State(state): State<RuntimeApiState>,
Json(req): Json<SaveSessionRequest>,
@@ -845,7 +850,16 @@ pub(super) async fn save_current_session(
}
}
} else {
let mut session = crate::session_manager::create_saved_session_with_mode(
// No session was named, so the conversation the thread is running
// answers it — and the engine's live conversation id *is* that
// conversation's identity: every `tool:` / `pre-turn:` workspace
// snapshot it took is tagged with it, and `patch-undo` /
// `file-revert` select snapshots by the thread's binding. Minting a
// third uuid here (what this used to do) left the thread bound to a
// document whose id owned none of those snapshots, so an undo forked
// the conversation and left every file on disk untouched.
let mut session = crate::session_manager::create_saved_session_with_id_and_mode(
snapshot.session_id.clone(),
&snapshot.messages,
&snapshot.model,
&snapshot.workspace,
+135
View File
@@ -5848,6 +5848,141 @@ async fn session_create_from_thread_returns_404_for_missing_thread() -> Result<(
Ok(())
}
/// `PUT /v1/sessions` with no `session_id` persists the conversation's own id,
/// not a fresh uuid.
///
/// The engine tags every `tool:` / `pre-turn:` workspace snapshot with its live
/// conversation id, and `patch-undo` / `file-revert` select snapshots by the
/// thread's `session_id`. Minting a third uuid at save time left those two
/// disagreeing for every thread whose first save minted the document, so the
/// toolbar's Undo forked the conversation and rolled no files back, and the
/// Changes panel's Revert refused a snapshot that existed (reported against the
/// VS Code client on engine 0.10.0, where no thread's binding matched any
/// snapshot in the workspace).
#[tokio::test]
async fn session_save_without_an_id_keeps_the_conversation_that_owns_the_snapshots() -> Result<()> {
let root = std::env::temp_dir().join(format!("deepseek-session-identity-{}", Uuid::new_v4()));
let sessions_dir = root.join("sessions");
let Some((addr, runtime_threads, handle)) =
spawn_test_server_with_root(root.clone(), sessions_dir.clone()).await?
else {
return Ok(());
};
let client = crate::tls::reqwest_client();
let created: serde_json::Value = client
.post(format!("http://{addr}/v1/threads"))
.json(&json!({
"model": "deepseek-v4-pro",
"mode": "agent",
"workspace": root.join("workspace")
}))
.send()
.await?
.error_for_status()?
.json()
.await?;
let thread_id = created["id"]
.as_str()
.context("missing thread id")?
.to_string();
// A new conversation's id is generated inside the engine, so only a mock
// can state it. It stands in for the id the real engine stamps on every
// snapshot the conversation takes.
const CONVERSATION_ID: &str = "3f9c7b1e-58b4-4d2a-9f5a-8a1e0c6d2b70";
let harness = crate::core::engine::mock_engine_handle();
runtime_threads
.install_test_engine(&thread_id, harness.handle.clone())
.await?;
let mut rx_op = harness.rx_op;
tokio::spawn(async move {
while let Some(op) = rx_op.recv().await {
if let Op::GetSessionSnapshot { tx } = op {
let snapshot = crate::core::ops::SessionSnapshot {
session_id: CONVERSATION_ID.to_string(),
messages: vec![Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: "One conversation, one id".to_string(),
cache_control: None,
}],
}],
total_tokens: 7,
model: "deepseek-v4-pro".to_string(),
model_provider: "deepseek".to_string(),
model_provider_id: None,
workspace: root.clone(),
system_prompt: None,
mode: "agent".to_string(),
};
if let Some(tx) = tx.lock().ok().and_then(|mut guard| guard.take()) {
let _ = tx.send(snapshot);
}
}
}
});
let saved: serde_json::Value = client
.put(format!("http://{addr}/v1/sessions"))
.json(&json!({ "thread_id": thread_id }))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(
saved["session_id"], CONVERSATION_ID,
"a save that names no session must persist the conversation's own id"
);
// That is the id `patch_undo_workspace_files` and `revert_file_from_snapshot`
// select snapshots by, so the binding and the snapshots must agree.
let detail: serde_json::Value = client
.get(format!("http://{addr}/v1/threads/{thread_id}"))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(detail["thread"]["session_id"], CONVERSATION_ID);
// Saving again keeps the one document instead of collecting a new one per
// visit — the drift above was also how one conversation came to hold
// several session files.
let documents = || -> Result<Vec<String>> {
let mut names = Vec::new();
for entry in fs::read_dir(&sessions_dir)? {
names.push(entry?.file_name().to_string_lossy().into_owned());
}
names.sort();
Ok(names)
};
let before = documents()?;
let resaved: serde_json::Value = client
.put(format!("http://{addr}/v1/sessions"))
.json(&json!({ "thread_id": thread_id }))
.send()
.await?
.error_for_status()?
.json()
.await?;
assert_eq!(resaved["session_id"], CONVERSATION_ID);
assert_eq!(
documents()?,
before,
"a second save writes the session it already owns, not another one"
);
assert!(
sessions_dir
.join(format!("{CONVERSATION_ID}.json"))
.exists()
);
handle.abort();
Ok(())
}
/// Create a thread over HTTP and seed it with one user/assistant turn.
/// Shared setup for the undo/patch-undo/retry endpoint tests.
async fn create_seeded_thread(
+118 -22
View File
@@ -772,27 +772,92 @@ 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(),
// A kept prompt is still unaccounted for, so this transcript has
// drifted from the records; even the undone turn's prompt here
// would cut away a turn the backtrack must keep.
Some(_) => return None,
// The first user text after the whole kept prefix is where the
// undone turn begins — and it has to be that turn's own prompt.
None => 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 {
@@ -8911,12 +8976,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
+265
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()?;
@@ -17839,6 +18077,33 @@ mod adoption_refusal {
}
}
#[test]
fn saved_history_boundary_refuses_until_every_kept_prompt_is_seen() {
let user = |text: &str| Message {
role: Role::User,
content: vec![ContentBlock::Text {
text: text.to_string(),
cache_control: None,
}],
};
let kept = vec!["first".to_string(), "second".to_string()];
// The kept prompts in order, then the undone turn's prompt: cut there.
let messages = vec![user("first"), user("second"), user("undo me")];
assert_eq!(saved_history_boundary(&messages, &kept, "undo me"), Some(2));
// "second" never appears: matching "undo me" early would drop a kept turn.
let drifted = vec![user("first"), user("undo me"), user("second")];
assert_eq!(saved_history_boundary(&drifted, &kept, "undo me"), None);
// A repeated prompt stays aligned with the records.
let repeated = vec![user("same"), user("same")];
assert_eq!(
saved_history_boundary(&repeated, &["same".to_string()], "same"),
Some(1)
);
}
/// #6522 review: `/resume`, `/load` and launch warm the canonical sessions
/// root on a blocking thread, so the confinement predicate that follows on
/// the UI runtime is served from the cache instead of resolving a path.