Harden ACP multi-session interoperability

Keep model routing scoped to each ACP session, reject unknown session ids instead of borrowing live state, and let cancellation interrupt inference without corrupting remote tool history. Persist model selections alongside saved sessions and cover the session/new cloud-routing regression. Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 17, 2026 at 08:23 UTC 2e91587c3c196ec53c84b36176a61ec6264d7c52
4 files changed +429 -119
.agents/AGENTS.md
+7 -3
@@ -111,9 +111,13 @@ The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goe
111 feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
112
113 - **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
114 - new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`
115 - (note: it bakes in smbCloud-specific context the agent should use when the repo is clearly
116 - smbCloud, and stay general otherwise).
114 + new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`.
115 + ACP session state owns its roots, conversation, and selected model even though the process has
116 + one live backend; activating a thread parks and restores all three. Unknown session ids are
117 + rejected instead of silently borrowing the active thread's cwd. Prompt cancellation is routed
118 + outside `turn_lock`, which lets a client cancel the turn currently holding that lock. The
119 + `SYSTEM_PROMPT` bakes in smbCloud-specific context the agent should use when the repo is clearly
120 + smbCloud, and stay general otherwise.
121 - **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
122 `ToolResult`, `TurnResult`). Two impls: `LocalBackend` (on-device via `onde::ChatEngine`) and
123 `OpenAiBackend` (any OpenAI-compatible HTTP endpoint). A `BackendError` is user-facing: the
src/backend.rs
+34
@@ -883,6 +883,24 @@ impl InferenceBackend for OpenAiBackend {
883 async fn record_cancelled_tool_results(&self, results: Vec<ToolResult>) {
884 let mut history = self.history.lock().await;
885 for result in results {
886 + // Cancellation races the in-flight continuation request. That
887 + // request appends its tool results before awaiting HTTP, so cleanup
888 + // must be safe whether cancellation won just before or just after
889 + // the append.
890 + let matching_call = history.iter().rposition(|message| {
891 + message["role"] == "assistant"
892 + && message["tool_calls"].as_array().is_some_and(|calls| {
893 + calls.iter().any(|call| call["id"] == result.tool_call_id)
894 + })
895 + });
896 + let already_recorded = matching_call.is_some_and(|call_index| {
897 + history[call_index + 1..].iter().any(|message| {
898 + message["role"] == "tool" && message["tool_call_id"] == result.tool_call_id
899 + })
900 + });
901 + if already_recorded {
902 + continue;
903 + }
904 history.push(serde_json::json!({
905 "role": "tool",
906 "tool_call_id": result.tool_call_id,
@@ -1357,6 +1375,22 @@ mod tests {
1375 assert_eq!(last["role"], "tool");
1376 assert_eq!(last["tool_call_id"], "call_9");
1377 assert_eq!(last["content"], "cancelled by the user");
1378 +
1379 + drop(history);
1380 + backend
1381 + .record_cancelled_tool_results(vec![ToolResult {
1382 + tool_call_id: "call_9".to_string(),
1383 + content: "duplicate cleanup".to_string(),
1384 + }])
1385 + .await;
1386 + let history = backend.history.lock().await;
1387 + assert_eq!(
1388 + history
1389 + .iter()
1390 + .filter(|message| message["tool_call_id"] == "call_9")
1391 + .count(),
1392 + 1
1393 + );
1394 }
1395
1396 #[test]
src/main.rs
+361 -112
@@ -323,6 +323,17 @@ impl StreamedReply {
323 }
324 }
325
326 +#[derive(Default)]
327 +struct PromptCancellation {
328 + cancelled: AtomicBool,
329 + notify: tokio::sync::Notify,
330 +}
331 +
332 +enum DrainTurnError {
333 + Backend(backend::BackendError),
334 + Cancelled,
335 +}
336 +
337 /// Outcome of asking the client for permission to run one tool call.
338 enum PermissionVerdict {
339 /// Run the tool.
@@ -760,10 +771,14 @@ fn initialize_meta() -> Meta {
771 /// serialized by `turn_lock`), so each session's roots and conversation are
772 /// parked here while another session holds the process, and swapped back in
773 /// by `activate_session` before its next request runs.
763 -#[derive(Clone, Debug, Default)]
774 +#[derive(Clone, Debug)]
775 struct SessionState {
776 cwd: PathBuf,
777 additional_roots: Vec<PathBuf>,
778 + /// The model/backend selection that belongs to this session. ACP config
779 + /// options are session-scoped, so switching threads must restore it along
780 + /// with the conversation rather than leaving the last thread's model live.
781 + model_id: String,
782 /// The conversation without its system messages (see
783 /// `backend::carryover_history`). Those are rebuilt from the roots each
784 /// time the session is installed, so they always name this session's
@@ -784,6 +799,11 @@ struct SiGitAgent {
799 sessions: std::sync::Mutex<std::collections::HashMap<String, SessionState>>,
800 /// The session whose roots and conversation are installed right now.
801 active_session: std::sync::Mutex<Option<String>>,
802 + /// Cancellation signals for prompts currently waiting on inference. The
803 + /// notification handler runs outside `turn_lock`, so it can interrupt the
804 + /// turn that currently owns that lock and let another session proceed.
805 + prompt_cancellations:
806 + std::sync::Mutex<std::collections::HashMap<String, Arc<PromptCancellation>>>,
807 current_model: std::sync::Mutex<GgufModelConfig>,
808 /// flipped once the startup model finishes (success or failure)
809 model_ready: Arc<AtomicBool>,
@@ -827,6 +847,7 @@ impl SiGitAgent {
847 session_cwd: std::sync::Mutex::new(None),
848 sessions: std::sync::Mutex::new(std::collections::HashMap::new()),
849 active_session: std::sync::Mutex::new(None),
850 + prompt_cancellations: std::sync::Mutex::new(std::collections::HashMap::new()),
851 current_model: std::sync::Mutex::new(initial_model),
852 model_ready,
853 startup_model_load_started,
@@ -1117,14 +1138,24 @@ impl SiGitAgent {
1138 fut: F,
1139 sink_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
1140 reply: &mut StreamedReply,
1120 - ) -> Result<TurnResult, backend::BackendError>
1141 + cancellation: &PromptCancellation,
1142 + ) -> Result<TurnResult, DrainTurnError>
1143 where
1144 F: std::future::Future<Output = Result<TurnResult, backend::BackendError>>,
1145 {
1146 + if cancellation.cancelled.load(Ordering::Acquire) {
1147 + return Err(DrainTurnError::Cancelled);
1148 + }
1149 +
1150 tokio::pin!(fut);
1151 let result = loop {
1152 tokio::select! {
1127 - done = &mut fut => break done,
1153 + done = &mut fut => break done.map_err(DrainTurnError::Backend),
1154 + _ = cancellation.notify.notified() => {
1155 + if cancellation.cancelled.load(Ordering::Acquire) {
1156 + break Err(DrainTurnError::Cancelled);
1157 + }
1158 + }
1159 Some(piece) = sink_rx.recv() => {
1160 self.emit_visible_chunk(cx, session_id, &piece, reply);
1161 }
@@ -1345,6 +1376,7 @@ impl SiGitAgent {
1376 let mut guard = self.current_model.lock().unwrap();
1377 *guard = new_config.clone();
1378 }
1379 + self.record_active_model();
1380
1381 if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
1382 self.engine
@@ -1457,14 +1489,34 @@ impl SiGitAgent {
1489
1490 /// Build the state for a session request: its primary `cwd` plus the extra
1491 /// roots worth keeping (see `workspace::set_additional_roots`).
1460 - fn session_state(cwd: &std::path::Path, additional: &[PathBuf]) -> SessionState {
1492 + fn session_state(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> SessionState {
1493 + let current_model = self.current_model.lock().unwrap().model_id.clone();
1494 + let model_id =
1495 + if !settings::local_inference_enabled() && provider::active_provider().is_none() {
1496 + format!("sigit-cloud:{}", provider::DEFAULT_CLOUD_TIER)
1497 + } else {
1498 + current_model
1499 + };
1500 SessionState {
1501 cwd: cwd.to_path_buf(),
1502 additional_roots: workspace::filter_additional_roots(cwd, additional),
1503 + model_id,
1504 conversation: Vec::new(),
1505 }
1506 }
1507
1508 + /// Keep the active session's parked metadata in step with a successful
1509 + /// model/backend switch. Its conversation remains stale while active.
1510 + fn record_active_model(&self) {
1511 + let Some(active) = self.active_session.lock().unwrap().clone() else {
1512 + return;
1513 + };
1514 + let model_id = self.current_model.lock().unwrap().model_id.clone();
1515 + if let Some(state) = self.sessions.lock().unwrap().get_mut(&active) {
1516 + state.model_id = model_id;
1517 + }
1518 + }
1519 +
1520 /// Move the live conversation into the state of the session that owns it,
1521 /// so installing another session can't lose or leak it.
1522 async fn park_active_session(&self) {
@@ -1474,6 +1526,7 @@ impl SiGitAgent {
1526 let snapshot = self.backend.lock().await.history_snapshot().await;
1527 if let Some(state) = self.sessions.lock().unwrap().get_mut(&active) {
1528 state.conversation = backend::carryover_history(snapshot);
1529 + state.model_id = self.current_model.lock().unwrap().model_id.clone();
1530 }
1531 }
1532
@@ -1493,7 +1546,7 @@ impl SiGitAgent {
1546 self.install_session(&key, state).await;
1547
1548 // Honor the persisted Local Inference toggle (off + signed in → cloud).
1496 - self.apply_startup_inference_mode().await;
1549 + self.restore_session_model(&key).await;
1550 }
1551
1552 /// Make `session_id` the live session before one of its requests runs.
@@ -1502,23 +1555,19 @@ impl SiGitAgent {
1555 /// this one's roots and conversation are installed in its place. An id this
1556 /// process has never seen (the editor kept a thread open across a sigit
1557 /// restart) is rebuilt from the session store when it can be.
1505 - async fn activate_session(&self, session_id: &SessionId) {
1558 + async fn activate_session(&self, session_id: &SessionId) -> agent_client_protocol::Result<()> {
1559 let key = session_id.to_string();
1560 if self.active_session.lock().unwrap().as_deref() == Some(key.as_str()) {
1508 - return;
1561 + return Ok(());
1562 }
1563
1511 - let known = self.sessions.lock().unwrap().get(&key).cloned();
1512 - let state = match known {
1513 - Some(state) => state,
1514 - None => {
1515 - let state = self.recover_session(&key);
1516 - self.sessions
1517 - .lock()
1518 - .unwrap()
1519 - .insert(key.clone(), state.clone());
1520 - state
1521 - }
1564 + let Some(state) = self.sessions.lock().unwrap().get(&key).cloned() else {
1565 + return Err(agent_client_protocol::Error::new(
1566 + -32602,
1567 + format!(
1568 + "unknown session {key}; create it with session/new or restore it with session/load"
1569 + ),
1570 + ));
1571 };
1572
1573 log::info!(
@@ -1529,34 +1578,8 @@ impl SiGitAgent {
1578 );
1579 self.park_active_session().await;
1580 self.install_session(&key, state).await;
1532 - }
1533 -
1534 - /// State for a session id this process didn't open: the saved conversation
1535 - /// and roots when the store has them, otherwise an empty conversation in the
1536 - /// current directory. Never the live session's conversation.
1537 - fn recover_session(&self, key: &str) -> SessionState {
1538 - let entry = session_store::list()
1539 - .into_iter()
1540 - .find(|entry| entry.id == key);
1541 - let cwd = entry
1542 - .as_ref()
1543 - .and_then(|entry| entry.cwd.clone())
1544 - .or_else(|| self.session_cwd.lock().ok().and_then(|guard| guard.clone()))
1545 - .or_else(|| std::env::current_dir().ok())
1546 - .unwrap_or_else(|| PathBuf::from("."));
1547 - let additional = entry
1548 - .map(|entry| entry.additional_directories)
1549 - .unwrap_or_default();
1550 - let mut state = Self::session_state(&cwd, &additional);
1551 - state.conversation = session_store::load(key)
1552 - .map(backend::carryover_history)
1553 - .unwrap_or_default();
1554 - log::warn!(
1555 - "session({key}) was not opened by this process; recovered {} message(s) in {}",
1556 - state.conversation.len(),
1557 - cwd.display()
1558 - );
1559 - state
1581 + self.restore_session_model(&key).await;
1582 + Ok(())
1583 }
1584
1585 /// Point the process at `state`'s roots and load its conversation.
@@ -1569,6 +1592,7 @@ impl SiGitAgent {
1592 let SessionState {
1593 cwd,
1594 additional_roots,
1595 + model_id: _,
1596 conversation,
1597 } = state;
1598
@@ -1588,6 +1612,52 @@ impl SiGitAgent {
1612 .await;
1613 }
1614
1615 + /// Restore the active session's model/backend after its conversation has
1616 + /// been installed on the currently live backend. Model switches carry that
1617 + /// freshly installed conversation across to the target backend.
1618 + async fn restore_session_model(&self, key: &str) {
1619 + // Explicit OpenAI-compatible provider overrides are process-wide and
1620 + // intentionally take precedence over the editor's model picker.
1621 + if provider::active_provider().is_some() {
1622 + return;
1623 + }
1624 +
1625 + let Some(target) = self
1626 + .sessions
1627 + .lock()
1628 + .unwrap()
1629 + .get(key)
1630 + .map(|state| state.model_id.clone())
1631 + else {
1632 + return;
1633 + };
1634 + let current = self.current_model.lock().unwrap().model_id.clone();
1635 + if current == target {
1636 + return;
1637 + }
1638 +
1639 + if let Some(tier) = target.strip_prefix("sigit-cloud:") {
1640 + if self.switch_to_cloud_tier(tier).await.is_none() {
1641 + log::warn!(
1642 + "session({key}): cannot restore cloud model without a signed-in account"
1643 + );
1644 + self.record_active_model();
1645 + }
1646 + return;
1647 + }
1648 +
1649 + match self.switch_model_by_id(&target).await {
1650 + Ok(_) => {
1651 + self.reset_to_local_backend().await;
1652 + let _ = settings::set_local_inference(true);
1653 + }
1654 + Err(error) => {
1655 + log::warn!("session({key}): could not restore model {target}: {error}");
1656 + self.record_active_model();
1657 + }
1658 + }
1659 + }
1660 +
1661 /// Replace the conversation on the engine and the active backend with
1662 /// `conversation`, under system context naming `cwd` and `additional_roots`.
1663 ///
@@ -1630,13 +1700,21 @@ impl SiGitAgent {
1700 /// wrong project is worse than one that isn't listed at all.
1701 async fn persist_session(&self, session_id: &SessionId, snapshot: &[serde_json::Value]) {
1702 let key = session_id.to_string();
1703 + if self.active_session.lock().unwrap().as_deref() == Some(key.as_str()) {
1704 + self.record_active_model();
1705 + }
1706 if let Err(error) = session_store::save(&key, snapshot) {
1707 log::warn!("session({session_id}) save failed: {error}");
1708 return;
1709 }
1710 let state = self.sessions.lock().unwrap().get(&key).cloned();
1711 match state {
1639 - Some(state) => session_store::save_meta(&key, &state.cwd, &state.additional_roots),
1712 + Some(state) => session_store::save_meta(
1713 + &key,
1714 + &state.cwd,
1715 + &state.additional_roots,
1716 + Some(&state.model_id),
1717 + ),
1718 None => log::warn!(
1719 "session({session_id}) has no recorded cwd — saved, but it won't be listed"
1720 ),
@@ -1700,17 +1778,25 @@ impl SiGitAgent {
1778 .collect::<Vec<_>>()
1779 );
1780
1703 - let mut state = Self::session_state(&args.cwd, &args.additional_directories);
1781 + let session_key = args.session_id.to_string();
1782 + let mut state = self.session_state(&args.cwd, &args.additional_directories);
1783 + if let Some(model_id) = session_store::list()
1784 + .into_iter()
1785 + .find(|entry| entry.id == session_key)
1786 + .and_then(|entry| entry.model_id)
1787 + {
1788 + state.model_id = model_id;
1789 + }
1790
1791 // A session boundary: grants and plan mode from the previous life of
1792 // this session id must not carry over. Other sessions keep theirs; the
1793 // editor may still have them open.
1708 - permissions::reset_session(&args.session_id.to_string());
1794 + permissions::reset_session(&session_key);
1795
1796 // Durable sessions: when this session id was saved before, restore its
1797 // conversation. The saved system messages are dropped; they are rebuilt
1798 // from the roots the editor sent now, which may differ from last time.
1713 - if let Some(history) = session_store::load(&args.session_id.to_string()) {
1799 + if let Some(history) = session_store::load(&session_key) {
1800 let restored = history.len();
1801
1802 // The client draws the reopened thread from these notifications
@@ -1768,25 +1854,31 @@ impl SiGitAgent {
1854 // new id has none. The source's conversation is current only after
1855 // parking when the source is the live session.
1856 self.park_active_session().await;
1771 - let source = self
1772 - .sessions
1773 - .lock()
1774 - .unwrap()
1775 - .get(&args.session_id.to_string())
1776 - .map(|source| source.conversation.clone())
1777 - .or_else(|| {
1778 - session_store::load(&args.session_id.to_string()).map(backend::carryover_history)
1779 - })
1780 - .unwrap_or_default();
1781 - let copied = source.len();
1782 - let updates = history_replay_updates(&source);
1857 + let source_key = args.session_id.to_string();
1858 + let source_state = self.sessions.lock().unwrap().get(&source_key).cloned();
1859 + let mut state = self.session_state(&args.cwd, &args.additional_directories);
1860 + if let Some(source) = source_state {
1861 + state.conversation = source.conversation;
1862 + state.model_id = source.model_id;
1863 + } else {
1864 + state.conversation = session_store::load(&source_key)
1865 + .map(backend::carryover_history)
1866 + .unwrap_or_default();
1867 + if let Some(model_id) = session_store::list()
1868 + .into_iter()
1869 + .find(|entry| entry.id == source_key)
1870 + .and_then(|entry| entry.model_id)
1871 + {
1872 + state.model_id = model_id;
1873 + }
1874 + }
1875 + let copied = state.conversation.len();
1876 + let updates = history_replay_updates(&state.conversation);
1877 let replayed = updates.len();
1878 for update in updates {
1879 cx.send_notification(SessionNotification::new(new_id.clone(), update))
1880 .ok();
1881 }
1788 - let mut state = Self::session_state(&args.cwd, &args.additional_directories);
1789 - state.conversation = source;
1882 self.open_session(&new_id, state).await;
1883 log::info!(
1884 "fork_session: copied {copied} message(s), replayed {replayed} update(s) for {new_id}"
@@ -1823,7 +1915,7 @@ impl SiGitAgent {
1915 .collect::<Vec<_>>()
1916 );
1917
1826 - let state = Self::session_state(&args.cwd, &args.additional_directories);
1918 + let state = self.session_state(&args.cwd, &args.additional_directories);
1919 self.open_session(&session_id, state).await;
1920
1921 let config_options = {
@@ -1851,7 +1943,7 @@ impl SiGitAgent {
1943
1944 // Everything below (slash commands included) acts on the live session,
1945 // so it has to be this one. Resource links are read relative to its cwd.
1854 - self.activate_session(&session_id).await;
1946 + self.activate_session(&session_id).await?;
1947
1948 // log every block so we can debug @ references and file context
1949 for (i, block) in args.prompt.iter().enumerate() {
@@ -2058,6 +2150,12 @@ impl SiGitAgent {
2150 }
2151 }
2152
2153 + let cancellation = Arc::new(PromptCancellation::default());
2154 + self.prompt_cancellations
2155 + .lock()
2156 + .unwrap()
2157 + .insert(session_id.to_string(), Arc::clone(&cancellation));
2158 +
2159 // ── tool-calling loop ────────────────────────────────────────────
2160 // send message → execute any tool calls → feed results back
2161 // repeat up to MAX_TOOL_ROUNDS, then force a text reply
@@ -2073,27 +2171,40 @@ impl SiGitAgent {
2171 let mut reply = StreamedReply::default();
2172 let mut repeated_tool_calls = std::collections::HashMap::<String, usize>::new();
2173
2076 - let mut result = self
2174 + let mut result = match self
2175 .drain_turn(
2176 cx,
2177 &session_id,
2178 backend.send_message_with_tools(&user_text, &tools, Some(&sink)),
2179 &mut sink_rx,
2180 &mut reply,
2181 + &cancellation,
2182 )
2183 .await
2085 - .map_err(|error| {
2184 + {
2185 + Ok(result) => result,
2186 + Err(DrainTurnError::Cancelled) => {
2187 + self.finish_prompt(&session_id, &cancellation);
2188 + return Ok(PromptResponse::new(StopReason::Cancelled));
2189 + }
2190 + Err(DrainTurnError::Backend(error)) => {
2191 log::error!("send_message_with_tools failed: {error}");
2192 // Verbatim, no prefix: the backend has already turned this into
2193 // something worth reading (see `describe_api_error`), and it is
2194 // what the editor puts in its error banner. The context a
2195 // prefix would add is in the log line above.
2091 - agent_client_protocol::Error::new(-32603, error)
2092 - })?;
2196 + self.finish_prompt(&session_id, &cancellation);
2197 + return Err(agent_client_protocol::Error::new(-32603, error));
2198 + }
2199 + };
2200
2201 let mut round = 0;
2202
2203 while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
2204 + if cancellation.cancelled.load(Ordering::Acquire) {
2205 + self.finish_prompt(&session_id, &cancellation);
2206 + return Ok(PromptResponse::new(StopReason::Cancelled));
2207 + }
2208 round += 1;
2209 log::info!(
2210 "prompt({}) tool round {} — {} call(s)",
@@ -2121,13 +2232,14 @@ impl SiGitAgent {
2232 log::warn!("prompt({}) compaction failed: {error}", session_id);
2233 self.send_assistant_message(
2234 cx,
2124 - session_id,
2235 + session_id.clone(),
2236 format!(
2237 "This session is too large, and siGit Code could not compact it: \
2238 {error}. Start a new thread or run `/clear`, then retry."
2239 ),
2240 )
2241 .ok();
2242 + self.finish_prompt(&session_id, &cancellation);
2243 return Ok(PromptResponse::new(StopReason::EndTurn));
2244 }
2245 }
@@ -2271,6 +2383,7 @@ impl SiGitAgent {
2383 )
2384 .ok();
2385 backend.record_cancelled_tool_results(tool_results).await;
2386 + self.finish_prompt(&session_id, &cancellation);
2387 return Ok(PromptResponse::new(StopReason::Cancelled));
2388 }
2389 }
@@ -2299,6 +2412,20 @@ impl SiGitAgent {
2412 tool_call_id: tc.id.clone(),
2413 content: output,
2414 });
2415 + if cancellation.cancelled.load(Ordering::Acquire) {
2416 + for pending in &result.tool_calls[call_index + 1..] {
2417 + tool_results.push(BackendToolResult {
2418 + tool_call_id: pending.id.clone(),
2419 + content: format!(
2420 + "`{}` was not executed: the user cancelled the turn.",
2421 + pending.name
2422 + ),
2423 + });
2424 + }
2425 + backend.record_cancelled_tool_results(tool_results).await;
2426 + self.finish_prompt(&session_id, &cancellation);
2427 + return Ok(PromptResponse::new(StopReason::Cancelled));
2428 + }
2429 }
2430
2431 let allow_tool_calls = round < MAX_TOOL_ROUNDS && !force_text;
@@ -2307,19 +2434,32 @@ impl SiGitAgent {
2434 // continuing the sentence the tool calls interrupted.
2435 reply.interrupt();
2436
2310 - result = self
2437 + let cancelled_results = tool_results.clone();
2438 + result = match self
2439 .drain_turn(
2440 cx,
2441 &session_id,
2442 backend.send_tool_results(tool_results, &tools, allow_tool_calls, Some(&sink)),
2443 &mut sink_rx,
2444 &mut reply,
2445 + &cancellation,
2446 )
2447 .await
2319 - .map_err(|error| {
2448 + {
2449 + Ok(result) => result,
2450 + Err(DrainTurnError::Cancelled) => {
2451 + backend
2452 + .record_cancelled_tool_results(cancelled_results)
2453 + .await;
2454 + self.finish_prompt(&session_id, &cancellation);
2455 + return Ok(PromptResponse::new(StopReason::Cancelled));
2456 + }
2457 + Err(DrainTurnError::Backend(error)) => {
2458 log::error!("send_tool_results failed: {error}");
2321 - agent_client_protocol::Error::new(-32603, error)
2322 - })?;
2459 + self.finish_prompt(&session_id, &cancellation);
2460 + return Err(agent_client_protocol::Error::new(-32603, error));
2461 + }
2462 + };
2463 }
2464
2465 // ── Final text response ───────────────────────────────────────────
@@ -2359,6 +2499,7 @@ impl SiGitAgent {
2499 // the conversation back up.
2500 let snapshot = backend.history_snapshot().await;
2501 self.persist_session(&session_id, &snapshot).await;
2502 + self.finish_prompt(&session_id, &cancellation);
2503
2504 log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
2505 Ok(PromptResponse::new(StopReason::EndTurn))
@@ -2445,8 +2586,29 @@ impl SiGitAgent {
2586 }
2587 }
2588
2589 + fn finish_prompt(&self, session_id: &SessionId, cancellation: &Arc<PromptCancellation>) {
2590 + let key = session_id.to_string();
2591 + let mut active = self.prompt_cancellations.lock().unwrap();
2592 + if active
2593 + .get(&key)
2594 + .is_some_and(|registered| Arc::ptr_eq(registered, cancellation))
2595 + {
2596 + active.remove(&key);
2597 + }
2598 + }
2599 +
2600 async fn handle_cancel(&self, args: CancelNotification) -> agent_client_protocol::Result<()> {
2601 log::info!("cancel requested for session {}", args.session_id);
2602 + if let Some(cancellation) = self
2603 + .prompt_cancellations
2604 + .lock()
2605 + .unwrap()
2606 + .get(&args.session_id.to_string())
2607 + .cloned()
2608 + {
2609 + cancellation.cancelled.store(true, Ordering::Release);
2610 + cancellation.notify.notify_one();
2611 + }
2612 Ok(())
2613 }
2614
@@ -2456,6 +2618,22 @@ impl SiGitAgent {
2618 /// for login). Shared by the panel picker and the `/models` slash command.
2619 async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
2620 let cfg = crate::provider::cloud_tier_provider(tier)?;
2621 + let display_name = cfg.display_name.clone();
2622 + self.install_remote_backend(cfg, format!("sigit-cloud:{tier}"))
2623 + .await;
2624 +
2625 + // Explicitly choosing a cloud tier puts us in cloud mode.
2626 + let _ = settings::set_local_inference(false);
2627 +
2628 + log::info!("switched to cloud tier {tier}");
2629 + Some(display_name)
2630 + }
2631 +
2632 + /// Install a remote provider under `selection_id`, carrying only the live
2633 + /// session's non-system conversation. Kept separate from cloud credential
2634 + /// lookup so the session-boundary regression can be tested without an
2635 + /// account or network request.
2636 + async fn install_remote_backend(&self, cfg: provider::ProviderConfig, selection_id: String) {
2637 // Mirror the cwd guidance and project instruction files the local engine
2638 // gets at session load, so the cloud model shares the same project context.
2639 let system_prompt = match self.session_cwd.lock().ok().and_then(|g| g.clone()) {
@@ -2477,8 +2655,8 @@ impl SiGitAgent {
2655 backend::adopt_carryover(cloud_backend.as_ref(), carried).await;
2656 *self.backend.lock().await = cloud_backend;
2657
2480 - let cloud_config = GgufModelConfig {
2481 - model_id: format!("sigit-cloud:{tier}"),
2658 + let remote_config = GgufModelConfig {
2659 + model_id: selection_id,
2660 files: Vec::new(),
2661 tok_model_id: None,
2662 display_name: cfg.display_name.clone(),
@@ -2487,37 +2665,9 @@ impl SiGitAgent {
2665 };
2666 {
2667 let mut guard = self.current_model.lock().unwrap();
2490 - *guard = cloud_config;
2491 - }
2492 -
2493 - // Explicitly choosing a cloud tier puts us in cloud mode.
2494 - let _ = settings::set_local_inference(false);
2495 -
2496 - log::info!("switched to cloud tier {tier}");
2497 - Some(cfg.display_name)
2498 - }
2499 -
2500 - /// Apply the persisted Local Inference mode at session start. When local
2501 - /// inference is off and an account is signed in, route to a cloud tier so the
2502 - /// on-device model is never loaded; otherwise leave the on-device backend in
2503 - /// place. Call after the session cwd is set so the cloud system prompt picks
2504 - /// it up. Does not flip the stored setting on the not-signed-in fallback.
2505 - async fn apply_startup_inference_mode(&self) {
2506 - if settings::local_inference_enabled() {
2507 - return;
2508 - }
2509 - if self
2510 - .switch_to_cloud_tier(provider::DEFAULT_CLOUD_TIER)
2511 - .await
2512 - .is_some()
2513 - {
2514 - log::info!("startup: local inference off; routing inference to siGit Code Cloud");
2515 - } else {
2516 - log::warn!(
2517 - "local inference is off but no account is signed in; staying on-device. \
2518 - Run /login or set Local Inference on."
2519 - );
2668 + *guard = remote_config;
2669 }
2670 + self.record_active_model();
2671 }
2672
2673 /// Route inference back on-device. Used after leaving a cloud tier for a
@@ -2542,6 +2692,7 @@ impl SiGitAgent {
2692 Arc::new(LocalBackend::new(Arc::clone(&self.engine)));
2693 backend::adopt_carryover(local_backend.as_ref(), carried).await;
2694 *self.backend.lock().await = local_backend;
2695 + self.record_active_model();
2696 }
2697
2698 /// Re-attempt the lazy startup model load if the previous attempt failed.
@@ -2579,7 +2730,9 @@ impl SiGitAgent {
2730 Some(tier) => match self.switch_to_cloud_tier(&tier).await {
2731 Some(name) => format!("Active: {name}."),
2732 None => {
2733 + *self.current_model.lock().unwrap() = default_local_model_config();
2734 self.reset_to_local_backend().await;
2735 + let _ = settings::set_local_inference(true);
2736 "Signed out — back to on-device. Pick a model with /models.".to_string()
2737 }
2738 },
@@ -2625,7 +2778,7 @@ impl SiGitAgent {
2778
2779 // A model switch carries the live conversation onto the new backend, so
2780 // the live conversation has to be the one whose picker changed.
2628 - self.activate_session(&args.session_id).await;
2781 + self.activate_session(&args.session_id).await?;
2782
2783 // ── Local Inference toggle ──────────────────────────────────────────
2784 if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID {
@@ -3726,7 +3879,9 @@ async fn exec_slash_acp(
3879 };
3880 let message = account::end_session().await;
3881 if on_cloud {
3882 + *agent.current_model.lock().unwrap() = default_local_model_config();
3883 agent.reset_to_local_backend().await;
3884 + let _ = settings::set_local_inference(true);
3885 }
3886 agent.send_assistant_message(cx, session_id, message).ok();
3887 }
@@ -4503,6 +4658,61 @@ mod tests {
4658 agent.backend.lock().await.history_snapshot().await
4659 }
4660
4661 + #[tokio::test]
4662 + async fn unknown_session_is_rejected_instead_of_borrowing_live_cwd() {
4663 + let agent = SiGitAgent::new(
4664 + Arc::new(ChatEngine::new()),
4665 + default_local_model_config(),
4666 + Arc::new(AtomicBool::new(true)),
4667 + Arc::new(AtomicBool::new(false)),
4668 + Arc::new(std::sync::Mutex::new(None)),
4669 + false,
4670 + false,
4671 + );
4672 +
4673 + let error = agent
4674 + .activate_session(&SessionId::new("not-opened"))
4675 + .await
4676 + .unwrap_err();
4677 + assert_eq!(error.code, agent_client_protocol::ErrorCode::InvalidParams);
4678 + assert!(error.message.contains("session/new"));
4679 + assert!(agent.active_session.lock().unwrap().is_none());
4680 + }
4681 +
4682 + #[tokio::test]
4683 + async fn cancel_signals_only_the_matching_active_prompt() {
4684 + let agent = SiGitAgent::new(
4685 + Arc::new(ChatEngine::new()),
4686 + default_local_model_config(),
4687 + Arc::new(AtomicBool::new(true)),
4688 + Arc::new(AtomicBool::new(false)),
4689 + Arc::new(std::sync::Mutex::new(None)),
4690 + false,
4691 + false,
4692 + );
4693 + let first = Arc::new(PromptCancellation::default());
4694 + let second = Arc::new(PromptCancellation::default());
4695 + {
4696 + let mut prompts = agent.prompt_cancellations.lock().unwrap();
4697 + prompts.insert("first".into(), Arc::clone(&first));
4698 + prompts.insert("second".into(), Arc::clone(&second));
4699 + }
4700 +
4701 + agent
4702 + .handle_cancel(CancelNotification::new(SessionId::new("first")))
4703 + .await
4704 + .unwrap();
4705 +
4706 + assert!(first.cancelled.load(Ordering::Acquire));
4707 + assert!(!second.cancelled.load(Ordering::Acquire));
4708 + tokio::time::timeout(
4709 + std::time::Duration::from_millis(50),
4710 + first.notify.notified(),
4711 + )
4712 + .await
4713 + .expect("cancel notification should remain available to the prompt task");
4714 + }
4715 +
4716 #[tokio::test]
4717 #[allow(clippy::await_holding_lock)] // the lock guards process-global cwd/env for the whole test
4718 async fn each_session_keeps_its_own_roots_and_conversation() {
@@ -4542,17 +4752,37 @@ mod tests {
4752 "m",
4753 Some("base prompt".into()),
4754 ));
4755 + say(&agent, "[Conversation summary]\nold thread").await;
4756 let thread_a = SessionId::new("thread-a");
4757 let thread_b = SessionId::new("thread-b");
4758 agent
4548 - .open_session(&thread_a, SiGitAgent::session_state(&repo_a, &[]))
4759 + .open_session(&thread_a, agent.session_state(&repo_a, &[]))
4760 + .await;
4761 + let history = history_text(&live(&agent).await);
4762 + assert!(!history.contains("old thread"), "{history}");
4763 +
4764 + // Startup cloud routing creates another remote backend after the new
4765 + // session was seeded. It must carry the new session (empty here), not
4766 + // resurrect the summary that was live before session/new.
4767 + agent
4768 + .install_remote_backend(
4769 + provider::ProviderConfig {
4770 + display_name: "test cloud".into(),
4771 + base_url: "http://127.0.0.1:9".into(),
4772 + api_key: String::new(),
4773 + model: "test".into(),
4774 + },
4775 + "sigit-cloud:test".into(),
4776 + )
4777 .await;
4778 + let history = history_text(&live(&agent).await);
4779 + assert!(!history.contains("old thread"), "{history}");
4780 say(&agent, "working on repo a").await;
4781
4782 agent
4783 .open_session(
4784 &thread_b,
4555 - SiGitAgent::session_state(&repo_b, std::slice::from_ref(&extra_b)),
4785 + agent.session_state(&repo_b, std::slice::from_ref(&extra_b)),
4786 )
4787 .await;
4788 let history = history_text(&live(&agent).await);
@@ -4566,7 +4796,7 @@ mod tests {
4796 say(&agent, "working on repo b").await;
4797
4798 // Prompting thread A again puts its roots and conversation back.
4569 - agent.activate_session(&thread_a).await;
4799 + agent.activate_session(&thread_a).await.unwrap();
4800 let history = history_text(&live(&agent).await);
4801 assert!(history.contains("working on repo a"), "{history}");
4802 assert!(!history.contains("working on repo b"), "{history}");
@@ -4580,7 +4810,7 @@ mod tests {
4810 workspace::canonical_key(&repo_a)
4811 );
4812
4583 - agent.activate_session(&thread_b).await;
4813 + agent.activate_session(&thread_b).await.unwrap();
4814 let history = history_text(&live(&agent).await);
4815 assert!(history.contains("working on repo b"), "{history}");
4816 assert!(!history.contains("working on repo a"), "{history}");
@@ -4593,6 +4823,25 @@ mod tests {
4823 .count();
4824 assert_eq!(systems, 1);
4825
4826 + // Model changes update only the live session's parked state. Editors
4827 + // such as Zed and JetBrains can keep several threads open in one agent
4828 + // process, so a picker change in B must not rewrite A's selection.
4829 + agent
4830 + .install_remote_backend(
4831 + provider::ProviderConfig {
4832 + display_name: "second test cloud".into(),
4833 + base_url: "http://127.0.0.1:9".into(),
4834 + api_key: String::new(),
4835 + model: "test-b".into(),
4836 + },
4837 + "sigit-cloud:test-b".into(),
4838 + )
4839 + .await;
4840 + let sessions = agent.sessions.lock().unwrap();
4841 + assert_eq!(sessions["thread-a"].model_id, "sigit-cloud:test");
4842 + assert_eq!(sessions["thread-b"].model_id, "sigit-cloud:test-b");
4843 + drop(sessions);
4844 +
4845 std::env::set_current_dir(prev_cwd).unwrap();
4846 tools::set_active_session(None);
4847 workspace::replace_additional_roots(Vec::new());
src/session_store.rs
+27 -4
@@ -92,17 +92,26 @@ pub struct SessionMeta {
92 /// Extra workspace roots of a multi-root project, in order.
93 #[serde(default)]
94 pub additional_directories: Vec<PathBuf>,
95 + /// Model selection last used by this session. Older sidecars omit it.
96 + #[serde(default, skip_serializing_if = "Option::is_none")]
97 + pub model_id: Option<String>,
98 }
99
100 /// Record where `session_id` is running so it can be listed later. Cheap enough
101 /// to call on every save; the sidecar is a few hundred bytes.
99 -pub fn save_meta(session_id: &str, cwd: &Path, additional_directories: &[PathBuf]) {
102 +pub fn save_meta(
103 + session_id: &str,
104 + cwd: &Path,
105 + additional_directories: &[PathBuf],
106 + model_id: Option<&str>,
107 +) {
108 let Some(path) = meta_path(session_id) else {
109 return;
110 };
111 let meta = SessionMeta {
112 cwd: cwd.to_path_buf(),
113 additional_directories: additional_directories.to_vec(),
114 + model_id: model_id.map(str::to_string),
115 };
116 let Ok(body) = serde_json::to_string(&meta) else {
117 return;
@@ -177,6 +186,8 @@ pub struct SessionEntry {
186 pub cwd: Option<PathBuf>,
187 /// Extra workspace roots recorded alongside `cwd`.
188 pub additional_directories: Vec<PathBuf>,
189 + /// Model selection saved alongside the session, when available.
190 + pub model_id: Option<String>,
191 /// A one-line title taken from the first user message, when there is one.
192 pub title: Option<String>,
193 }
@@ -257,9 +268,9 @@ pub fn list() -> Vec<SessionEntry> {
268 .and_then(|m| m.modified().ok())
269 .unwrap_or(std::time::UNIX_EPOCH);
270 let meta = load_meta(&id);
260 - let (cwd, additional_directories) = match meta {
261 - Some(meta) => (Some(meta.cwd), meta.additional_directories),
262 - None => (None, Vec::new()),
271 + let (cwd, additional_directories, model_id) = match meta {
272 + Some(meta) => (Some(meta.cwd), meta.additional_directories, meta.model_id),
273 + None => (None, Vec::new(), None),
274 };
275 Some(SessionEntry {
276 id,
@@ -267,6 +278,7 @@ pub fn list() -> Vec<SessionEntry> {
278 message_count,
279 cwd,
280 additional_directories,
281 + model_id,
282 title,
283 })
284 })
@@ -414,12 +426,22 @@ mod tests {
426 assert_eq!(listed[0].cwd, None);
427 assert!(listed[0].additional_directories.is_empty());
428
429 + // Sidecars written before model persistence remain readable.
430 + std::fs::write(
431 + sessions.join("newer.meta.json"),
432 + r#"{"cwd":"/tmp/project","additional_directories":["/tmp/lib"]}"#,
433 + )
434 + .unwrap();
435 + let listed = list();
436 + assert_eq!(listed[0].model_id, None);
437 +
438 // A sidecar makes the session listable with its directories, and
439 // deleting the session takes the sidecar with it.
440 save_meta(
441 "newer",
442 Path::new("/tmp/project"),
443 &[PathBuf::from("/tmp/lib")],
444 + Some("test-model"),
445 );
446 let listed = list();
447 assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/tmp/project")));
@@ -427,6 +449,7 @@ mod tests {
449 listed[0].additional_directories,
450 vec![PathBuf::from("/tmp/lib")]
451 );
452 + assert_eq!(listed[0].model_id.as_deref(), Some("test-model"));
453 assert!(sessions.join("newer.meta.json").is_file());
454 delete("newer");
455 assert!(!sessions.join("newer.meta.json").exists());