Serve session/list and session/delete over ACP

Zed's "Import Threads" picker asks every configured agent whether it can enumerate its saved threads; siGit Code answered nothing, so its entry sat there with a warning triangle even though every ACP turn was already being written to sessions/<id>.jsonl. The gap was metadata, not storage: a saved transcript carries no cwd or title, and SessionInfo needs both. Each session now gets a small sidecar (sessions/<id>.meta.json) written alongside its transcript at the end of every turn, holding the cwd, additional roots, and a title derived from the first user message. session/list reads it back with no engine or backend access, so it's answerable mid-turn; session/delete removes both files and clears the session's permission grants, and runs through the same turn_lock as load/fork/new/prompt so it can't race an in-flight save and resurrect the file it just removed. Sessions with no sidecar (pre-existing transcripts, the singleton tui/headless entries) are skipped from the list rather than guessed at. Co-Authored-By: siGit Code <noreply@sigit.si>

Seto Elkahfi committed Sep 9, 2026 at 04:30 UTC c6f3e172c5e4c929b97e81ec229fdb60c5b96a4e
7 files changed +609 -25
.agents/AGENTS.md
+16 -3
@@ -111,9 +111,22 @@ The agent loop is backend-agnostic. The flow: a turn (messages + tool specs) goe
111 feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete backend.
112
113 - **`src/main.rs`** — entry point, mode dispatch, the full ACP `Agent` impl (session lifecycle:
114 - new/load/fork/prompt/cancel, config options, slash-command advertisement), and the `SYSTEM_PROMPT`
115 - (note: it bakes in smbCloud-specific context the agent should use when the repo is clearly
116 - smbCloud, and stay general otherwise).
114 + new/load/fork/prompt/cancel/list/delete, config options, slash-command advertisement), and the
115 + `SYSTEM_PROMPT` (note: it bakes in smbCloud-specific context the agent should use when the repo
116 + is clearly smbCloud, and stay general otherwise). `session/list` and `session/delete` are served
117 + from the metadata sidecar `session_store` writes at the end of every `handle_prompt` turn
118 + (`SessionMeta`: cwd, additional roots, a title derived from the first user message by
119 + `title_from_history`); `handle_list_sessions` is a pure filesystem read registered inline
120 + alongside `initialize` (answerable mid-turn), while `handle_delete_session` runs in the
121 + `turn_lock`-serialized group like load/fork/new/prompt so a delete can't race the end-of-turn
122 + save and resurrect the file it just removed.
123 +- **`src/session_store.rs`** — durable session storage: one JSON-lines transcript per session at
124 + `sessions/<id>.jsonl`, plus a `sessions/<id>.meta.json` sidecar (`SessionMeta`) carrying the ACP
125 + fields a transcript alone doesn't have (cwd, additional roots, title). `list()` (consumed by both
126 + the Unix-only TUI History tab and the cross-platform ACP `session/list` handler) skips sessions
127 + with no sidecar — that covers transcripts saved before the sidecar existed and the singleton
128 + `tui`/`headless` entries, which have no meaningful cwd for an editor's thread list. Writes are
129 + atomic (temp file + rename) for both files.
130 - **`src/backend.rs`** — the `InferenceBackend` trait and neutral types (`ToolSpec`, `ToolCall`,
131 `ToolResult`, `TurnResult`). Two impls: `LocalBackend` (on-device via `onde::ChatEngine`) and
132 `OpenAiBackend` (any OpenAI-compatible HTTP endpoint). A `BackendError` is user-facing: the
CHANGELOG.md
+12
@@ -1,5 +1,17 @@
1 # Changelog
2
3 +## Unreleased
4 +
5 +### Added
6 +
7 +- **Editors can now list and delete siGit Code threads.** `session/list` and
8 + `session/delete` were unadvertised, so Zed's "Import Threads" picker showed
9 + siGit Code with a warning triangle even though every ACP turn was already
10 + saved to disk. A session now writes a small metadata sidecar (cwd,
11 + additional roots, a title from its first message) alongside its transcript,
12 + so a client can list threads for the open project, reopen one through the
13 + existing `session/load` replay, and remove one it no longer wants
14 +
15 ## 1.5.7
16
17 ### What changed
Cargo.lock
+1
@@ -5784,6 +5784,7 @@ dependencies = [
5784 "anyhow",
5785 "async-trait",
5786 "base64 0.22.1",
5787 + "chrono",
5788 "crossterm 0.29.0",
5789 "futures",
5790 "libc",
Cargo.toml
+3
@@ -59,6 +59,9 @@ rpassword = "7"
59 base64 = "0.22"
60 rand = "0.9"
61 sha2 = "0.10"
62 +# RFC 3339 timestamps for `session/list`. Already pulled in transitively by
63 +# onde-mistralrs-core and serde_with, so this costs nothing extra to build.
64 +chrono = "0.4"
65
66 # Release binaries ship to five registries, and the NuGet package bundles all
67 # six targets in a single archive, so binary size is a distribution constraint
src/main.rs
+173 -16
@@ -72,16 +72,18 @@ use agent_client_protocol::schema::ProtocolVersion;
72 use agent_client_protocol::schema::v1::{
73 AgentCapabilities, AuthMethod, AuthMethodAgent, AuthenticateRequest, AuthenticateResponse,
74 AvailableCommand, AvailableCommandInput, AvailableCommandsUpdate, CancelNotification,
75 - ConfigOptionUpdate, ContentBlock, ContentChunk, EmbeddedResourceResource, ForkSessionRequest,
76 - ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse, LoadSessionRequest,
77 - LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse, PermissionOption,
78 - PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus, PromptRequest,
79 - PromptResponse, RequestPermissionOutcome, RequestPermissionRequest,
75 + ConfigOptionUpdate, ContentBlock, ContentChunk, DeleteSessionRequest, DeleteSessionResponse,
76 + EmbeddedResourceResource, ForkSessionRequest, ForkSessionResponse, Implementation,
77 + InitializeRequest, InitializeResponse, ListSessionsRequest, ListSessionsResponse,
78 + LoadSessionRequest, LoadSessionResponse, Meta, NewSessionRequest, NewSessionResponse,
79 + PermissionOption, PermissionOptionKind, Plan, PlanEntry, PlanEntryPriority, PlanEntryStatus,
80 + PromptRequest, PromptResponse, RequestPermissionOutcome, RequestPermissionRequest,
81 SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionConfigOption,
82 SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
82 - SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
83 - SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
84 - ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
83 + SessionDeleteCapabilities, SessionForkCapabilities, SessionId, SessionInfo,
84 + SessionListCapabilities, SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
85 + SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallStatus, ToolCallUpdate,
86 + ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
87 };
88 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
89 use onde::inference::{ChatEngine, GgufModelConfig};
@@ -364,6 +366,35 @@ fn history_message_text(message: &serde_json::Value) -> String {
366 }
367 }
368
369 +/// First user message, trimmed to one short line — the thread label a client
370 +/// shows in its history list.
371 +fn title_from_history(history: &[serde_json::Value]) -> Option<String> {
372 + let text = history
373 + .iter()
374 + .find(|message| message["role"] == "user")
375 + .map(history_message_text)?;
376 + let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
377 + if collapsed.is_empty() {
378 + return None;
379 + }
380 + const MAX_LEN: usize = 60;
381 + if collapsed.chars().count() <= MAX_LEN {
382 + return Some(collapsed);
383 + }
384 + let mut truncated: String = collapsed.chars().take(MAX_LEN).collect();
385 + if let Some(last_space) = truncated.rfind(' ') {
386 + truncated.truncate(last_space);
387 + }
388 + truncated.push('…');
389 + Some(truncated)
390 +}
391 +
392 +/// RFC 3339 formatting for `SessionInfo.updated_at`, sourced from the
393 +/// transcript file's mtime rather than a separately tracked timestamp.
394 +fn rfc3339(time: std::time::SystemTime) -> String {
395 + chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339()
396 +}
397 +
398 /// Rebuild the `session/update` notifications that re-draw a saved conversation
399 /// in the client.
400 ///
@@ -1256,14 +1287,16 @@ impl SiGitAgent {
1287 AgentCapabilities::default()
1288 .load_session(true)
1289 .session_capabilities(
1259 - SessionCapabilities::new()
1260 - .fork(SessionForkCapabilities::new())
1261 - // Without this, a client that has several directories
1262 - // open never sends the extra ones: Zed drops every root
1263 - // but the first and tells the user the agent has no
1264 - // multi-root support. See `workspace.rs`.
1265 - .additional_directories(SessionAdditionalDirectoriesCapabilities::new()),
1266 - ),
1290 + SessionCapabilities::new()
1291 + .fork(SessionForkCapabilities::new())
1292 + // Without this, a client that has several directories
1293 + // open never sends the extra ones: Zed drops every root
1294 + // but the first and tells the user the agent has no
1295 + // multi-root support. See `workspace.rs`.
1296 + .additional_directories(SessionAdditionalDirectoriesCapabilities::new())
1297 + .list(SessionListCapabilities::new())
1298 + .delete(SessionDeleteCapabilities::new()),
1299 + ),
1300 )
1301 .meta(initialize_meta()))
1302 }
@@ -1334,6 +1367,54 @@ impl SiGitAgent {
1367 additional_roots
1368 }
1369
1370 + /// Serve `session/list`: a pure filesystem read, no engine or backend
1371 + /// access, so it stays answerable while a turn is in flight (registered
1372 + /// inline, not in the `turn_lock` group).
1373 + ///
1374 + /// Sessions with no metadata sidecar are skipped — that covers transcripts
1375 + /// saved before this existed and the singleton `tui`/`headless` entries,
1376 + /// neither of which has a meaningful cwd for an editor's thread list.
1377 + async fn handle_list_sessions(
1378 + &self,
1379 + args: ListSessionsRequest,
1380 + ) -> agent_client_protocol::Result<ListSessionsResponse> {
1381 + let sessions = session_store::list()
1382 + .into_iter()
1383 + .filter_map(|entry| {
1384 + let meta = entry.meta?;
1385 + if let Some(cwd) = &args.cwd
1386 + && *cwd != meta.cwd
1387 + {
1388 + return None;
1389 + }
1390 + Some(
1391 + SessionInfo::new(SessionId::new(entry.id), meta.cwd)
1392 + .additional_directories(meta.additional_directories)
1393 + .title(meta.title)
1394 + .updated_at(rfc3339(entry.modified)),
1395 + )
1396 + })
1397 + .collect();
1398 +
1399 + Ok(ListSessionsResponse::new(sessions))
1400 + }
1401 +
1402 + /// Serve `session/delete`: removes the transcript and its metadata
1403 + /// sidecar, and drops any permission grants so they don't outlive the
1404 + /// thread. Registered in the `turn_lock` group — without the lock a
1405 + /// delete could land between a turn's inference and its end-of-turn
1406 + /// `session_store::save`, and the save would resurrect the file the user
1407 + /// just removed.
1408 + async fn handle_delete_session(
1409 + &self,
1410 + args: DeleteSessionRequest,
1411 + ) -> agent_client_protocol::Result<DeleteSessionResponse> {
1412 + log::info!("delete_session: id={}", args.session_id);
1413 + session_store::delete(&args.session_id.to_string());
1414 + permissions::reset_session(&args.session_id.to_string());
1415 + Ok(DeleteSessionResponse::new())
1416 + }
1417 +
1418 async fn handle_load_session(
1419 &self,
1420 cx: &ConnectionTo<Client>,
@@ -2010,6 +2091,19 @@ impl SiGitAgent {
2091 log::warn!("prompt({}) session save failed: {error}", session_id);
2092 }
2093
2094 + // Metadata sidecar for `session/list`. Written every turn — cheap, and
2095 + // self-healing for sessions that predate this change.
2096 + if let Some(cwd) = self.session_cwd.lock().ok().and_then(|guard| guard.clone()) {
2097 + let meta = session_store::SessionMeta {
2098 + cwd,
2099 + additional_directories: workspace::additional_roots(),
2100 + title: title_from_history(&snapshot),
2101 + };
2102 + if let Err(error) = session_store::save_meta(&session_id.to_string(), &meta) {
2103 + log::warn!("prompt({}) session meta save failed: {error}", session_id);
2104 + }
2105 + }
2106 +
2107 log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
2108 Ok(PromptResponse::new(StopReason::EndTurn))
2109 }
@@ -3640,6 +3734,15 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
3734 },
3735 agent_client_protocol::on_receive_request!(),
3736 )
3737 + .on_receive_request(
3738 + {
3739 + let state = Arc::clone(&state);
3740 + async move |req: ListSessionsRequest, responder, _cx: ConnectionTo<Client>| {
3741 + handle_response(responder, state.handle_list_sessions(req).await)
3742 + }
3743 + },
3744 + agent_client_protocol::on_receive_request!(),
3745 + )
3746 // Turn-affecting handlers below run in spawned tasks, serialized by
3747 // `turn_lock`, so the dispatch loop stays free to route client
3748 // responses (permission answers) while a turn is in flight. Awaiting a
@@ -3720,6 +3823,19 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
3823 },
3824 agent_client_protocol::on_receive_request!(),
3825 )
3826 + .on_receive_request(
3827 + {
3828 + let state = Arc::clone(&state);
3829 + async move |req: DeleteSessionRequest, responder, cx: ConnectionTo<Client>| {
3830 + let state = Arc::clone(&state);
3831 + cx.spawn(async move {
3832 + let _turn = state.turn_lock.lock().await;
3833 + handle_response(responder, state.handle_delete_session(req).await)
3834 + })
3835 + }
3836 + },
3837 + agent_client_protocol::on_receive_request!(),
3838 + )
3839 .on_receive_notification(
3840 {
3841 let state = Arc::clone(&state);
@@ -3966,6 +4082,47 @@ mod tests {
4082 assert!(multi.contains("absolute paths"));
4083 }
4084
4085 + #[test]
4086 + fn title_from_history_picks_the_first_user_message() {
4087 + let history = vec![
4088 + serde_json::json!({ "role": "system", "content": "project context" }),
4089 + serde_json::json!({ "role": "user", "content": " what do\nthe notes say? " }),
4090 + serde_json::json!({ "role": "assistant", "content": "The notes say hello." }),
4091 + serde_json::json!({ "role": "user", "content": "a later question" }),
4092 + ];
4093 + assert_eq!(
4094 + title_from_history(&history),
4095 + Some("what do the notes say?".to_string())
4096 + );
4097 +
4098 + assert_eq!(title_from_history(&[]), None);
4099 +
4100 + let no_user = vec![serde_json::json!({ "role": "system", "content": "just context" })];
4101 + assert_eq!(title_from_history(&no_user), None);
4102 +
4103 + let blank = vec![serde_json::json!({ "role": "user", "content": " " })];
4104 + assert_eq!(title_from_history(&blank), None);
4105 + }
4106 +
4107 + #[test]
4108 + fn title_from_history_truncates_long_text_on_a_word_boundary() {
4109 + let text = "a".repeat(50) + " " + &"b".repeat(50);
4110 + let history = vec![serde_json::json!({ "role": "user", "content": text })];
4111 + let title = title_from_history(&history).unwrap();
4112 + assert!(title.ends_with('…'), "{title}");
4113 + assert!(title.chars().count() <= 61, "{title}");
4114 + assert!(
4115 + title.starts_with(&"a".repeat(50)),
4116 + "must truncate on a word boundary rather than mid-word: {title}"
4117 + );
4118 + }
4119 +
4120 + #[test]
4121 + fn rfc3339_formats_a_known_epoch_second() {
4122 + let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
4123 + assert_eq!(rfc3339(time), "2023-11-14T22:13:20+00:00");
4124 + }
4125 +
4126 #[test]
4127 fn history_replay_redraws_the_conversation_for_the_client() {
4128 let history = vec![
src/session_store.rs
+122 -6
@@ -12,6 +12,7 @@
12
13 use std::path::PathBuf;
14
15 +use serde::{Deserialize, Serialize};
16 use serde_json::Value;
17
18 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
@@ -50,6 +51,57 @@ fn session_path(session_id: &str) -> Option<PathBuf> {
51 sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id))))
52 }
53
54 +/// `<sessions_dir>/<id>.meta.json`. Note this does not collide with the
55 +/// `list()` extension filter (`== Some("jsonl")`): the extension of
56 +/// `"a.meta.json"` is `json`, not `jsonl`.
57 +fn meta_path(session_id: &str) -> Option<PathBuf> {
58 + sessions_dir().map(|dir| dir.join(format!("{}.meta.json", sanitize_id(session_id))))
59 +}
60 +
61 +/// ACP-facing metadata for a saved session, kept alongside its transcript so
62 +/// `session/list` can be served without touching the model backend.
63 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64 +pub struct SessionMeta {
65 + pub cwd: PathBuf,
66 + #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 + pub additional_directories: Vec<PathBuf>,
68 + #[serde(default)]
69 + pub title: Option<String>,
70 +}
71 +
72 +/// Persist `meta` for `session_id`, replacing any previous sidecar. Same
73 +/// atomic temp-file + rename pattern as [`save`].
74 +pub fn save_meta(session_id: &str, meta: &SessionMeta) -> Result<(), String> {
75 + let path =
76 + meta_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?;
77 + let dir = path
78 + .parent()
79 + .ok_or_else(|| "session meta path has no parent".to_string())?;
80 + std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?;
81 +
82 + let body = serde_json::to_string(meta).map_err(|error| format!("serialize meta: {error}"))?;
83 +
84 + let tmp = dir.join(format!(
85 + ".{}.{}.tmp",
86 + sanitize_id(session_id),
87 + std::process::id()
88 + ));
89 + std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?;
90 + std::fs::rename(&tmp, &path).map_err(|error| {
91 + let _ = std::fs::remove_file(&tmp);
92 + format!("rename {tmp:?} -> {path:?}: {error}")
93 + })?;
94 + Ok(())
95 +}
96 +
97 +/// Load the saved metadata for `session_id`, or `None` when no sidecar exists
98 +/// (or it cannot be parsed) — matching how [`load`] tolerates junk.
99 +pub fn load_meta(session_id: &str) -> Option<SessionMeta> {
100 + let path = meta_path(session_id)?;
101 + let contents = std::fs::read_to_string(&path).ok()?;
102 + serde_json::from_str(&contents).ok()
103 +}
104 +
105 /// Persist a history snapshot for `session_id`, replacing any previous save.
106 /// The write is atomic: a temp file in the same directory is renamed over the
107 /// final path.
@@ -97,19 +149,21 @@ pub fn load(session_id: &str) -> Option<Vec<Value>> {
149 )
150 }
151
100 -/// Remove the saved history for `session_id`. Missing files are fine.
152 +/// Remove the saved history for `session_id`, along with its metadata
153 +/// sidecar. Missing files are fine.
154 pub fn delete(session_id: &str) {
155 if let Some(path) = session_path(session_id) {
156 let _ = std::fs::remove_file(path);
157 }
158 + if let Some(path) = meta_path(session_id) {
159 + let _ = std::fs::remove_file(path);
160 + }
161 }
162
163 /// One saved session as seen on disk.
164 ///
109 -/// Cross-platform like the rest of the store, though today only the Unix-only
110 -/// TUI (`chat.rs` History tab) consumes it — hence the non-Unix dead-code gate,
111 -/// mirroring `permissions::TUI_SESSION`.
112 -#[cfg_attr(not(unix), allow(dead_code))]
165 +/// Cross-platform: consumed by the Unix-only TUI (`chat.rs` History tab) and
166 +/// by the ACP `session/list` handler (`main.rs`) on every platform.
167 #[derive(Debug, Clone, PartialEq, Eq)]
168 pub struct SessionEntry {
169 /// The sanitized id (the file stem), which `load`/`delete` accept as-is.
@@ -118,12 +172,14 @@ pub struct SessionEntry {
172 pub modified: std::time::SystemTime,
173 /// Number of history messages (non-empty lines) in the file.
174 pub message_count: usize,
175 + /// The metadata sidecar, when one was recorded. `None` for sessions saved
176 + /// before this existed, or for the singleton `tui`/`headless` entries.
177 + pub meta: Option<SessionMeta>,
178 }
179
180 /// List the saved sessions, newest first. Non-`.jsonl` entries (temp files,
181 /// stray junk) are skipped. A missing or unreadable sessions dir yields an
182 /// empty list.
126 -#[cfg_attr(not(unix), allow(dead_code))]
183 pub fn list() -> Vec<SessionEntry> {
184 let Some(dir) = sessions_dir() else {
185 return Vec::new();
@@ -147,10 +203,12 @@ pub fn list() -> Vec<SessionEntry> {
203 .ok()
204 .and_then(|m| m.modified().ok())
205 .unwrap_or(std::time::UNIX_EPOCH);
206 + let meta = load_meta(&id);
207 Some(SessionEntry {
208 id,
209 modified,
210 message_count,
211 + meta,
212 })
213 })
214 .collect();
@@ -251,6 +309,10 @@ mod tests {
309 assert_eq!(listed.len(), 2);
310 assert_eq!(listed[0].id, "newer");
311 assert_eq!(listed[0].message_count, 1);
312 + assert!(
313 + listed[0].meta.is_none(),
314 + "no sidecar was written for either session"
315 + );
316 assert_eq!(listed[1].id, "older");
317 assert_eq!(listed[1].message_count, 3);
318 assert!(listed[0].modified >= listed[1].modified);
@@ -258,4 +320,58 @@ mod tests {
320 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
321 let _ = std::fs::remove_dir_all(&dir);
322 }
323 +
324 + // Same single-test-per-env-var pattern as the tests above.
325 + #[test]
326 + fn meta_round_trips_and_is_removed_with_the_session() {
327 + let _guard = crate::ENV_TEST_LOCK
328 + .lock()
329 + .unwrap_or_else(|poisoned| poisoned.into_inner());
330 + let dir = std::env::temp_dir().join(format!("sigit_sessions_meta_{}", std::process::id()));
331 + let _ = std::fs::remove_dir_all(&dir);
332 + // SAFETY: serialized by ENV_TEST_LOCK; restored below.
333 + unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
334 +
335 + // Missing sidecar → None.
336 + assert_eq!(load_meta("sess-1"), None);
337 +
338 + let meta = SessionMeta {
339 + cwd: PathBuf::from("/tmp/project"),
340 + additional_directories: vec![PathBuf::from("/tmp/other-root")],
341 + title: Some("what do the notes say?".to_string()),
342 + };
343 + save(
344 + "sess-1",
345 + &[serde_json::json!({ "role": "user", "content": "hi" })],
346 + )
347 + .unwrap();
348 + save_meta("sess-1", &meta).unwrap();
349 + assert_eq!(load_meta("sess-1"), Some(meta.clone()));
350 +
351 + // A sidecar with no title round-trips too.
352 + let untitled = SessionMeta {
353 + cwd: PathBuf::from("/tmp/project"),
354 + additional_directories: vec![],
355 + title: None,
356 + };
357 + save_meta("sess-1", &untitled).unwrap();
358 + assert_eq!(load_meta("sess-1"), Some(untitled));
359 +
360 + // list() surfaces the sidecar for a session that has one.
361 + let listed = list();
362 + assert_eq!(listed.len(), 1);
363 + assert_eq!(
364 + listed[0].meta.as_ref().map(|m| &m.cwd),
365 + Some(&PathBuf::from("/tmp/project"))
366 + );
367 +
368 + // delete() removes both the transcript and the sidecar.
369 + delete("sess-1");
370 + assert_eq!(load("sess-1"), None);
371 + assert_eq!(load_meta("sess-1"), None);
372 + assert!(list().is_empty());
373 +
374 + unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
375 + let _ = std::fs::remove_dir_all(&dir);
376 + }
377 }
tests/acp_session_list.rs new
+282
@@ -0,0 +1,282 @@
1 +//! End-to-end check that `session/list` and `session/delete` work over ACP.
2 +//!
3 +//! Zed's "Import Threads" picker calls `session/list` for every configured
4 +//! agent; siGit Code advertised no capability for it, so the entry showed a
5 +//! warning triangle even though the transcripts were on disk all along
6 +//! (issue #14). This drives the real binary: list before anything ran, run
7 +//! one prompt, list again filtered by cwd, then delete and confirm both the
8 +//! transcript and its metadata sidecar are gone.
9 +
10 +use std::collections::VecDeque;
11 +use std::io::{BufRead, BufReader, Read, Write};
12 +use std::net::TcpListener;
13 +use std::process::{Child, ChildStdin, Command, Stdio};
14 +use std::sync::Mutex;
15 +use std::sync::mpsc::{Receiver, channel};
16 +use std::time::{Duration, Instant};
17 +
18 +use serde_json::{Value, json};
19 +
20 +const TIMEOUT: Duration = Duration::from_secs(60);
21 +
22 +// ── Scripted OpenAI-compatible endpoint ─────────────────────────────────────
23 +
24 +fn sse_body(events: &[Value]) -> String {
25 + let mut body = String::new();
26 + for event in events {
27 + body.push_str("data: ");
28 + body.push_str(&event.to_string());
29 + body.push_str("\n\n");
30 + }
31 + body.push_str("data: [DONE]\n\n");
32 + body
33 +}
34 +
35 +fn sse_text(text: &str) -> String {
36 + sse_body(&[json!({"choices": [{"delta": {"content": text}}]})])
37 +}
38 +
39 +/// Serves one scripted SSE response per request.
40 +fn start_fake_endpoint(responses: Vec<String>) -> u16 {
41 + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
42 + let port = listener.local_addr().unwrap().port();
43 + let queue = Mutex::new(VecDeque::from(responses));
44 +
45 + std::thread::spawn(move || {
46 + for stream in listener.incoming() {
47 + let Ok(mut stream) = stream else { continue };
48 + let mut reader = BufReader::new(match stream.try_clone() {
49 + Ok(clone) => clone,
50 + Err(_) => continue,
51 + });
52 + let mut content_length = 0usize;
53 + loop {
54 + let mut line = String::new();
55 + if reader.read_line(&mut line).unwrap_or(0) == 0 {
56 + break;
57 + }
58 + let line = line.trim();
59 + if line.is_empty() {
60 + break;
61 + }
62 + if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
63 + content_length = length.trim().parse().unwrap_or(0);
64 + }
65 + }
66 + let mut body = vec![0u8; content_length];
67 + if reader.read_exact(&mut body).is_err() {
68 + continue;
69 + }
70 + let payload = queue
71 + .lock()
72 + .unwrap()
73 + .pop_front()
74 + .unwrap_or_else(|| "data: [DONE]\n\n".to_string());
75 + let response = format!(
76 + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
77 + content-length: {}\r\nconnection: close\r\n\r\n{}",
78 + payload.len(),
79 + payload
80 + );
81 + let _ = stream.write_all(response.as_bytes());
82 + }
83 + });
84 +
85 + port
86 +}
87 +
88 +// ── The agent under test ────────────────────────────────────────────────────
89 +
90 +struct AgentUnderTest {
91 + child: Child,
92 + stdin: ChildStdin,
93 + incoming: Receiver<Value>,
94 + next_id: u64,
95 +}
96 +
97 +fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
98 + let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
99 + .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
100 + .env("OPENAI_API_KEY", "test-key")
101 + .env("SIGIT_MODEL", "scripted-model")
102 + .env("SIGIT_CONFIG_DIR", config_dir)
103 + .env("SIGIT_MCP", "off")
104 + .env("SIGIT_PERMISSIONS", "allow")
105 + .env_remove("SIGIT_LOCAL_INFERENCE")
106 + .stdin(Stdio::piped())
107 + .stdout(Stdio::piped())
108 + .stderr(Stdio::null())
109 + .spawn()
110 + .expect("spawn sigit in ACP mode");
111 +
112 + let stdout = child.stdout.take().unwrap();
113 + let (message_tx, incoming) = channel();
114 + std::thread::spawn(move || {
115 + for line in BufReader::new(stdout).lines() {
116 + let Ok(line) = line else { break };
117 + if let Ok(message) = serde_json::from_str::<Value>(&line)
118 + && message_tx.send(message).is_err()
119 + {
120 + break;
121 + }
122 + }
123 + });
124 +
125 + let stdin = child.stdin.take().unwrap();
126 + AgentUnderTest {
127 + child,
128 + stdin,
129 + incoming,
130 + next_id: 0,
131 + }
132 +}
133 +
134 +impl AgentUnderTest {
135 + fn request(&mut self, method: &str, params: Value) -> u64 {
136 + self.next_id += 1;
137 + let id = self.next_id;
138 + let mut line =
139 + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
140 + line.push('\n');
141 + self.stdin
142 + .write_all(line.as_bytes())
143 + .expect("write to agent stdin");
144 + self.stdin.flush().expect("flush agent stdin");
145 + id
146 + }
147 +
148 + /// The response to one of our requests. `session/update` notifications
149 + /// that arrive along the way are dropped — this test only cares about
150 + /// list/delete responses, not turn replay.
151 + fn wait_for_response(&mut self, id: u64) -> Value {
152 + let deadline = Instant::now() + TIMEOUT;
153 + loop {
154 + let remaining = deadline.saturating_duration_since(Instant::now());
155 + let Ok(message) = self.incoming.recv_timeout(remaining) else {
156 + panic!("timed out waiting for the response to request {id}");
157 + };
158 + if message["id"] == id && message.get("method").is_none() {
159 + assert!(
160 + message.get("error").is_none(),
161 + "request {id} failed: {message}"
162 + );
163 + return message;
164 + }
165 + }
166 + }
167 +}
168 +
169 +impl Drop for AgentUnderTest {
170 + fn drop(&mut self) {
171 + let _ = self.child.kill();
172 + let _ = self.child.wait();
173 + }
174 +}
175 +
176 +#[test]
177 +fn listing_and_deleting_sessions_over_acp() {
178 + let scratch = std::env::temp_dir().join(format!("sigit_acp_list_{}", std::process::id()));
179 + let config_dir = scratch.join("config");
180 + let project = scratch.join("project");
181 + let elsewhere = scratch.join("elsewhere");
182 + std::fs::create_dir_all(&config_dir).unwrap();
183 + std::fs::create_dir_all(&project).unwrap();
184 + std::fs::create_dir_all(&elsewhere).unwrap();
185 +
186 + let port = start_fake_endpoint(vec![sse_text("The notes say hello.")]);
187 + let mut agent = spawn_agent(port, &config_dir);
188 +
189 + let id = agent.request(
190 + "initialize",
191 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
192 + );
193 + let initialize = agent.wait_for_response(id);
194 + let session_caps = &initialize["result"]["agentCapabilities"]["sessionCapabilities"];
195 + assert!(
196 + session_caps.get("list").is_some(),
197 + "session/list capability must be advertised: {initialize}"
198 + );
199 + assert!(
200 + session_caps.get("delete").is_some(),
201 + "session/delete capability must be advertised: {initialize}"
202 + );
203 +
204 + // Before anything ran, the list is empty.
205 + let id = agent.request("session/list", json!({}));
206 + let listed = agent.wait_for_response(id);
207 + assert_eq!(
208 + listed["result"]["sessions"].as_array().unwrap().len(),
209 + 0,
210 + "no sessions yet: {listed}"
211 + );
212 +
213 + let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
214 + let new_session = agent.wait_for_response(id);
215 + let session_id = new_session["result"]["sessionId"]
216 + .as_str()
217 + .expect("session id")
218 + .to_string();
219 +
220 + let id = agent.request(
221 + "session/prompt",
222 + json!({
223 + "sessionId": session_id,
224 + "prompt": [{"type": "text", "text": "what do the notes say?"}],
225 + }),
226 + );
227 + agent.wait_for_response(id);
228 +
229 + // Listing filtered by the project's cwd finds exactly this session.
230 + let id = agent.request("session/list", json!({"cwd": project}));
231 + let listed = agent.wait_for_response(id);
232 + let sessions = listed["result"]["sessions"].as_array().unwrap();
233 + assert_eq!(sessions.len(), 1, "expected one session: {listed}");
234 + let entry = &sessions[0];
235 + assert_eq!(entry["sessionId"], session_id);
236 + assert_eq!(entry["cwd"], project.to_string_lossy().to_string());
237 + assert_eq!(entry["title"], "what do the notes say?");
238 + let updated_at = entry["updatedAt"].as_str().expect("updatedAt present");
239 + assert!(
240 + chrono::DateTime::parse_from_rfc3339(updated_at).is_ok(),
241 + "updatedAt must be RFC 3339: {updated_at}"
242 + );
243 +
244 + // Filtered by a different cwd, the list is empty.
245 + let id = agent.request("session/list", json!({"cwd": elsewhere}));
246 + let listed = agent.wait_for_response(id);
247 + assert_eq!(
248 + listed["result"]["sessions"].as_array().unwrap().len(),
249 + 0,
250 + "wrong cwd must not match: {listed}"
251 + );
252 +
253 + // Delete it, then confirm it's gone from the list and off disk.
254 + let id = agent.request("session/delete", json!({"sessionId": session_id}));
255 + let deleted = agent.wait_for_response(id);
256 + assert!(deleted.get("error").is_none(), "delete failed: {deleted}");
257 +
258 + let id = agent.request("session/list", json!({"cwd": project}));
259 + let listed = agent.wait_for_response(id);
260 + assert_eq!(
261 + listed["result"]["sessions"].as_array().unwrap().len(),
262 + 0,
263 + "deleted session must not be listed: {listed}"
264 + );
265 +
266 + let sessions_dir = config_dir.join("sessions");
267 + assert!(
268 + std::fs::read_dir(&sessions_dir)
269 + .into_iter()
270 + .flatten()
271 + .flatten()
272 + .all(|entry| {
273 + let name = entry.file_name();
274 + let name = name.to_string_lossy();
275 + !name.contains(&session_id)
276 + }),
277 + "both the transcript and its meta sidecar must be removed"
278 + );
279 +
280 + drop(agent);
281 + let _ = std::fs::remove_dir_all(&scratch);
282 +}