fix(api): keep a saved conversation on the id its workspace snapshots are tagged with

Undo and the file-revert endpoint both select workspace snapshots by the
thread's `session_id`, but the engine tags every `tool:` / `pre-turn:`
snapshot with its own live conversation id — and a save that named no session
minted a third uuid for the document it wrote. The thread was then bound to a
session that owned none of its snapshots: Undo forked the conversation and
left every file on disk untouched, and the Changes panel's Revert refused a
snapshot that existed.

`PUT /v1/sessions` now persists the conversation's own id when the caller
names none (`SessionSnapshot` publishes it), so the document, the thread
binding and the snapshots agree by construction. A repeated save writes that
one document instead of collecting another one per visit.

Measured before the change on engine 0.10.0: in one workspace every thread's
`session_id` was disjoint from the set of ids that owned snapshots, and none
of the snapshot-owning ids had a session document. The regression test fails
without this fix.

Signed-off-by: Ben Gao <[email protected]>
This commit is contained in:
Ben Gao committed 2026-09-24 09:45:44 +08:00
1 parent 6c8f242fa8
commit 43771182e9
4 files changed
+160 -1

No files matched your search

+1
View File
@@ -3269,6 +3269,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
@@ -5648,6 +5648,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(