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());