73
use agent_client_protocol::schema::v1::{
74
AgentAuthCapabilities, AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest,
75
AuthenticateResponse, AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate,
76
- CancelNotification, ConfigOptionUpdate, ContentBlock, ContentChunk, CurrentModeUpdate,
77
- EmbeddedResourceResource, ForkSessionRequest, ForkSessionResponse, Implementation,
78
- InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse,
79
- LoadSessionRequest, LoadSessionResponse, LogoutCapabilities, LogoutRequest, LogoutResponse,
80
- McpCapabilities, McpServer, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
81
- PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptCapabilities,
82
- PromptRequest, PromptResponse, ReadTextFileRequest, RequestPermissionOutcome,
83
- RequestPermissionRequest, SessionAdditionalDirectoriesCapabilities, SessionCapabilities,
84
- SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
85
- SessionConfigValueId, SessionForkCapabilities, SessionId, SessionInfo, SessionListCapabilities,
86
- SessionMode, SessionModeState, SessionNotification, SessionUpdate,
87
- SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest,
88
- SetSessionModeResponse, StopReason, ToolCall, ToolCallContent, ToolCallLocation,
89
- ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
90
- WriteTextFileRequest,
76
+ CancelNotification, CloseSessionRequest, CloseSessionResponse, ConfigOptionUpdate,
77
+ ContentBlock, ContentChunk, CurrentModeUpdate, EmbeddedResourceResource, ForkSessionRequest,
78
+ ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse,
79
+ ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
80
+ LogoutCapabilities, LogoutRequest, LogoutResponse, McpCapabilities, McpServer, Meta,
81
+ NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, Plan, PlanEntry,
82
+ PlanEntryPriority, PlanEntryStatus, PromptCapabilities, PromptRequest, PromptResponse,
83
+ ReadTextFileRequest, RequestPermissionOutcome, RequestPermissionRequest, ResumeSessionRequest,
84
+ ResumeSessionResponse, SessionAdditionalDirectoriesCapabilities, SessionCapabilities,
85
+ SessionCloseCapabilities, SessionConfigOption, SessionConfigOptionCategory,
86
+ SessionConfigSelectOption, SessionConfigValueId, SessionForkCapabilities, SessionId,
87
+ SessionInfo, SessionListCapabilities, SessionMode, SessionModeState, SessionNotification,
88
+ SessionResumeCapabilities, SessionUpdate, SetSessionConfigOptionRequest,
89
+ SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, StopReason,
90
+ ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate,
91
+ ToolCallUpdateFields, ToolKind, UnstructuredCommandInput, WriteTextFileRequest,
92
};
93
use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
94
use onde::inference::{ChatEngine, GgufModelConfig};
923
}
924
}
925
926
+/// Which request is bringing a saved session back (see `restore_session`).
927
+#[derive(Clone, Copy, PartialEq, Eq)]
928
+enum RestoreKind {
929
+ /// `session/load`: the client draws the thread from a replay.
930
+ Load,
931
+ /// `session/resume`: the client kept the thread, so nothing is replayed.
932
+ Resume,
933
+}
934
+
935
+impl RestoreKind {
936
+ fn method(self) -> &'static str {
937
+ match self {
938
+ RestoreKind::Load => "load_session",
939
+ RestoreKind::Resume => "resume_session",
940
+ }
941
+ }
942
+}
943
+
944
/// What one ACP session owns, kept per session id.
945
///
946
/// An editor runs a single sigit process for every thread it has open, but the
1040
/// that turn is holding.
1041
prompt_cancellations:
1042
std::sync::Mutex<std::collections::HashMap<String, Arc<PromptCancellation>>>,
1043
+ /// Sessions a `session/close` is waiting to tear down. A prompt for one of
1044
+ /// these that reaches the front of its session lock first is cancelled.
1045
+ closing_sessions: std::sync::Mutex<std::collections::HashSet<String>>,
1046
current_model: std::sync::Mutex<GgufModelConfig>,
1047
/// flipped once the startup model finishes (success or failure)
1048
model_ready: Arc<AtomicBool>,
1094
sessions: std::sync::Mutex::new(std::collections::HashMap::new()),
1095
active_session: std::sync::Mutex::new(None),
1096
prompt_cancellations: std::sync::Mutex::new(std::collections::HashMap::new()),
1097
+ closing_sessions: std::sync::Mutex::new(std::collections::HashSet::new()),
1098
current_model: std::sync::Mutex::new(initial_model),
1099
model_ready,
1100
startup_model_load_started,
1838
.description("Opens sigit.si in your browser to authorize this device."),
1839
)];
1840
1818
- Ok(
1819
- InitializeResponse::new(ProtocolVersion::V1)
1820
- .agent_info(
1821
- Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
1822
- .title("siGit Code - AI Coding Agent"),
1823
- )
1824
- .auth_methods(auth_methods)
1825
- .agent_capabilities(
1826
- AgentCapabilities::default()
1827
- .load_session(true)
1828
- // Gates the editor's sign out button. Without it the
1829
- // only way out of an account is typing `/logout`.
1830
- .auth(AgentAuthCapabilities::new().logout(LogoutCapabilities::new()))
1831
- // Lets the editor attach images to a prompt. The capability is fixed
1832
- // for the connection while the model is not, so a prompt that
1833
- // brings an image to a model that cannot read one is answered with
1834
- // a note instead (see `images_for_turn`).
1835
- .prompt_capabilities(PromptCapabilities::new().image(true))
1836
- // Clients only pass HTTP MCP servers in `mcpServers` to an
1837
- // agent that says it can reach them. SSE stays off: the
1838
- // MCP spec deprecated that transport.
1839
- .mcp_capabilities(McpCapabilities::new().http(true))
1840
- .session_capabilities(
1841
- SessionCapabilities::new()
1842
- .fork(SessionForkCapabilities::new())
1843
- // Durable sessions (`session_store`) are what makes
1844
- // listing meaningful: without this the editor's
1845
- // "Import Threads" picker reports that the agent
1846
- // doesn't support ACP's session/list capability.
1847
- .list(SessionListCapabilities::new())
1848
- // Without this, a client that has several directories
1849
- // open never sends the extra ones: Zed drops every root
1850
- // but the first and tells the user the agent has no
1851
- // multi-root support. See `workspace.rs`.
1852
- .additional_directories(
1853
- SessionAdditionalDirectoriesCapabilities::new(),
1854
- ),
1855
- ),
1856
- )
1857
- .meta(initialize_meta()),
1858
- )
1841
+ Ok(InitializeResponse::new(ProtocolVersion::V1)
1842
+ .agent_info(
1843
+ Implementation::new("sigit", env!("CARGO_PKG_VERSION"))
1844
+ .title("siGit Code - AI Coding Agent"),
1845
+ )
1846
+ .auth_methods(auth_methods)
1847
+ .agent_capabilities(
1848
+ AgentCapabilities::default()
1849
+ .load_session(true)
1850
+ // Gates the editor's sign out button. Without it the
1851
+ // only way out of an account is typing `/logout`.
1852
+ .auth(AgentAuthCapabilities::new().logout(LogoutCapabilities::new()))
1853
+ // Lets the editor attach images to a prompt. The capability is fixed
1854
+ // for the connection while the model is not, so a prompt that
1855
+ // brings an image to a model that cannot read one is answered with
1856
+ // a note instead (see `images_for_turn`).
1857
+ .prompt_capabilities(PromptCapabilities::new().image(true))
1858
+ // Clients only pass HTTP MCP servers in `mcpServers` to an
1859
+ // agent that says it can reach them. SSE stays off: the
1860
+ // MCP spec deprecated that transport.
1861
+ .mcp_capabilities(McpCapabilities::new().http(true))
1862
+ .session_capabilities(
1863
+ SessionCapabilities::new()
1864
+ .fork(SessionForkCapabilities::new())
1865
+ // Durable sessions (`session_store`) are what makes
1866
+ // listing meaningful: without this the editor's
1867
+ // "Import Threads" picker reports that the agent
1868
+ // doesn't support ACP's session/list capability.
1869
+ .list(SessionListCapabilities::new())
1870
+ // Without this, a client that has several directories
1871
+ // open never sends the extra ones: Zed drops every root
1872
+ // but the first and tells the user the agent has no
1873
+ // multi-root support. See `workspace.rs`.
1874
+ .additional_directories(SessionAdditionalDirectoriesCapabilities::new())
1875
+ // Reconnect to a thread the client still has on
1876
+ // screen, without the replay `session/load` sends.
1877
+ .resume(SessionResumeCapabilities::new())
1878
+ // Lets the client say a thread is gone, so its
1879
+ // state does not sit here until the process exits.
1880
+ .close(SessionCloseCapabilities::new()),
1881
+ ),
1882
+ )
1883
+ .meta(initialize_meta()))
1884
}
1885
1886
async fn handle_authenticate(
2345
cx: &ConnectionTo<Client>,
2346
args: LoadSessionRequest,
2347
) -> agent_client_protocol::Result<LoadSessionResponse> {
2323
- validate_session_roots(&args.cwd, &args.additional_directories)?;
2348
+ let config_options = self
2349
+ .restore_session(
2350
+ cx,
2351
+ RestoreKind::Load,
2352
+ &args.session_id,
2353
+ &args.cwd,
2354
+ &args.additional_directories,
2355
+ &args.mcp_servers,
2356
+ )
2357
+ .await?;
2358
+ Ok(LoadSessionResponse::new()
2359
+ .config_options(config_options)
2360
+ .modes(build_session_modes(&args.session_id.to_string())))
2361
+ }
2362
+
2363
+ /// ACP `session/resume`: `session/load` without the replay.
2364
+ ///
2365
+ /// The client still has the thread on screen (the agent restarted under
2366
+ /// it, say) and only needs the model to remember it. The spec forbids
2367
+ /// sending the history back as `session/update` here.
2368
+ async fn handle_resume_session(
2369
+ &self,
2370
+ cx: &ConnectionTo<Client>,
2371
+ args: ResumeSessionRequest,
2372
+ ) -> agent_client_protocol::Result<ResumeSessionResponse> {
2373
+ let config_options = self
2374
+ .restore_session(
2375
+ cx,
2376
+ RestoreKind::Resume,
2377
+ &args.session_id,
2378
+ &args.cwd,
2379
+ &args.additional_directories,
2380
+ &args.mcp_servers,
2381
+ )
2382
+ .await?;
2383
+ Ok(ResumeSessionResponse::new()
2384
+ .config_options(config_options)
2385
+ .modes(build_session_modes(&args.session_id.to_string())))
2386
+ }
2387
2388
+ /// Bring a saved session back as the live one, for `session/load` and
2389
+ /// `session/resume`. The two differ only in whether the conversation is
2390
+ /// replayed to the client.
2391
+ async fn restore_session(
2392
+ &self,
2393
+ cx: &ConnectionTo<Client>,
2394
+ kind: RestoreKind,
2395
+ session_id: &SessionId,
2396
+ cwd: &std::path::Path,
2397
+ additional_directories: &[PathBuf],
2398
+ mcp_servers: &[McpServer],
2399
+ ) -> agent_client_protocol::Result<Vec<SessionConfigOption>> {
2400
+ validate_session_roots(cwd, additional_directories)?;
2401
+
2402
+ let method = kind.method();
2403
log::info!(
2326
- "load_session: id={}, cwd={}, additional_directories={:?}",
2327
- args.session_id,
2328
- args.cwd.display(),
2329
- args.additional_directories
2404
+ "{method}: id={session_id}, cwd={}, additional_directories={:?}",
2405
+ cwd.display(),
2406
+ additional_directories
2407
.iter()
2408
.map(|p| p.display().to_string())
2409
.collect::<Vec<_>>()
2410
);
2411
2335
- let session_key = args.session_id.to_string();
2412
+ let session_key = session_id.to_string();
2413
2414
// The saved copy is the thread; failing that, this process may still
2415
// hold one nobody has spoken in yet (nothing is written until a turn
2416
// completes). An id found in neither place names no session, and
2417
// answering with an empty one would hand the client a blank thread it
2418
// cannot tell from a restored one. Parking first makes the in-memory
2342
- // copy current when the id being loaded is the live session.
2419
+ // copy current when the id being restored is the live session.
2420
self.park_active_session().await;
2421
let in_memory = self
2422
.sessions
2425
.get(&session_key)
2426
.map(|state| state.conversation.clone());
2427
let Some(history) = session_store::load(&session_key).or(in_memory) else {
2351
- log::warn!("load_session: no session {session_key}");
2428
+ log::warn!("{method}: no session {session_key}");
2429
return Err(agent_client_protocol::Error::new(
2430
-32002,
2431
format!("session {session_key} not found"),
2432
));
2433
};
2434
2358
- let mut state = self.session_state(&args.cwd, &args.additional_directories);
2435
+ let mut state = self.session_state(cwd, additional_directories);
2436
if let Some(model_id) = session_store::list()
2437
.into_iter()
2438
.find(|entry| entry.id == session_key)
2451
// from last time.
2452
let restored = history.len();
2453
2377
- // The client draws the reopened thread from these notifications alone.
2378
- let updates = history_replay_updates(&history);
2379
- let replayed = updates.len();
2380
- for update in updates {
2381
- cx.send_notification(SessionNotification::new(args.session_id.clone(), update))
2382
- .ok();
2454
+ // On a load the client draws the reopened thread from these
2455
+ // notifications alone. On a resume it already has the thread.
2456
+ let mut replayed = 0;
2457
+ if kind == RestoreKind::Load {
2458
+ let updates = history_replay_updates(&history);
2459
+ replayed = updates.len();
2460
+ for update in updates {
2461
+ cx.send_notification(SessionNotification::new(session_id.clone(), update))
2462
+ .ok();
2463
+ }
2464
}
2465
2466
state.conversation = backend::carryover_history(history);
2386
- state.mcp_servers =
2387
- mcp::connect_session_servers(client_mcp_servers(&args.mcp_servers)).await;
2467
+ state.mcp_servers = mcp::connect_session_servers(client_mcp_servers(mcp_servers)).await;
2468
log::info!(
2389
- "load_session: restored {restored} message(s), replayed {replayed} update(s) for {}",
2390
- args.session_id
2469
+ "{method}: restored {restored} message(s), replayed {replayed} update(s) for {session_id}"
2470
);
2471
2393
- self.open_parked_session(&args.session_id, state).await;
2472
+ self.open_parked_session(session_id, state).await;
2473
2474
let config_options = {
2475
let guard = self.current_model.lock().unwrap();
2397
- build_config_options(&guard, &args.session_id.to_string())
2476
+ build_config_options(&guard, &session_key)
2477
};
2478
2400
- self.advertise_commands(cx, args.session_id.clone());
2479
+ self.advertise_commands(cx, session_id.clone());
2480
2481
// Run SessionStart hooks
2482
let hook_settings = settings::load().hooks;
2483
if hook_settings.has_hooks() {
2405
- hooks::run_session_start_hooks(&hook_settings, &args.cwd, &args.session_id.to_string());
2484
+ hooks::run_session_start_hooks(&hook_settings, cwd, &session_key);
2485
}
2486
2408
- Ok(LoadSessionResponse::new()
2409
- .config_options(config_options)
2410
- .modes(build_session_modes(&args.session_id.to_string())))
2487
+ Ok(config_options)
2488
+ }
2489
+
2490
+ /// ACP `session/close`: the client is done with a thread.
2491
+ ///
2492
+ /// Drops everything this process holds for it: the parked state (and with
2493
+ /// it the MCP servers the client named), its permission grants and plan
2494
+ /// mode, and the background commands it started. The saved history stays,
2495
+ /// so the thread still lists and reopens. The caller has already signalled
2496
+ /// the session's running turn (see `begin_close`) and holds the session's
2497
+ /// lock and `workspace_lock`, so that turn has ended by the time this runs.
2498
+ ///
2499
+ /// An id this process does not hold is answered with success. The spec
2500
+ /// allows an error there, but the client wants the session gone and it is.
2501
+ async fn handle_close_session(
2502
+ &self,
2503
+ args: CloseSessionRequest,
2504
+ ) -> agent_client_protocol::Result<CloseSessionResponse> {
2505
+ let key = args.session_id.to_string();
2506
+ let known = self.sessions.lock().unwrap().remove(&key).is_some();
2507
+
2508
+ let was_live = {
2509
+ let mut active = self.active_session.lock().unwrap();
2510
+ let was_live = active.as_deref() == Some(key.as_str());
2511
+ if was_live {
2512
+ *active = None;
2513
+ }
2514
+ was_live
2515
+ };
2516
+ if was_live {
2517
+ // Nothing owns the live conversation or roots any more. Clearing
2518
+ // them here means the next session to open cannot inherit them,
2519
+ // and lets go of the last handle on this session's MCP servers.
2520
+ *self.session_cwd.lock().unwrap() = None;
2521
+ tools::set_active_session(None);
2522
+ workspace::replace_additional_roots(Vec::new());
2523
+ mcp::set_session_servers(mcp::SessionServers::default());
2524
+ self.engine.clear_history().await;
2525
+ let backend = self.backend.lock().await.clone();
2526
+ backend.restore_history(Vec::new()).await;
2527
+ }
2528
+
2529
+ permissions::reset_session(&key);
2530
+ let killed = tools::kill_session_tasks(&key);
2531
+ self.closing_sessions.lock().unwrap().remove(&key);
2532
+
2533
+ log::info!(
2534
+ "close_session: id={key} (known={known}, live={was_live}, {killed} background task(s) stopped)"
2535
+ );
2536
+ Ok(CloseSessionResponse::new())
2537
+ }
2538
+
2539
+ /// First half of `session/close`, run before waiting for any lock.
2540
+ ///
2541
+ /// The turn to cancel is the one holding the session's lock, so the signal has to
2542
+ /// go out first. Marking the session as closing covers the other case: a
2543
+ /// prompt for it that is still queued behind another thread's turn has
2544
+ /// registered nothing to cancel yet, and `handle_prompt` turns it away
2545
+ /// when it reaches the front.
2546
+ fn begin_close(&self, session_id: &SessionId) {
2547
+ let key = session_id.to_string();
2548
+ self.closing_sessions.lock().unwrap().insert(key.clone());
2549
+ if let Some(cancellation) = self.prompt_cancellations.lock().unwrap().get(&key).cloned() {
2550
+ cancellation.cancelled.store(true, Ordering::Release);
2551
+ cancellation.notify.notify_one();
2552
+ }
2553
}
2554
2555
async fn handle_fork_session(
2675
) -> agent_client_protocol::Result<PromptResponse> {
2676
let session_id = args.session_id.clone();
2677
2678
+ // `session/close` arrived while this prompt was waiting its turn.
2679
+ // Closing cancels the session's work, and this is part of it.
2680
+ if self
2681
+ .closing_sessions
2682
+ .lock()
2683
+ .unwrap()
2684
+ .contains(&session_id.to_string())
2685
+ {
2686
+ return Ok(PromptResponse::new(StopReason::Cancelled));
2687
+ }
2688
+
2689
// Everything below (slash commands included) acts on the live session,
2690
// so it has to be this one. Resource links are read relative to its cwd.
2691
let mut workspace = WorkspaceHold::acquire(&self.workspace_lock).await;
5379
},
5380
agent_client_protocol::on_receive_request!(),
5381
)
5382
+ .on_receive_request(
5383
+ {
5384
+ let state = Arc::clone(&state);
5385
+ async move |req: ResumeSessionRequest, responder, cx: ConnectionTo<Client>| {
5386
+ let state = Arc::clone(&state);
5387
+ let task_cx = cx.clone();
5388
+ cx.spawn(async move {
5389
+ let session = state.session_lock(&req.session_id);
5390
+ let _session = session.lock().await;
5391
+ let _workspace = state.workspace_lock.lock().await;
5392
+ handle_response(responder, state.handle_resume_session(&task_cx, req).await)
5393
+ })
5394
+ }
5395
+ },
5396
+ agent_client_protocol::on_receive_request!(),
5397
+ )
5398
+ .on_receive_request(
5399
+ {
5400
+ let state = Arc::clone(&state);
5401
+ async move |req: CloseSessionRequest, responder, cx: ConnectionTo<Client>| {
5402
+ // Signalled here, before taking any lock: the turn to
5403
+ // cancel is the one holding the session's.
5404
+ state.begin_close(&req.session_id);
5405
+ let state = Arc::clone(&state);
5406
+ cx.spawn(async move {
5407
+ let session = state.session_lock(&req.session_id);
5408
+ let _session = session.lock().await;
5409
+ let _workspace = state.workspace_lock.lock().await;
5410
+ handle_response(responder, state.handle_close_session(req).await)
5411
+ })
5412
+ }
5413
+ },
5414
+ agent_client_protocol::on_receive_request!(),
5415
+ )
5416
.on_receive_request(
5417
{
5418
let state = Arc::clone(&state);
6278
std::fs::remove_dir_all(&temp).ok();
6279
}
6280
6281
+ #[tokio::test]
6282
+ #[allow(clippy::await_holding_lock)] // the lock guards process-global cwd/env for the whole test
6283
+ async fn closing_a_session_drops_its_state_and_leaves_the_others() {
6284
+ let _guard = crate::ENV_TEST_LOCK
6285
+ .lock()
6286
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
6287
+ let temp = std::env::temp_dir().join(format!("sigit-close-{}", uuid::Uuid::new_v4()));
6288
+ let (repo_a, repo_b) = (temp.join("repo-a"), temp.join("repo-b"));
6289
+ for dir in [&repo_a, &repo_b] {
6290
+ std::fs::create_dir_all(dir).unwrap();
6291
+ }
6292
+ let prev_cwd = std::env::current_dir().unwrap();
6293
+ // SAFETY: serialized by ENV_TEST_LOCK and restored below.
6294
+ unsafe {
6295
+ std::env::set_var("SIGIT_CONFIG_DIR", temp.join("config"));
6296
+ std::env::set_var("SIGIT_LOCAL_INFERENCE", "on");
6297
+ }
6298
+
6299
+ let agent = SiGitAgent::new(
6300
+ Arc::new(ChatEngine::new()),
6301
+ default_local_model_config(),
6302
+ Arc::new(AtomicBool::new(true)),
6303
+ Arc::new(AtomicBool::new(false)),
6304
+ Arc::new(std::sync::Mutex::new(None)),
6305
+ false,
6306
+ false,
6307
+ );
6308
+ *agent.backend.lock().await = Arc::new(OpenAiBackend::new(
6309
+ "http://127.0.0.1:9",
6310
+ "",
6311
+ "m",
6312
+ Some("base prompt".into()),
6313
+ ));
6314
+ let thread_a = SessionId::new("close-thread-a");
6315
+ let thread_b = SessionId::new("close-thread-b");
6316
+ agent
6317
+ .open_session(&thread_a, agent.session_state(&repo_a, &[]))
6318
+ .await;
6319
+ say(&agent, "said in thread a").await;
6320
+ permissions::set_plan_mode("close-thread-a", true);
6321
+ agent
6322
+ .open_session(&thread_b, agent.session_state(&repo_b, &[]))
6323
+ .await;
6324
+ say(&agent, "said in thread b").await;
6325
+
6326
+ // Thread A is parked with a turn in flight when the close arrives.
6327
+ let turn = Arc::new(PromptCancellation::default());
6328
+ agent
6329
+ .prompt_cancellations
6330
+ .lock()
6331
+ .unwrap()
6332
+ .insert("close-thread-a".into(), Arc::clone(&turn));
6333
+ agent.begin_close(&thread_a);
6334
+ assert!(turn.cancelled.load(Ordering::Acquire));
6335
+ assert!(
6336
+ agent
6337
+ .closing_sessions
6338
+ .lock()
6339
+ .unwrap()
6340
+ .contains("close-thread-a")
6341
+ );
6342
+
6343
+ agent
6344
+ .handle_close_session(CloseSessionRequest::new(thread_a.clone()))
6345
+ .await
6346
+ .unwrap();
6347
+ assert!(
6348
+ !agent
6349
+ .sessions
6350
+ .lock()
6351
+ .unwrap()
6352
+ .contains_key("close-thread-a")
6353
+ );
6354
+ assert!(!permissions::plan_mode("close-thread-a"));
6355
+ assert!(agent.closing_sessions.lock().unwrap().is_empty());
6356
+ assert!(agent.activate_session(&thread_a).await.is_err());
6357
+
6358
+ // The live thread is untouched.
6359
+ assert_eq!(
6360
+ agent.active_session.lock().unwrap().as_deref(),
6361
+ Some("close-thread-b")
6362
+ );
6363
+ assert!(history_text(&live(&agent).await).contains("said in thread b"));
6364
+
6365
+ // Closing the live one leaves no session installed and no conversation
6366
+ // for the next one to inherit.
6367
+ agent
6368
+ .handle_close_session(CloseSessionRequest::new(thread_b.clone()))
6369
+ .await
6370
+ .unwrap();
6371
+ assert!(agent.active_session.lock().unwrap().is_none());
6372
+ assert!(live(&agent).await.is_empty());
6373
+ assert!(agent.sessions.lock().unwrap().is_empty());
6374
+
6375
+ // A second close, or one for an id nobody opened, is not an error.
6376
+ agent
6377
+ .handle_close_session(CloseSessionRequest::new(thread_b))
6378
+ .await
6379
+ .unwrap();
6380
+
6381
+ std::env::set_current_dir(prev_cwd).unwrap();
6382
+ tools::set_active_session(None);
6383
+ workspace::replace_additional_roots(Vec::new());
6384
+ // SAFETY: serialized by ENV_TEST_LOCK.
6385
+ unsafe {
6386
+ std::env::remove_var("SIGIT_CONFIG_DIR");
6387
+ std::env::remove_var("SIGIT_LOCAL_INFERENCE");
6388
+ }
6389
+ std::fs::remove_dir_all(&temp).ok();
6390
+ }
6391
+
6392
#[test]
6393
fn session_context_message_lists_every_root_of_a_multi_root_project() {
6394
let primary = PathBuf::from("/tmp/primary");