Handle ACP session/delete

sigit now advertises sessionCapabilities.delete and answers session/delete. A live thread is closed first, the same way session/close does it (its turn is signalled before any lock is taken, then its state, permission grants and background commands are dropped), and then its saved history and sidecar are removed, so it stops showing up in session/list and "Import Threads". Deleting an id that is already gone succeeds. Closes #192 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 00:10 UTC 7f72526690f43b2b2da548bcdc13eff9e7c925d8
3 files changed +152 -19
.agents/AGENTS.md
+3 -1
@@ -126,7 +126,9 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
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
129 + and reopens. `session/delete` is the one that removes it from disk: it runs the same two halves
130 + (`begin_close`, then `handle_close_session` from `handle_delete_session`) and then
131 + `session_store::delete`, and succeeds for an id that is already gone. The
132 `SYSTEM_PROMPT` bakes in smbCloud-specific context the agent should use when the repo is clearly
133 smbCloud, and stay general otherwise.
134 - **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
src/main.rs
+60 -18
@@ -77,23 +77,24 @@ use agent_client_protocol::schema::v1::{
77 AgentAuthCapabilities, AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest,
78 AuthenticateResponse, AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate,
79 CancelNotification, CloseSessionRequest, CloseSessionResponse, ConfigOptionUpdate,
80 - ContentBlock, ContentChunk, CreateTerminalRequest, CurrentModeUpdate, Diff,
81 - EmbeddedResourceResource, ForkSessionRequest, ForkSessionResponse, Implementation,
82 - InitializeRequest, InitializeResponse, KillTerminalRequest, ListSessionsRequest,
83 - ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, LogoutCapabilities,
84 - LogoutRequest, LogoutResponse, McpCapabilities, McpServer, Meta, NewSessionRequest,
85 - NewSessionResponse, PermissionOption, PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority,
86 - PlanEntryStatus, PromptCapabilities, PromptRequest, PromptResponse, ReadTextFileRequest,
87 - ReleaseTerminalRequest, RequestPermissionOutcome, RequestPermissionRequest,
88 - ResumeSessionRequest, ResumeSessionResponse, SessionAdditionalDirectoriesCapabilities,
89 - SessionCapabilities, SessionCloseCapabilities, SessionConfigOption,
90 - SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
91 - SessionForkCapabilities, SessionId, SessionInfo, SessionListCapabilities, SessionMode,
92 - SessionModeState, SessionNotification, SessionResumeCapabilities, SessionUpdate,
93 - SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, SetSessionModeRequest,
94 - SetSessionModeResponse, StopReason, Terminal, TerminalOutputRequest, ToolCall, ToolCallContent,
95 - ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind,
96 - UnstructuredCommandInput, WaitForTerminalExitRequest, WriteTextFileRequest,
80 + ContentBlock, ContentChunk, CreateTerminalRequest, CurrentModeUpdate, DeleteSessionRequest,
81 + DeleteSessionResponse, Diff, EmbeddedResourceResource, ForkSessionRequest, ForkSessionResponse,
82 + Implementation, InitializeRequest, InitializeResponse, KillTerminalRequest,
83 + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse,
84 + LogoutCapabilities, LogoutRequest, LogoutResponse, McpCapabilities, McpServer, Meta,
85 + NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, Plan, PlanEntry,
86 + PlanEntryPriority, PlanEntryStatus, PromptCapabilities, PromptRequest, PromptResponse,
87 + ReadTextFileRequest, ReleaseTerminalRequest, RequestPermissionOutcome,
88 + RequestPermissionRequest, ResumeSessionRequest, ResumeSessionResponse,
89 + SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionCloseCapabilities,
90 + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
91 + SessionConfigValueId, SessionDeleteCapabilities, SessionForkCapabilities, SessionId,
92 + SessionInfo, SessionListCapabilities, SessionMode, SessionModeState, SessionNotification,
93 + SessionResumeCapabilities, SessionUpdate, SetSessionConfigOptionRequest,
94 + SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, StopReason,
95 + Terminal, TerminalOutputRequest, ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus,
96 + ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
97 + WaitForTerminalExitRequest, WriteTextFileRequest,
98 };
99 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
100 use onde::inference::{ChatEngine, GgufModelConfig};
@@ -2236,7 +2237,11 @@ impl SiGitAgent {
2237 .resume(SessionResumeCapabilities::new())
2238 // Lets the client say a thread is gone, so its
2239 // state does not sit here until the process exits.
2239 - .close(SessionCloseCapabilities::new()),
2240 + .close(SessionCloseCapabilities::new())
2241 + // Without this, a thread the user removes in the
2242 + // editor stays on disk and comes back in "Import
2243 + // Threads".
2244 + .delete(SessionDeleteCapabilities::new()),
2245 ),
2246 )
2247 .meta(initialize_meta()))
@@ -2913,6 +2918,26 @@ impl SiGitAgent {
2918 Ok(CloseSessionResponse::new())
2919 }
2920
2921 + /// ACP `session/delete`: the user removed a thread in the editor.
2922 + ///
2923 + /// A live thread is closed first, exactly as `session/close` would (the
2924 + /// caller has run `begin_close` and holds the same locks), so its turn,
2925 + /// permission grants and background commands go with it. Then the saved
2926 + /// history and its sidecar are removed, which is what takes the thread out
2927 + /// of `session/list`. Deleting an id that was never saved, or is already
2928 + /// gone, succeeds quietly.
2929 + async fn handle_delete_session(
2930 + &self,
2931 + args: DeleteSessionRequest,
2932 + ) -> agent_client_protocol::Result<DeleteSessionResponse> {
2933 + let key = args.session_id.to_string();
2934 + self.handle_close_session(CloseSessionRequest::new(args.session_id))
2935 + .await?;
2936 + session_store::delete(&key);
2937 + log::info!("delete_session: id={key}");
2938 + Ok(DeleteSessionResponse::new())
2939 + }
2940 +
2941 /// First half of `session/close`, run before waiting for any lock.
2942 ///
2943 /// The turn to cancel is the one holding the session's lock, so the signal has to
@@ -5899,6 +5924,23 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
5924 },
5925 agent_client_protocol::on_receive_request!(),
5926 )
5927 + .on_receive_request(
5928 + {
5929 + let state = Arc::clone(&state);
5930 + async move |req: DeleteSessionRequest, responder, cx: ConnectionTo<Client>| {
5931 + // A delete closes the thread first, so it is signalled the
5932 + // same way a close is.
5933 + state.begin_close(&req.session_id);
5934 + let state = Arc::clone(&state);
5935 + cx.spawn(async move {
5936 + let _session = state.lock_session(&req.session_id).await;
5937 + let _workspace = state.workspace_lock.lock().await;
5938 + handle_response(responder, state.handle_delete_session(req).await)
5939 + })
5940 + }
5941 + },
5942 + agent_client_protocol::on_receive_request!(),
5943 + )
5944 .on_receive_request(
5945 {
5946 let state = Arc::clone(&state);
tests/acp_session_list.rs
+89
@@ -244,3 +244,92 @@ fn saved_sessions_are_listed_for_their_project() {
244 drop(agent);
245 let _ = std::fs::remove_dir_all(&scratch);
246 }
247 +
248 +/// A thread the user removes in the editor is gone for good: it no longer
249 +/// lists or reopens, and deleting it again still succeeds (issue #192).
250 +#[test]
251 +fn a_deleted_session_no_longer_lists_or_loads() {
252 + let scratch = std::env::temp_dir().join(format!("sigit_acp_delete_{}", std::process::id()));
253 + let config_dir = scratch.join("config");
254 + let project = scratch.join("project");
255 + std::fs::create_dir_all(&config_dir).unwrap();
256 + std::fs::create_dir_all(&project).unwrap();
257 +
258 + let port = start_fake_endpoint(vec![sse_text("Done.")]);
259 + let mut agent = spawn_agent(port, &config_dir);
260 +
261 + let id = agent.request(
262 + "initialize",
263 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
264 + );
265 + let initialize = agent.wait_for_response(id);
266 + assert!(
267 + initialize["result"]["agentCapabilities"]["sessionCapabilities"]["delete"].is_object(),
268 + "session/delete is gated on its capability: {initialize}"
269 + );
270 +
271 + let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
272 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
273 + .as_str()
274 + .expect("session id")
275 + .to_string();
276 + let id = agent.request(
277 + "session/prompt",
278 + json!({
279 + "sessionId": session_id,
280 + "prompt": [{"type": "text", "text": "say done"}],
281 + }),
282 + );
283 + agent.wait_for_response(id);
284 +
285 + let id = agent.request("session/list", json!({"cwd": project}));
286 + let listed = agent.wait_for_response(id);
287 + assert_eq!(
288 + listed["result"]["sessions"].as_array().map(Vec::len),
289 + Some(1),
290 + "{listed}"
291 + );
292 +
293 + // The live thread is deleted: closed first, then removed from disk.
294 + let id = agent.request("session/delete", json!({"sessionId": session_id}));
295 + agent.wait_for_response(id);
296 +
297 + let id = agent.request("session/list", json!({"cwd": project}));
298 + let listed = agent.wait_for_response(id);
299 + assert_eq!(
300 + listed["result"]["sessions"].as_array().map(Vec::len),
301 + Some(0),
302 + "a deleted thread must not come back in the picker: {listed}"
303 + );
304 + let saved: Vec<_> = std::fs::read_dir(config_dir.join("sessions"))
305 + .map(|entries| entries.flatten().map(|entry| entry.file_name()).collect())
306 + .unwrap_or_default();
307 + assert!(saved.is_empty(), "history and sidecar removed: {saved:?}");
308 +
309 + // Nothing is left to reopen.
310 + let id = agent.request(
311 + "session/load",
312 + json!({"sessionId": session_id, "cwd": project, "mcpServers": []}),
313 + );
314 + let deadline = Instant::now() + TIMEOUT;
315 + let load = loop {
316 + let remaining = deadline.saturating_duration_since(Instant::now());
317 + let message = agent
318 + .incoming
319 + .recv_timeout(remaining)
320 + .expect("session/load response");
321 + if message["id"] == id && message.get("method").is_none() {
322 + break message;
323 + }
324 + };
325 + assert!(load.get("error").is_some(), "{load}");
326 +
327 + // Deleting it again, or an id that never existed, succeeds quietly.
328 + let id = agent.request("session/delete", json!({"sessionId": session_id}));
329 + agent.wait_for_response(id);
330 + let id = agent.request("session/delete", json!({"sessionId": "never-existed"}));
331 + agent.wait_for_response(id);
332 +
333 + drop(agent);
334 + let _ = std::fs::remove_dir_all(&scratch);
335 +}