Preserve unloaded local session history

Keep restored on-device history pending until Onde can accept it, and make /clear update only its known active session state. Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 17, 2026 at 08:34 UTC 3715fe8aeca24dc0501b57ee7adde11c2b8d191e
2 files changed +98 -33
src/backend.rs
+77 -21
@@ -21,7 +21,7 @@
21 use std::sync::Arc;
22
23 use async_trait::async_trait;
24 -use onde::inference::{ChatEngine, ChatMessage, ChatRole, ToolDefinition};
24 +use onde::inference::{ChatEngine, ChatMessage, ChatRole, EngineStatus, ToolDefinition};
25 use serde::Deserialize;
26 use tokio::sync::Mutex;
27
@@ -207,11 +207,47 @@ pub trait InferenceBackend: Send + Sync {
207 /// On-device inference. A thin adapter over `onde::ChatEngine`.
208 pub struct LocalBackend {
209 engine: Arc<ChatEngine>,
210 + /// History restored before an on-device model is loaded. Onde ignores
211 + /// `push_history` while unloaded, so keep the snapshot here until the
212 + /// engine can accept it instead of silently dropping a loaded session.
213 + pending_history: Mutex<Option<Vec<serde_json::Value>>>,
214 }
215
216 impl LocalBackend {
217 pub fn new(engine: Arc<ChatEngine>) -> Self {
214 - Self { engine }
218 + Self {
219 + engine,
220 + pending_history: Mutex::new(None),
221 + }
222 + }
223 +
224 + async fn apply_pending_history(&self) {
225 + if self.engine.info().await.status != EngineStatus::Ready {
226 + return;
227 + }
228 + let Some(history) = self.pending_history.lock().await.take() else {
229 + return;
230 + };
231 +
232 + self.engine.clear_history().await;
233 + for entry in history {
234 + let role = entry["role"].as_str().unwrap_or("");
235 + let content = entry["content"].as_str().unwrap_or("").to_string();
236 + // Tool-call-only assistant entries and empty tool results carry no
237 + // text a plain chat history can replay; drop them.
238 + if content.is_empty() && role != "user" && role != "system" {
239 + continue;
240 + }
241 + let message = match role {
242 + "system" => ChatMessage::system(content),
243 + "user" => ChatMessage::user(content),
244 + "assistant" => ChatMessage::assistant(content),
245 + // Tool results flatten to plain text (MVP; acceptable loss).
246 + "tool" => ChatMessage::user(format!("[tool result]\n{content}")),
247 + _ => continue,
248 + };
249 + self.engine.push_history(message).await;
250 + }
251 }
252 }
253
@@ -234,6 +270,7 @@ impl InferenceBackend for LocalBackend {
270 tools: &[ToolSpec],
271 sink: Option<&TokenSink>,
272 ) -> Result<TurnResult, BackendError> {
273 + self.apply_pending_history().await;
274 // onde's tool-aware path is non-streaming: it has to buffer the whole
275 // reply to detect tool calls. We can only stream when no tools are on
276 // offer (a plain answer), which is exactly the tools-disabled case.
@@ -264,6 +301,7 @@ impl InferenceBackend for LocalBackend {
301 allow_tool_calls: bool,
302 sink: Option<&TokenSink>,
303 ) -> Result<TurnResult, BackendError> {
304 + self.apply_pending_history().await;
305 let onde_results: Vec<onde::inference::ToolResult> = results
306 .into_iter()
307 .map(|result| onde::inference::ToolResult {
@@ -306,6 +344,9 @@ impl InferenceBackend for LocalBackend {
344 }
345
346 async fn history_snapshot(&self) -> Vec<serde_json::Value> {
347 + if let Some(history) = self.pending_history.lock().await.as_ref() {
348 + return history.clone();
349 + }
350 // onde's `history()` already flattens tool entries: assistant tool
351 // calls become plain assistant text and tool results are omitted, so
352 // the snapshot is lossy for tool-heavy turns (acceptable in this MVP).
@@ -323,28 +364,12 @@ impl InferenceBackend for LocalBackend {
364 }
365
366 async fn restore_history(&self, history: Vec<serde_json::Value>) {
326 - self.engine.clear_history().await;
327 - for entry in history {
328 - let role = entry["role"].as_str().unwrap_or("");
329 - let content = entry["content"].as_str().unwrap_or("").to_string();
330 - // Tool-call-only assistant entries and empty tool results carry no
331 - // text a plain chat history can replay; drop them.
332 - if content.is_empty() && role != "user" && role != "system" {
333 - continue;
334 - }
335 - let message = match role {
336 - "system" => ChatMessage::system(content),
337 - "user" => ChatMessage::user(content),
338 - "assistant" => ChatMessage::assistant(content),
339 - // Tool results flatten to plain text (MVP; acceptable loss).
340 - "tool" => ChatMessage::user(format!("[tool result]\n{content}")),
341 - _ => continue,
342 - };
343 - self.engine.push_history(message).await;
344 - }
367 + *self.pending_history.lock().await = Some(history);
368 + self.apply_pending_history().await;
369 }
370
371 async fn compact_history(&self, keep_last: usize) -> Result<(), BackendError> {
372 + self.apply_pending_history().await;
373 let snapshot = self.engine.history().await;
374 // One plain (tool-free) inference round produces the summary. On error
375 // history is untouched — send_message only mutates it on success, and
@@ -1559,6 +1584,37 @@ mod tests {
1584 assert_eq!(restored.history_snapshot().await, snapshot);
1585 }
1586
1587 + #[tokio::test]
1588 + async fn local_restore_replaces_engine_history_and_system_context() {
1589 + let engine = Arc::new(ChatEngine::new());
1590 + let backend = LocalBackend::new(Arc::clone(&engine));
1591 + backend
1592 + .restore_history(vec![
1593 + serde_json::json!({ "role": "system", "content": "thread A context" }),
1594 + serde_json::json!({ "role": "user", "content": "thread A question" }),
1595 + ])
1596 + .await;
1597 +
1598 + backend
1599 + .restore_history(vec![
1600 + serde_json::json!({ "role": "system", "content": "thread B context" }),
1601 + serde_json::json!({ "role": "user", "content": "thread B question" }),
1602 + ])
1603 + .await;
1604 +
1605 + let restored = backend.history_snapshot().await;
1606 + assert_eq!(restored.len(), 2, "{restored:#?}");
1607 + assert_eq!(restored[0]["content"], "thread B context");
1608 + assert_eq!(restored[1]["content"], "thread B question");
1609 + assert!(
1610 + restored.iter().all(|message| !message["content"]
1611 + .as_str()
1612 + .unwrap_or_default()
1613 + .contains("thread A")),
1614 + "restore_history must replace, not append to, the local engine: {restored:#?}"
1615 + );
1616 + }
1617 +
1618 /// Minimal scripted OpenAI-compatible endpoint: accepts one HTTP request on
1619 /// a std listener and answers with a fixed non-streaming completion. The
1620 /// receiver yields the request body the backend actually put on the wire.
src/main.rs
+21 -12
@@ -1535,7 +1535,9 @@ impl SiGitAgent {
1535 /// Used by the session entry points (new/load/fork). The outgoing session
1536 /// is parked first, and the Local Inference routing runs last, so a cloud
1537 /// backend installed here starts from this session's conversation and
1538 - /// system prompt and nobody else's.
1538 + /// system prompt and nobody else's. Reopening an existing id deliberately
1539 + /// replaces its parked state (notably `session/load`); every caller holds
1540 + /// `turn_lock`, so parking and replacement cannot race another turn.
1541 async fn open_session(&self, session_id: &SessionId, state: SessionState) {
1542 let key = session_id.to_string();
1543 self.park_active_session().await;
@@ -3583,25 +3585,32 @@ async fn exec_slash_acp(
3585 .iter()
3586 .filter(|message| message["role"] == "user")
3587 .count();
3588 + let session_key = session_id.to_string();
3589 + debug_assert_eq!(
3590 + agent.active_session.lock().unwrap().as_deref(),
3591 + Some(session_key.as_str()),
3592 + "slash commands run only after handle_prompt activates their session"
3593 + );
3594 let (cwd, roots) = {
3587 - let sessions = agent.sessions.lock().unwrap();
3588 - match sessions.get(&session_id.to_string()) {
3589 - Some(state) => (state.cwd.clone(), state.additional_roots.clone()),
3590 - None => (
3591 - std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
3592 - workspace::additional_roots(),
3593 - ),
3594 - }
3595 + let mut sessions = agent.sessions.lock().unwrap();
3596 + let state = sessions.get_mut(&session_key).ok_or_else(|| {
3597 + agent_client_protocol::Error::new(
3598 + -32602,
3599 + format!("cannot clear unknown session {session_key}"),
3600 + )
3601 + })?;
3602 + state.conversation.clear();
3603 + (state.cwd.clone(), state.additional_roots.clone())
3604 };
3605 agent.seed_conversation(&cwd, &roots, Vec::new()).await;
3597 - permissions::reset_session(&session_id.to_string());
3606 + permissions::reset_session(&session_key);
3607 // The saved session must not resurrect what the user just wiped.
3599 - session_store::delete(&session_id.to_string());
3608 + session_store::delete(&session_key);
3609 // The wipe drops the session's Permissions override too; push the
3610 // refreshed chip so it doesn't keep showing a stale pick.
3611 let config_options = {
3612 let current = agent.current_model.lock().unwrap();
3604 - build_config_options(&current, &session_id.to_string())
3613 + build_config_options(&current, &session_key)
3614 };
3615 agent
3616 .send_tool_call_update(