feat(acp): add session/resume and session/close (#172)

sigit advertised list, fork and additionalDirectories but neither of the two optional v1 methods for reconnecting to a session and for dropping one. session/resume restores a saved thread the way session/load does and sends none of it back. It is for a client that still has the thread on screen, after an agent restart for example. Both now go through restore_session, which takes the replay as the only difference. session/close cancels the session's running turn and removes what the process held for it: the SessionState (and the MCP servers the client named), its permission grants and plan mode, and its background commands. Until now a SessionState lived until the process exited. The saved history is not touched, so a closed thread still lists and reopens. The turn to cancel holds turn_lock, so close signals it from the dispatch loop before queueing for the lock. A prompt for the same session that was still waiting behind another thread's turn has nothing registered to cancel, so the id is marked as closing and handle_prompt returns cancelled when that prompt reaches the front. Closing an id the process does not hold succeeds. The spec allows an error there, but the client wanted the session gone and it is. Closes #149 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 6, 2026 at 21:10 UTC 99685e1a32c66c9cbefab1b6bc22116934a84cd0
5 files changed +619 -83
.agents/AGENTS.md
+10 -1
@@ -117,7 +117,16 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
117 three. A thread on an HTTP backend keeps a backend of its own (`InferenceBackend::fresh`,
118 `SessionState::remote`), so its turn can wait on the endpoint while another thread is
119 installed. Unknown session ids are rejected instead of silently borrowing the active thread's
120 - cwd. Prompt cancellation takes no lock, which lets a client cancel a turn whatever it holds. The
120 + cwd. Prompt cancellation takes no lock, which lets a client cancel a turn whatever it holds.
121 + `session/load` and `session/resume` share `restore_session`; the only difference is that
122 + resume must not replay the history as `session/update`. `session/close` is the one place a
123 + `SessionState` is dropped. It runs in two halves: `begin_close` signals the session's turn
124 + from the dispatch loop (the turn holds the session's lock, so the signal cannot wait for it) and
125 + marks the id in `closing_sessions`, which makes `handle_prompt` cancel a prompt that was
126 + still queued; `handle_close_session` then runs under the session and workspace locks and
127 + removes the state, the permission grants and the background commands
128 + (`tools::kill_session_tasks`). It leaves `session_store` alone, so a closed thread still lists
129 + and reopens. The
130 `SYSTEM_PROMPT` bakes in smbCloud-specific context the agent should use when the repo is clearly
131 smbCloud, and stay general otherwise.
132 - **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
CHANGELOG.md
+10
@@ -4,6 +4,16 @@
4
5 ### Added
6
7 +- **A client can resume a session and close one.** siGit Code now advertises
8 + `sessionCapabilities.resume` and `sessionCapabilities.close`.
9 + `session/resume` restores a saved thread the way `session/load` does, but
10 + sends none of it back: it is for a client that still has the thread on
11 + screen, after an agent restart for example. `session/close` cancels the
12 + session's running turn and drops what the process held for it, which is its
13 + state, its permission grants and plan mode, the MCP servers the client named
14 + for it, and the background commands it started. The saved history is left
15 + alone, so a closed thread still lists and reopens (#149).
16 +
17 - **`sigit --version` prints the release.** `sigit --version` and `sigit -V`
18 print `sigit <version>` on stdout and exit 0. Before, the flag was not
19 recognized and siGit Code started a session, which left a script that asked
src/main.rs
+380 -82
@@ -73,21 +73,22 @@ use agent_client_protocol::schema::ProtocolVersion;
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};
@@ -922,6 +923,24 @@ impl client_fs::ClientFileSystem for AcpClientFs {
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
@@ -1021,6 +1040,9 @@ struct SiGitAgent {
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>,
@@ -1072,6 +1094,7 @@ impl SiGitAgent {
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,
@@ -1815,47 +1838,49 @@ impl SiGitAgent {
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(
@@ -2320,26 +2345,78 @@ impl SiGitAgent {
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
@@ -2348,14 +2425,14 @@ impl SiGitAgent {
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)
@@ -2374,40 +2451,105 @@ impl SiGitAgent {
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(
@@ -2533,6 +2675,17 @@ impl SiGitAgent {
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;
@@ -5226,6 +5379,40 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
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);
@@ -6091,6 +6278,117 @@ mod tests {
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");
src/tools.rs
+49
@@ -2855,6 +2855,30 @@ async fn exec_command_output_wait(arguments: &str, owner: Option<String>) -> Str
2855 }
2856 }
2857
2858 +/// Stop and forget every background task `owner` started. Called when its
2859 +/// session closes: nobody is left who could poll or kill them. Returns how
2860 +/// many were still running.
2861 +pub fn kill_session_tasks(owner: &str) -> usize {
2862 + let mut map = lock_tasks();
2863 + let ids: Vec<u64> = map
2864 + .iter()
2865 + .filter(|(_, task)| task.owner.as_deref() == Some(owner))
2866 + .map(|(id, _)| *id)
2867 + .collect();
2868 +
2869 + let mut running = 0;
2870 + for id in ids {
2871 + let Some(mut task) = map.remove(&id) else {
2872 + continue;
2873 + };
2874 + if poll_exit_code(&mut task).is_none() {
2875 + running += 1;
2876 + kill_shell_tree(&mut task.child);
2877 + }
2878 + }
2879 + running
2880 +}
2881 +
2882 /// `kill_command` tool: stop a background task and report its output tail.
2883 fn exec_kill_command(arguments: &str, owner: Option<&str>) -> String {
2884 let task_id = match parse_task_id(arguments) {
@@ -4265,6 +4289,31 @@ mod tests {
4289 );
4290 }
4291
4292 + #[test]
4293 + fn closing_a_session_stops_its_background_tasks_and_no_one_elses() {
4294 + #[cfg(unix)]
4295 + let command = "sleep 30";
4296 + #[cfg(windows)]
4297 + let command = "ping -n 31 127.0.0.1 > nul";
4298 +
4299 + let cwd = std::env::temp_dir();
4300 + let closed = start_background_task(command, &cwd, Some("thread-closing".into()));
4301 + let kept = start_background_task(command, &cwd, Some("thread-staying".into()));
4302 + let closed_args = serde_json::json!({ "task_id": background_task_id(&closed) }).to_string();
4303 + let kept_args = serde_json::json!({ "task_id": background_task_id(&kept) }).to_string();
4304 +
4305 + assert_eq!(kill_session_tasks("thread-closing"), 1);
4306 +
4307 + // The task is gone from the table, not just stopped.
4308 + let poll = exec_command_output(&closed_args, Some("thread-closing"));
4309 + assert!(poll.contains("no background task with id"), "got: {poll}");
4310 + let poll = exec_command_output(&kept_args, Some("thread-staying"));
4311 + assert!(poll.contains("still running"), "got: {poll}");
4312 +
4313 + assert_eq!(kill_session_tasks("thread-closing"), 0);
4314 + exec_kill_command(&kept_args, Some("thread-staying"));
4315 + }
4316 +
4317 #[test]
4318 fn test_command_output_unknown_task() {
4319 let result = exec_command_output(r#"{"task_id": 9999999}"#, None);
tests/acp_session_load.rs
+170
@@ -401,3 +401,173 @@ fn loading_an_unknown_session_is_an_error() {
401 drop(agent);
402 let _ = std::fs::remove_dir_all(&scratch);
403 }
404 +
405 +fn prompt(agent: &mut AgentUnderTest, session_id: &str, text: &str) -> Value {
406 + let id = agent.request(
407 + "session/prompt",
408 + json!({
409 + "sessionId": session_id,
410 + "prompt": [{"type": "text", "text": text}],
411 + }),
412 + );
413 + agent.wait_for_raw_response(id)
414 +}
415 +
416 +/// `session/resume` is for a client that kept the thread on screen while the
417 +/// agent went away. The model has to remember the conversation, and the client
418 +/// must not be sent it again (issue #149).
419 +#[test]
420 +fn resuming_a_session_restores_it_without_a_replay() {
421 + let scratch = std::env::temp_dir().join(format!("sigit_acp_resume_{}", std::process::id()));
422 + let config_dir = scratch.join("config");
423 + let project = scratch.join("project");
424 + std::fs::create_dir_all(&config_dir).unwrap();
425 + std::fs::create_dir_all(&project).unwrap();
426 +
427 + let port = start_fake_endpoint(vec![sse_text("First answer."), sse_text("Second answer.")]);
428 +
429 + let mut agent = spawn_agent(port, &config_dir);
430 + let id = agent.request(
431 + "initialize",
432 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
433 + );
434 + let (initialize, _) = agent.wait_for_response_with_updates(id);
435 + let capabilities = &initialize["result"]["agentCapabilities"]["sessionCapabilities"];
436 + assert!(
437 + capabilities["resume"].is_object() && capabilities["close"].is_object(),
438 + "a client only calls session/resume and session/close on an agent that advertises them: {initialize}"
439 + );
440 +
441 + let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
442 + let (new_session, _) = agent.wait_for_response_with_updates(id);
443 + let session_id = new_session["result"]["sessionId"]
444 + .as_str()
445 + .expect("session id")
446 + .to_string();
447 + prompt(&mut agent, &session_id, "first question");
448 +
449 + // The agent restarts under the editor, which still shows the thread.
450 + drop(agent);
451 + let mut agent = spawn_agent(port, &config_dir);
452 + let id = agent.request(
453 + "initialize",
454 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
455 + );
456 + agent.wait_for_response_with_updates(id);
457 +
458 + let id = agent.request(
459 + "session/resume",
460 + json!({"sessionId": session_id, "cwd": project, "mcpServers": []}),
461 + );
462 + let (resumed, updates) = agent.wait_for_response_with_updates(id);
463 + assert!(
464 + resumed["result"]["configOptions"].is_array(),
465 + "the resumed session reports its config options like a loaded one: {resumed}"
466 + );
467 + for update in &updates {
468 + assert_eq!(
469 + update["sessionUpdate"], "available_commands_update",
470 + "nothing of the conversation may be replayed on resume: {update}"
471 + );
472 + }
473 +
474 + // The conversation is there all the same: the next turn is saved on top of
475 + // the first, so a load brings both back. A resume that opened a blank
476 + // session would have saved the second turn alone.
477 + prompt(&mut agent, &session_id, "second question");
478 + let id = agent.request(
479 + "session/load",
480 + json!({"sessionId": session_id, "cwd": project, "mcpServers": []}),
481 + );
482 + let (_, replay) = agent.wait_for_response_with_updates(id);
483 + let user: Vec<&str> = replay
484 + .iter()
485 + .filter(|update| update["sessionUpdate"] == "user_message_chunk")
486 + .map(text_of)
487 + .collect();
488 + assert_eq!(user, vec!["first question", "second question"]);
489 +
490 + // Like a load, a resume of an id that names nothing is an error.
491 + let id = agent.request(
492 + "session/resume",
493 + json!({"sessionId": "does-not-exist", "cwd": project, "mcpServers": []}),
494 + );
495 + let response = agent.wait_for_raw_response(id);
496 + assert_eq!(
497 + response["error"]["code"], -32002,
498 + "an unknown id must be reported as not found: {response}"
499 + );
500 +
501 + drop(agent);
502 + let _ = std::fs::remove_dir_all(&scratch);
503 +}
504 +
505 +/// `session/close` frees what the process holds for a thread. The saved copy
506 +/// is not part of that: the thread still lists and reopens (issue #149).
507 +#[test]
508 +fn closing_a_session_forgets_it_but_keeps_the_saved_history() {
509 + let scratch = std::env::temp_dir().join(format!("sigit_acp_close_{}", std::process::id()));
510 + let config_dir = scratch.join("config");
511 + let project = scratch.join("project");
512 + std::fs::create_dir_all(&config_dir).unwrap();
513 + std::fs::create_dir_all(&project).unwrap();
514 +
515 + let port = start_fake_endpoint(vec![sse_text("An answer.")]);
516 + let mut agent = spawn_agent(port, &config_dir);
517 + let id = agent.request(
518 + "initialize",
519 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
520 + );
521 + agent.wait_for_response_with_updates(id);
522 +
523 + let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
524 + let (new_session, _) = agent.wait_for_response_with_updates(id);
525 + let session_id = new_session["result"]["sessionId"]
526 + .as_str()
527 + .expect("session id")
528 + .to_string();
529 + prompt(&mut agent, &session_id, "a question");
530 +
531 + let id = agent.request("session/close", json!({"sessionId": session_id}));
532 + let closed = agent.wait_for_raw_response(id);
533 + assert!(
534 + closed.get("error").is_none() && closed["result"].is_object(),
535 + "close answers with an empty result: {closed}"
536 + );
537 +
538 + // The id is no longer a session this process serves.
539 + let response = prompt(&mut agent, &session_id, "/help");
540 + assert!(
541 + response.get("error").is_some(),
542 + "a closed session must not take prompts: {response}"
543 + );
544 +
545 + // Closing it again is harmless.
546 + let id = agent.request("session/close", json!({"sessionId": session_id}));
547 + let closed = agent.wait_for_raw_response(id);
548 + assert!(closed.get("error").is_none(), "{closed}");
549 +
550 + // The history on disk was left alone.
551 + let id = agent.request("session/list", json!({}));
552 + let (listed, _) = agent.wait_for_response_with_updates(id);
553 + let ids: Vec<&str> = listed["result"]["sessions"]
554 + .as_array()
555 + .expect("sessions")
556 + .iter()
557 + .filter_map(|session| session["sessionId"].as_str())
558 + .collect();
559 + assert_eq!(ids, vec![session_id.as_str()]);
560 +
561 + let id = agent.request(
562 + "session/load",
563 + json!({"sessionId": session_id, "cwd": project, "mcpServers": []}),
564 + );
565 + let (_, replay) = agent.wait_for_response_with_updates(id);
566 + assert!(
567 + replay.iter().any(|update| text_of(update) == "a question"),
568 + "the closed thread must reopen with its history: {replay:#?}"
569 + );
570 +
571 + drop(agent);
572 + let _ = std::fs::remove_dir_all(&scratch);
573 +}