593
/// `additional_roots` is empty for an ordinary project and non-empty for a
594
/// multi-root one, where the editor opened several directories at once (see
595
/// `workspace.rs`).
596
+/// The single system message a remote backend gets: the full agent prompt
597
+/// followed by the session context. One message rather than two, because
598
+/// `compact_history` keeps only the first system message.
599
+fn remote_system_prompt(session_context: &str) -> String {
600
+ format!("{}\n\n{session_context}", system_prompt_for_model(true))
601
+}
602
+
603
fn session_context_message(cwd: &std::path::Path, additional_roots: &[PathBuf]) -> String {
604
let mut message = if additional_roots.is_empty() {
605
format!(
752
meta
753
}
754
755
+/// What one ACP session owns, kept per session id.
756
+///
757
+/// An editor runs a single sigit process for every thread it has open, but the
758
+/// process has one working directory, one set of workspace roots, and one
759
+/// backend conversation. Only one session can be live at a time (turns are
760
+/// serialized by `turn_lock`), so each session's roots and conversation are
761
+/// parked here while another session holds the process, and swapped back in
762
+/// by `activate_session` before its next request runs.
763
+#[derive(Clone, Debug, Default)]
764
+struct SessionState {
765
+ cwd: PathBuf,
766
+ additional_roots: Vec<PathBuf>,
767
+ /// The conversation without its system messages (see
768
+ /// `backend::carryover_history`). Those are rebuilt from the roots each
769
+ /// time the session is installed, so they always name this session's
770
+ /// project. Stale while the session is live: the backend holds the
771
+ /// current copy then.
772
+ conversation: Vec<serde_json::Value>,
773
+}
774
+
775
struct SiGitAgent {
776
engine: Arc<ChatEngine>,
777
/// The active inference backend. `LocalBackend` by default; swapped to an
778
/// `OpenAiBackend` when the user selects a siGit Code Cloud tier in the panel.
779
backend: tokio::sync::Mutex<Arc<dyn InferenceBackend>>,
753
- /// cwd from the editor — tool calls run here, not where the process started
780
+ /// cwd from the editor — tool calls run here, not where the process started.
781
+ /// Always the live session's `cwd`.
782
session_cwd: std::sync::Mutex<Option<PathBuf>>,
783
+ /// Every session this process has opened, by id.
784
+ sessions: std::sync::Mutex<std::collections::HashMap<String, SessionState>>,
785
+ /// The session whose roots and conversation are installed right now.
786
+ active_session: std::sync::Mutex<Option<String>>,
787
current_model: std::sync::Mutex<GgufModelConfig>,
788
/// flipped once the startup model finishes (success or failure)
789
model_ready: Arc<AtomicBool>,
825
engine,
826
backend: tokio::sync::Mutex::new(backend),
827
session_cwd: std::sync::Mutex::new(None),
828
+ sessions: std::sync::Mutex::new(std::collections::HashMap::new()),
829
+ active_session: std::sync::Mutex::new(None),
830
current_model: std::sync::Mutex::new(initial_model),
831
model_ready,
832
startup_model_load_started,
1455
}
1456
}
1457
1424
- /// Record the session's roots and move the process into the primary one.
1458
+ /// Build the state for a session request: its primary `cwd` plus the extra
1459
+ /// roots worth keeping (see `workspace::set_additional_roots`).
1460
+ fn session_state(cwd: &std::path::Path, additional: &[PathBuf]) -> SessionState {
1461
+ SessionState {
1462
+ cwd: cwd.to_path_buf(),
1463
+ additional_roots: workspace::filter_additional_roots(cwd, additional),
1464
+ conversation: Vec::new(),
1465
+ }
1466
+ }
1467
+
1468
+ /// Move the live conversation into the state of the session that owns it,
1469
+ /// so installing another session can't lose or leak it.
1470
+ async fn park_active_session(&self) {
1471
+ let Some(active) = self.active_session.lock().unwrap().clone() else {
1472
+ return;
1473
+ };
1474
+ let snapshot = self.backend.lock().await.history_snapshot().await;
1475
+ if let Some(state) = self.sessions.lock().unwrap().get_mut(&active) {
1476
+ state.conversation = backend::carryover_history(snapshot);
1477
+ }
1478
+ }
1479
+
1480
+ /// Register `state` under `session_id` and make it the live session.
1481
///
1426
- /// The editor sends `cwd` plus, for a multi-root project, the other
1427
- /// directories it has open. Tool calls may use relative paths, so the
1428
- /// process follows `cwd`; the extra roots go to `workspace`, which is where
1429
- /// project-local discovery picks them up. Returns the roots that were kept.
1430
- fn enter_session_roots(&self, cwd: &std::path::Path, additional: &[PathBuf]) -> Vec<PathBuf> {
1482
+ /// Used by the session entry points (new/load/fork). The outgoing session
1483
+ /// is parked first, and the Local Inference routing runs last, so a cloud
1484
+ /// backend installed here starts from this session's conversation and
1485
+ /// system prompt and nobody else's.
1486
+ async fn open_session(&self, session_id: &SessionId, state: SessionState) {
1487
+ let key = session_id.to_string();
1488
+ self.park_active_session().await;
1489
+ self.sessions
1490
+ .lock()
1491
+ .unwrap()
1492
+ .insert(key.clone(), state.clone());
1493
+ self.install_session(&key, state).await;
1494
+
1495
+ // Honor the persisted Local Inference toggle (off + signed in → cloud).
1496
+ self.apply_startup_inference_mode().await;
1497
+ }
1498
+
1499
+ /// Make `session_id` the live session before one of its requests runs.
1500
+ ///
1501
+ /// A no-op when it already is. Otherwise the live session is parked and
1502
+ /// this one's roots and conversation are installed in its place. An id this
1503
+ /// process has never seen (the editor kept a thread open across a sigit
1504
+ /// restart) is rebuilt from the session store when it can be.
1505
+ async fn activate_session(&self, session_id: &SessionId) {
1506
+ let key = session_id.to_string();
1507
+ if self.active_session.lock().unwrap().as_deref() == Some(key.as_str()) {
1508
+ return;
1509
+ }
1510
+
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
+ }
1522
+ };
1523
+
1524
+ log::info!(
1525
+ "session({key}): activating (cwd={}, {} extra root(s), {} message(s))",
1526
+ state.cwd.display(),
1527
+ state.additional_roots.len(),
1528
+ state.conversation.len()
1529
+ );
1530
+ self.park_active_session().await;
1531
+ 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
1560
+ }
1561
+
1562
+ /// Point the process at `state`'s roots and load its conversation.
1563
+ ///
1564
+ /// The process follows `cwd` because tool calls may use relative paths; the
1565
+ /// extra roots go to `workspace`, where project-local discovery reads them.
1566
+ /// Background tasks are scoped to the live session too (see
1567
+ /// `tools::set_active_session`).
1568
+ async fn install_session(&self, key: &str, state: SessionState) {
1569
+ let SessionState {
1570
+ cwd,
1571
+ additional_roots,
1572
+ conversation,
1573
+ } = state;
1574
+
1575
if let Ok(mut guard) = self.session_cwd.lock() {
1432
- *guard = Some(cwd.to_path_buf());
1576
+ *guard = Some(cwd.clone());
1577
}
1434
- let additional_roots = workspace::set_additional_roots(cwd, additional);
1578
+ workspace::replace_additional_roots(additional_roots.clone());
1579
if cwd.is_dir()
1436
- && let Err(err) = std::env::set_current_dir(cwd)
1580
+ && let Err(err) = std::env::set_current_dir(&cwd)
1581
{
1582
log::warn!("could not set cwd to {}: {err}", cwd.display());
1583
}
1440
- additional_roots
1441
- }
1584
+ *self.active_session.lock().unwrap() = Some(key.to_string());
1585
+ tools::set_active_session(Some(key));
1586
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
- ))
1587
+ self.seed_conversation(&cwd, &additional_roots, conversation)
1588
.await;
1589
+ }
1590
1591
+ /// Replace the conversation on the engine and the active backend with
1592
+ /// `conversation`, under system context naming `cwd` and `additional_roots`.
1593
+ ///
1594
+ /// Both are seeded even when only one serves inference: switching between
1595
+ /// on-device and cloud carries the conversation across and keeps the target's
1596
+ /// own system messages, so each has to name the live session's project.
1597
+ async fn seed_conversation(
1598
+ &self,
1599
+ cwd: &std::path::Path,
1600
+ additional_roots: &[PathBuf],
1601
+ conversation: Vec<serde_json::Value>,
1602
+ ) {
1603
+ let context = session_context_message(cwd, additional_roots);
1604
let backend = self.backend.lock().await.clone();
1605
+
1606
if backend.is_remote() {
1461
- backend::clear_conversation(backend.as_ref()).await;
1607
+ self.engine.clear_history().await;
1608
+ self.engine
1609
+ .push_history(onde::inference::ChatMessage::system(context.clone()))
1610
+ .await;
1611
}
1612
1464
- // Honor the persisted Local Inference toggle (off + signed in → cloud).
1465
- self.apply_startup_inference_mode().await;
1613
+ let system_prompt = if backend.is_remote() {
1614
+ remote_system_prompt(&context)
1615
+ } else {
1616
+ context
1617
+ };
1618
+ let mut history = vec![serde_json::json!({ "role": "system", "content": system_prompt })];
1619
+ history.extend(conversation);
1620
+ backend.restore_history(history).await;
1621
}
1622
1623
/// Save a session's history plus the sidecar that says where it ran.
1634
log::warn!("session({session_id}) save failed: {error}");
1635
return;
1636
}
1482
- match self.session_cwd.lock().ok().and_then(|guard| guard.clone()) {
1483
- Some(cwd) => session_store::save_meta(&key, &cwd, &workspace::additional_roots()),
1637
+ let state = self.sessions.lock().unwrap().get(&key).cloned();
1638
+ match state {
1639
+ Some(state) => session_store::save_meta(&key, &state.cwd, &state.additional_roots),
1640
None => log::warn!(
1641
"session({session_id}) has no recorded cwd — saved, but it won't be listed"
1642
),
1700
.collect::<Vec<_>>()
1701
);
1702
1547
- let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1703
+ let mut state = Self::session_state(&args.cwd, &args.additional_directories);
1704
1705
// A session boundary: grants and plan mode from the previous life of
1550
- // this session id must not carry over — and since one shared engine
1551
- // means one live conversation, state for every other id is dead too.
1552
- permissions::reset_all();
1553
-
1554
- // start from a clean slate; a stored session (below) replaces it
1555
- self.begin_conversation(&args.cwd, &additional_roots).await;
1706
+ // this session id must not carry over. Other sessions keep theirs; the
1707
+ // editor may still have them open.
1708
+ permissions::reset_session(&args.session_id.to_string());
1709
1710
// Durable sessions: when this session id was saved before, restore its
1558
- // history into the active backend. The snapshot includes the system
1559
- // messages that were live when it was saved, so restore replaces the
1560
- // freshly seeded state wholesale.
1711
+ // conversation. The saved system messages are dropped; they are rebuilt
1712
+ // 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()) {
1714
let restored = history.len();
1715
1564
- // Replay before restoring: the client draws the reopened thread
1565
- // from these notifications alone, and `restore_history` consumes
1566
- // the snapshot.
1716
+ // The client draws the reopened thread from these notifications
1717
+ // alone.
1718
let updates = history_replay_updates(&history);
1719
let replayed = updates.len();
1720
for update in updates {
1722
.ok();
1723
}
1724
1574
- let backend = self.backend.lock().await.clone();
1575
- backend.restore_history(history).await;
1725
+ state.conversation = backend::carryover_history(history);
1726
log::info!(
1727
"load_session: restored {restored} message(s), replayed {replayed} update(s) for {}",
1728
args.session_id
1729
);
1730
}
1731
1732
+ self.open_session(&args.session_id, state).await;
1733
+
1734
let config_options = {
1735
let guard = self.current_model.lock().unwrap();
1736
build_config_options(&guard, &args.session_id.to_string())
1753
args: ForkSessionRequest,
1754
) -> agent_client_protocol::Result<ForkSessionResponse> {
1755
let new_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1604
- // Session boundary: permission grants and plan mode never cross it
1605
- // (see handle_load_session), so a fork starts with a clean slate.
1606
- permissions::reset_all();
1756
log::info!(
1757
"fork_session: from={} new={new_id}, cwd={}, additional_directories={:?}",
1758
args.session_id,
1763
.collect::<Vec<_>>()
1764
);
1765
1617
- let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1618
-
1619
- // no persistence, so fork == fresh session
1620
- self.begin_conversation(&args.cwd, &additional_roots).await;
1766
+ // The fork continues the source thread's conversation under the roots
1767
+ // it was forked into. Permission grants and plan mode stay behind: the
1768
+ // new id has none. The source's conversation is current only after
1769
+ // parking when the source is the live session.
1770
+ 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 mut state = Self::session_state(&args.cwd, &args.additional_directories);
1782
+ state.conversation = source;
1783
+ self.open_session(&new_id, state).await;
1784
1785
let config_options = {
1786
let guard = self.current_model.lock().unwrap();
1804
args: NewSessionRequest,
1805
) -> agent_client_protocol::Result<NewSessionResponse> {
1806
let session_id = SessionId::new(uuid::Uuid::new_v4().to_string());
1644
- // Session boundary: permission grants and plan mode never cross it
1645
- // (see handle_load_session), so stale ids stop accumulating state.
1646
- permissions::reset_all();
1807
log::info!(
1808
"new_session: id={session_id}, cwd={}, additional_directories={:?}",
1809
args.cwd.display(),
1813
.collect::<Vec<_>>()
1814
);
1815
1656
- let additional_roots = self.enter_session_roots(&args.cwd, &args.additional_directories);
1657
-
1658
- self.begin_conversation(&args.cwd, &additional_roots).await;
1816
+ let state = Self::session_state(&args.cwd, &args.additional_directories);
1817
+ self.open_session(&session_id, state).await;
1818
1819
let config_options = {
1820
let guard = self.current_model.lock().unwrap();
1839
) -> agent_client_protocol::Result<PromptResponse> {
1840
let session_id = args.session_id.clone();
1841
1842
+ // Everything below (slash commands included) acts on the live session,
1843
+ // so it has to be this one. Resource links are read relative to its cwd.
1844
+ self.activate_session(&session_id).await;
1845
+
1846
// log every block so we can debug @ references and file context
1847
for (i, block) in args.prompt.iter().enumerate() {
1848
match block {
2446
/// for login). Shared by the panel picker and the `/models` slash command.
2447
async fn switch_to_cloud_tier(&self, tier: &str) -> Option<String> {
2448
let cfg = crate::provider::cloud_tier_provider(tier)?;
2286
- let mut system_prompt = system_prompt_for_model(true).to_string();
2449
// Mirror the cwd guidance and project instruction files the local engine
2450
// gets at session load, so the cloud model shares the same project context.
2289
- if let Some(cwd) = self.session_cwd.lock().ok().and_then(|g| g.clone()) {
2290
- system_prompt.push_str("\n\n");
2291
- system_prompt.push_str(&session_context_message(
2451
+ let system_prompt = match self.session_cwd.lock().ok().and_then(|g| g.clone()) {
2452
+ Some(cwd) => remote_system_prompt(&session_context_message(
2453
&cwd,
2454
&workspace::additional_roots(),
2294
- ));
2295
- }
2455
+ )),
2456
+ None => system_prompt_for_model(true).to_string(),
2457
+ };
2458
let cloud_backend: Arc<dyn InferenceBackend> = Arc::new(OpenAiBackend::new(
2459
cfg.base_url,
2460
cfg.api_key,
2613
args.value
2614
);
2615
2616
+ // A model switch carries the live conversation onto the new backend, so
2617
+ // the live conversation has to be the one whose picker changed.
2618
+ self.activate_session(&args.session_id).await;
2619
+
2620
// ── Local Inference toggle ──────────────────────────────────────────
2621
if args.config_id.0.as_ref() == LOCAL_INFERENCE_CONFIG_ID {
2622
let enabled = match args.value.as_value_id().map(|v| v.0.as_ref()) {
3413
.ok();
3414
}
3415
SlashCommand::Clear => {
3250
- let cleared = agent.engine.clear_history().await;
3416
+ // Wipe the conversation on whichever backend holds it, keeping the
3417
+ // session context. Clearing only the engine left a cloud thread intact.
3418
+ let snapshot = agent.backend.lock().await.history_snapshot().await;
3419
+ let cleared = snapshot
3420
+ .iter()
3421
+ .filter(|message| message["role"] == "user")
3422
+ .count();
3423
+ let (cwd, roots) = {
3424
+ let sessions = agent.sessions.lock().unwrap();
3425
+ match sessions.get(&session_id.to_string()) {
3426
+ Some(state) => (state.cwd.clone(), state.additional_roots.clone()),
3427
+ None => (
3428
+ std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
3429
+ workspace::additional_roots(),
3430
+ ),
3431
+ }
3432
+ };
3433
+ agent.seed_conversation(&cwd, &roots, Vec::new()).await;
3434
permissions::reset_session(&session_id.to_string());
3435
// The saved session must not resurrect what the user just wiped.
3436
session_store::delete(&session_id.to_string());
4472
assert!(!same_dir(&link, &real));
4473
}
4474
4475
+ /// Text of every message in `history`, for containment checks.
4476
+ fn history_text(history: &[serde_json::Value]) -> String {
4477
+ history
4478
+ .iter()
4479
+ .filter_map(|message| message["content"].as_str())
4480
+ .collect::<Vec<_>>()
4481
+ .join("\n")
4482
+ }
4483
+
4484
+ /// Append a user message to the live conversation.
4485
+ async fn say(agent: &SiGitAgent, text: &str) {
4486
+ let backend = agent.backend.lock().await.clone();
4487
+ let mut history = backend.history_snapshot().await;
4488
+ history.push(serde_json::json!({ "role": "user", "content": text }));
4489
+ backend.restore_history(history).await;
4490
+ }
4491
+
4492
+ async fn live(agent: &SiGitAgent) -> Vec<serde_json::Value> {
4493
+ agent.backend.lock().await.history_snapshot().await
4494
+ }
4495
+
4496
+ #[tokio::test]
4497
+ #[allow(clippy::await_holding_lock)] // the lock guards process-global cwd/env for the whole test
4498
+ async fn each_session_keeps_its_own_roots_and_conversation() {
4499
+ let _guard = crate::ENV_TEST_LOCK
4500
+ .lock()
4501
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
4502
+ let temp = std::env::temp_dir().join(format!("sigit-sessions-{}", uuid::Uuid::new_v4()));
4503
+ let (repo_a, repo_b, extra_b) = (
4504
+ temp.join("repo-a"),
4505
+ temp.join("repo-b"),
4506
+ temp.join("extra-b"),
4507
+ );
4508
+ for dir in [&repo_a, &repo_b, &extra_b] {
4509
+ std::fs::create_dir_all(dir).unwrap();
4510
+ }
4511
+ let prev_cwd = std::env::current_dir().unwrap();
4512
+ // SAFETY: serialized by ENV_TEST_LOCK and restored below.
4513
+ unsafe {
4514
+ std::env::set_var("SIGIT_CONFIG_DIR", temp.join("config"));
4515
+ std::env::set_var("SIGIT_LOCAL_INFERENCE", "on");
4516
+ }
4517
+
4518
+ let agent = SiGitAgent::new(
4519
+ Arc::new(ChatEngine::new()),
4520
+ default_local_model_config(),
4521
+ Arc::new(AtomicBool::new(true)),
4522
+ Arc::new(AtomicBool::new(false)),
4523
+ Arc::new(std::sync::Mutex::new(None)),
4524
+ false,
4525
+ false,
4526
+ );
4527
+ // A remote backend keeps its own history, which is where threads used
4528
+ // to bleed into each other. No request is ever sent.
4529
+ *agent.backend.lock().await = Arc::new(OpenAiBackend::new(
4530
+ "http://127.0.0.1:9",
4531
+ "",
4532
+ "m",
4533
+ Some("base prompt".into()),
4534
+ ));
4535
+ let thread_a = SessionId::new("thread-a");
4536
+ let thread_b = SessionId::new("thread-b");
4537
+ agent
4538
+ .open_session(&thread_a, SiGitAgent::session_state(&repo_a, &[]))
4539
+ .await;
4540
+ say(&agent, "working on repo a").await;
4541
+
4542
+ agent
4543
+ .open_session(
4544
+ &thread_b,
4545
+ SiGitAgent::session_state(&repo_b, std::slice::from_ref(&extra_b)),
4546
+ )
4547
+ .await;
4548
+ let history = history_text(&live(&agent).await);
4549
+ assert!(!history.contains("working on repo a"), "{history}");
4550
+ assert!(history.contains(&repo_b.display().to_string()), "{history}");
4551
+ assert!(
4552
+ history.contains(&extra_b.display().to_string()),
4553
+ "{history}"
4554
+ );
4555
+ assert_eq!(workspace::additional_roots(), vec![extra_b.clone()]);
4556
+ say(&agent, "working on repo b").await;
4557
+
4558
+ // Prompting thread A again puts its roots and conversation back.
4559
+ agent.activate_session(&thread_a).await;
4560
+ let history = history_text(&live(&agent).await);
4561
+ assert!(history.contains("working on repo a"), "{history}");
4562
+ assert!(!history.contains("working on repo b"), "{history}");
4563
+ assert!(
4564
+ !history.contains(&repo_b.display().to_string()),
4565
+ "{history}"
4566
+ );
4567
+ assert!(workspace::additional_roots().is_empty());
4568
+ assert_eq!(
4569
+ workspace::canonical_key(&std::env::current_dir().unwrap()),
4570
+ workspace::canonical_key(&repo_a)
4571
+ );
4572
+
4573
+ agent.activate_session(&thread_b).await;
4574
+ let history = history_text(&live(&agent).await);
4575
+ assert!(history.contains("working on repo b"), "{history}");
4576
+ assert!(!history.contains("working on repo a"), "{history}");
4577
+
4578
+ // The system prompt is rebuilt, not stacked, on every switch.
4579
+ let systems = live(&agent)
4580
+ .await
4581
+ .iter()
4582
+ .filter(|message| message["role"] == "system")
4583
+ .count();
4584
+ assert_eq!(systems, 1);
4585
+
4586
+ std::env::set_current_dir(prev_cwd).unwrap();
4587
+ tools::set_active_session(None);
4588
+ workspace::replace_additional_roots(Vec::new());
4589
+ // SAFETY: serialized by ENV_TEST_LOCK.
4590
+ unsafe {
4591
+ std::env::remove_var("SIGIT_CONFIG_DIR");
4592
+ std::env::remove_var("SIGIT_LOCAL_INFERENCE");
4593
+ }
4594
+ std::fs::remove_dir_all(&temp).ok();
4595
+ }
4596
+
4597
#[test]
4598
fn session_context_message_lists_every_root_of_a_multi_root_project() {
4599
let primary = PathBuf::from("/tmp/primary");