Start new sessions without the previous thread's history

Opening a new thread in Zed showed a [Conversation summary] from an earlier session. The session handlers cleared the on-device engine but not the history a cloud backend keeps for itself, and the startup routing to the cloud tier then carried that history into the backend it installed. A provider override had the same leak. new/load/fork now share begin_conversation, which also strips the active remote backend down to its system prompt before routing. Fixes #107 Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 16, 2026 at 21:26 UTC 3c62e4c35d0cc66067c05c59669e48d6002012cb
2 files changed +65 -30
src/backend.rs
+37
@@ -1200,6 +1200,21 @@ pub fn carryover_history(snapshot: Vec<serde_json::Value>) -> Vec<serde_json::Va
1200 carried
1201 }
1202
1203 +/// Drop everything from `backend`'s history except the system messages it
1204 +/// seeded for itself. Used at a session boundary: a remote backend keeps its own
1205 +/// history, so clearing the on-device engine alone leaves the previous thread
1206 +/// (compaction summary included) live there, and the next model switch or
1207 +/// startup routing carries it into the new session.
1208 +pub async fn clear_conversation(backend: &dyn InferenceBackend) {
1209 + let seeded: Vec<serde_json::Value> = backend
1210 + .history_snapshot()
1211 + .await
1212 + .into_iter()
1213 + .take_while(|message| message["role"] == "system")
1214 + .collect();
1215 + backend.restore_history(seeded).await;
1216 +}
1217 +
1218 /// Replay `carried` (from [`carryover_history`]) into `backend`, on top of the
1219 /// system messages `backend` seeded for itself. Used when a model switch
1220 /// installs a new backend — or reloads the on-device engine, which wipes its
@@ -1478,6 +1493,28 @@ mod tests {
1493 assert_eq!(history[0]["content"], "new prompt");
1494 }
1495
1496 + #[tokio::test]
1497 + async fn clear_conversation_keeps_only_the_seeded_system_prompt() {
1498 + let backend = OpenAiBackend::new("http://localhost", "", "m", Some("prompt".into()));
1499 + adopt_carryover(
1500 + &backend,
1501 + vec![
1502 + serde_json::json!({ "role": "user", "content": "[Conversation summary]\nold" }),
1503 + serde_json::json!({ "role": "assistant", "content": "ok" }),
1504 + ],
1505 + )
1506 + .await;
1507 +
1508 + clear_conversation(&backend).await;
1509 +
1510 + let history = backend.history_snapshot().await;
1511 + assert_eq!(history.len(), 1);
1512 + assert_eq!(history[0]["role"], "system");
1513 + assert_eq!(history[0]["content"], "prompt");
1514 + // Nothing left for a later backend switch to carry over.
1515 + assert!(carryover_history(history).is_empty());
1516 + }
1517 +
1518 #[test]
1519 fn estimate_tokens_scales_with_serialized_size() {
1520 assert_eq!(estimate_tokens(&[]), 0);
src/main.rs
+28 -30
@@ -1440,6 +1440,31 @@ impl SiGitAgent {
1440 additional_roots
1441 }
1442
1443 + /// Start a clean conversation for a session entry point (new/load/fork):
1444 + /// wipe the previous thread from the engine *and* the active backend, seed
1445 + /// the session context, then route inference per the Local Inference mode.
1446 + ///
1447 + /// The backend has to be cleared too. A cloud tier or provider override
1448 + /// keeps its own history, and `switch_to_cloud_tier` carries whatever it
1449 + /// finds there into the backend it installs — so a new thread would open
1450 + /// holding the last one's conversation.
1451 + async fn begin_conversation(&self, cwd: &std::path::Path, additional_roots: &[PathBuf]) {
1452 + self.engine.clear_history().await;
1453 + self.engine
1454 + .push_history(onde::inference::ChatMessage::system(
1455 + session_context_message(cwd, additional_roots),
1456 + ))
1457 + .await;
1458 +
1459 + let backend = self.backend.lock().await.clone();
1460 + if backend.is_remote() {
1461 + backend::clear_conversation(backend.as_ref()).await;
1462 + }
1463 +
1464 + // Honor the persisted Local Inference toggle (off + signed in → cloud).
1465 + self.apply_startup_inference_mode().await;
1466 + }
1467 +
1468 /// Save a session's history plus the sidecar that says where it ran.
1469 ///
1470 /// The sidecar is written here rather than at session start so a thread the
@@ -1527,16 +1552,7 @@ impl SiGitAgent {
1552 permissions::reset_all();
1553
1554 // start from a clean slate; a stored session (below) replaces it
1530 - self.engine.clear_history().await;
1531 -
1532 - self.engine
1533 - .push_history(onde::inference::ChatMessage::system(
1534 - session_context_message(&args.cwd, &additional_roots),
1535 - ))
1536 - .await;
1537 -
1538 - // Honor the persisted Local Inference toggle (off + signed in → cloud).
1539 - self.apply_startup_inference_mode().await;
1555 + self.begin_conversation(&args.cwd, &additional_roots).await;
1556
1557 // Durable sessions: when this session id was saved before, restore its
1558 // history into the active backend. The snapshot includes the system
@@ -1601,16 +1617,7 @@ impl SiGitAgent {
1617 let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1618
1619 // no persistence, so fork == fresh session
1604 - self.engine.clear_history().await;
1605 -
1606 - self.engine
1607 - .push_history(onde::inference::ChatMessage::system(
1608 - session_context_message(&args.cwd, &additional_roots),
1609 - ))
1610 - .await;
1611 -
1612 - // Honor the persisted Local Inference toggle (off + signed in → cloud).
1613 - self.apply_startup_inference_mode().await;
1620 + self.begin_conversation(&args.cwd, &additional_roots).await;
1621
1622 let config_options = {
1623 let guard = self.current_model.lock().unwrap();
@@ -1648,16 +1655,7 @@ impl SiGitAgent {
1655
1656 let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1657
1651 - self.engine.clear_history().await;
1652 -
1653 - self.engine
1654 - .push_history(onde::inference::ChatMessage::system(
1655 - session_context_message(&args.cwd, &additional_roots),
1656 - ))
1657 - .await;
1658 -
1659 - // Honor the persisted Local Inference toggle (off + signed in → cloud).
1660 - self.apply_startup_inference_mode().await;
1658 + self.begin_conversation(&args.cwd, &additional_roots).await;
1659
1660 let config_options = {
1661 let guard = self.current_model.lock().unwrap();