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(¤t, &session_id.to_string())
3613
+ build_config_options(¤t, &session_key)
3614
};
3615
agent
3616
.send_tool_call_update(