mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
1 parent
6c8f242fa8
commit
43771182e9
4 files changed
+160
-1
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user