Let editors import saved threads through session/list

Zed's "Import Threads" picker refused siGit Code with "this agent doesn't support ACP's session/list capability" (issue #93), even though every ACP session is already persisted under ~/.config/sigit/sessions/ and reopens through session/load. Listing needs something history alone cannot give: SessionInfo must carry an absolute cwd, and the request may filter on it. So a save now also writes a <id>.meta.json sidecar naming the session's cwd and the extra roots of a multi-root project, and session/list reports that pair plus an ISO 8601 last-activity timestamp and a title taken from the first user message. The sidecar is written at save time rather than at session start, so a thread nobody spoke in leaves nothing behind, and deleting a session takes its sidecar with it. Threads saved before this have no sidecar and are skipped by the picker instead of being given a guessed directory; they still reopen by id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Seto Elkahfi committed Sep 16, 2026 at 12:23 UTC 147e3dc0befbb66811d85ea2a78e76321c55816f
6 files changed +620 -44
.agents/AGENTS.md
+9
@@ -249,6 +249,15 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
249 any session exists, so a second root's `.sigit/mcp.toml` has nobody to tell.
250 - **`src/chat.rs`** — the Unix-only ratatui TUI. Loading-spinner phase then chat; uses
251 `tokio::select!` to multiplex terminal events with streaming tokens.
252 +- **`src/session_store.rs`** — durable conversations: one JSON-lines history file per session at
253 + `$SIGIT_CONFIG_DIR/sessions/<id>.jsonl`, written atomically, restorable into either backend.
254 + Each save also writes a `<id>.meta.json` sidecar naming the session's `cwd` and the extra roots
255 + of a multi-root project. That sidecar is what makes a thread *listable*: ACP's `session/list`
256 + (advertised as `sessionCapabilities.list`, handled by `handle_list_sessions` in `main.rs` — the
257 + editor's "Import Threads" picker) must report an absolute `cwd` per session and may filter on
258 + it, so a session without one is skipped there while still reopening by id through
259 + `session/load`. Sidecars are written at save time, not at session start, so a thread nobody
260 + spoke in leaves nothing behind.
261 - **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
262 Must run (`setup_shared_model_cache`) *before* anything touches `ChatEngine`/`hf-hub`, since
263 those read env vars once at init.
.agents/skills/agent-client-protocol/SKILL.md
+11 -1
@@ -196,6 +196,7 @@ the connection and the agent anymore. Don't reintroduce the mpsc forwarder patte
196 | `NewSessionRequest` | `handle_new_session` | sets cwd, resets history, advertises commands + config options |
197 | `LoadSessionRequest` | `handle_load_session` | like new_session; gated by `load_session(true)` capability |
198 | `ForkSessionRequest` | `handle_fork_session` | gated by `unstable_session_fork` + `SessionForkCapabilities` |
199 +| `ListSessionsRequest` | `handle_list_sessions` | the editor's "Import Threads" picker; gated by `SessionListCapabilities` |
200 | `PromptRequest` | `handle_prompt` | the turn: parse blocks → slash commands or tool-calling loop |
201 | `SetSessionConfigOptionRequest` | `handle_set_session_config_option` | the Zed model picker — switches/downloads models |
202 | `CancelNotification` | `handle_cancel` | notification, no response |
@@ -226,7 +227,8 @@ Ok(InitializeResponse::new(ProtocolVersion::V1) // use V1, not args.proto
227 .load_session(true) // enables LoadSessionRequest
228 .session_capabilities(
229 SessionCapabilities::new()
229 - .fork(SessionForkCapabilities::new()), // enables ForkSessionRequest
230 + .fork(SessionForkCapabilities::new()) // enables ForkSessionRequest
231 + .list(SessionListCapabilities::new()), // enables ListSessionsRequest
232 ),
233 )
234 .meta(initialize_meta())) // free-form Meta (see below)
@@ -573,6 +575,14 @@ Editor Agent
575 12. **Store `SessionId` as `SessionId`**, not `String`, so `==` is clean.
576 13. **`SetSessionConfigOptionResponse::new(config_options)`** — the response
577 carries the *rebuilt* options so the picker reflects the new current value.
578 +14. **`session/list` needs a `cwd` per session, which history alone can't
579 + supply.** `SessionInfo` requires an absolute `cwd` and the request may
580 + filter on it, so `session_store` writes a `<id>.meta.json` sidecar next to
581 + each saved thread; sessions without one are skipped by
582 + `handle_list_sessions` rather than guessed at. Advertising
583 + `SessionCapabilities::new().list(SessionListCapabilities::new())` is what
584 + turns the editor's "Import Threads" picker from an error banner into a
585 + list.
586
587 ---
588
CHANGELOG.md
+15
@@ -1,5 +1,20 @@
1 # Changelog
2
3 +## Unreleased
4 +
5 +### Added
6 +
7 +- **Saved threads can be imported into the editor.** siGit Code now advertises
8 + ACP's `sessionCapabilities.list` and answers `session/list`, so Zed's "Import
9 + Threads" picker offers the conversations stored under
10 + `~/.config/sigit/sessions/` instead of reporting that the agent doesn't
11 + support the capability. A saved session gets a sidecar recording the project
12 + directory it ran in — listing reports that `cwd`, the extra roots of a
13 + multi-root project, an ISO 8601 last-activity timestamp, and a title taken
14 + from the first user message — and a request may filter on `cwd` so one
15 + project is never offered another's threads. Threads saved before this have no
16 + sidecar and are not listed; they still reopen by id through `session/load`
17 +
18 ## 1.5.9
19
20 ### Fixed
src/main.rs
+96 -16
@@ -73,16 +73,16 @@ 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,
80 - SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionConfigOption,
81 - SessionConfigOptionCategory, SessionConfigSelectOption, SessionConfigValueId,
82 - SessionForkCapabilities, SessionId, SessionNotification, SessionUpdate,
83 - SetSessionConfigOptionRequest, SetSessionConfigOptionResponse, StopReason, ToolCall,
84 - ToolCallContent, ToolCallLocation, ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields,
85 - ToolKind, UnstructuredCommandInput,
76 + ForkSessionResponse, Implementation, InitializeRequest, InitializeResponse,
77 + ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, LoadSessionResponse, Meta,
78 + NewSessionRequest, NewSessionResponse, PermissionOption, PermissionOptionKind, Plan, PlanEntry,
79 + PlanEntryPriority, PlanEntryStatus, PromptRequest, PromptResponse, RequestPermissionOutcome,
80 + RequestPermissionRequest, SessionAdditionalDirectoriesCapabilities, SessionCapabilities,
81 + SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
82 + SessionConfigValueId, SessionForkCapabilities, SessionId, SessionInfo, SessionListCapabilities,
83 + SessionNotification, SessionUpdate, SetSessionConfigOptionRequest,
84 + SetSessionConfigOptionResponse, StopReason, ToolCall, ToolCallContent, ToolCallLocation,
85 + ToolCallStatus, ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
86 };
87 use agent_client_protocol::{Agent, ByteStreams, Client, ConnectionTo, Responder};
88 use onde::inference::{ChatEngine, GgufModelConfig};
@@ -445,6 +445,12 @@ fn history_message_text(message: &serde_json::Value) -> String {
445 }
446 }
447
448 +/// Compare directories the way `session/list` filtering needs: resolve symlinks
449 +/// and `..` when the path exists, otherwise fall back to the path as given.
450 +fn normalize_dir(path: &std::path::Path) -> PathBuf {
451 + std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
452 +}
453 +
454 /// Rebuild the `session/update` notifications that re-draw a saved conversation
455 /// in the client.
456 ///
@@ -1342,6 +1348,11 @@ impl SiGitAgent {
1348 .session_capabilities(
1349 SessionCapabilities::new()
1350 .fork(SessionForkCapabilities::new())
1351 + // Durable sessions (`session_store`) are what makes
1352 + // listing meaningful: without this the editor's
1353 + // "Import Threads" picker reports that the agent
1354 + // doesn't support ACP's session/list capability.
1355 + .list(SessionListCapabilities::new())
1356 // Without this, a client that has several directories
1357 // open never sends the extra ones: Zed drops every root
1358 // but the first and tells the user the agent has no
@@ -1418,6 +1429,70 @@ impl SiGitAgent {
1429 additional_roots
1430 }
1431
1432 + /// Save a session's history plus the sidecar that says where it ran.
1433 + ///
1434 + /// The sidecar is written here rather than at session start so a thread the
1435 + /// user never spoke in doesn't leave one behind, and so a listed session
1436 + /// always has history to import.
1437 + async fn persist_session(&self, session_id: &SessionId, snapshot: &[serde_json::Value]) {
1438 + let key = session_id.to_string();
1439 + if let Err(error) = session_store::save(&key, snapshot) {
1440 + log::warn!("session({session_id}) save failed: {error}");
1441 + return;
1442 + }
1443 + let cwd = self
1444 + .session_cwd
1445 + .lock()
1446 + .ok()
1447 + .and_then(|guard| guard.clone())
1448 + .or_else(|| std::env::current_dir().ok());
1449 + if let Some(cwd) = cwd {
1450 + session_store::save_meta(&key, &cwd, &workspace::additional_roots());
1451 + }
1452 + }
1453 +
1454 + /// ACP `session/list`: the saved threads an editor can reopen.
1455 + ///
1456 + /// Only sessions with a recorded working directory qualify — `SessionInfo`
1457 + /// must carry an absolute `cwd`, and the request may filter on it. Threads
1458 + /// saved before sidecars existed have none and are skipped; they still load
1459 + /// by id. Everything is returned in one page, so there is no cursor.
1460 + async fn handle_list_sessions(
1461 + &self,
1462 + args: ListSessionsRequest,
1463 + ) -> agent_client_protocol::Result<ListSessionsResponse> {
1464 + let filter = args.cwd.as_ref().map(|cwd| normalize_dir(cwd));
1465 + let sessions: Vec<SessionInfo> = session_store::list()
1466 + .into_iter()
1467 + .filter(|entry| entry.message_count > 0)
1468 + .filter_map(|entry| {
1469 + let cwd = entry.cwd?;
1470 + if let Some(filter) = &filter
1471 + && normalize_dir(&cwd) != *filter
1472 + {
1473 + return None;
1474 + }
1475 + Some(
1476 + SessionInfo::new(SessionId::new(entry.id), cwd)
1477 + .additional_directories(entry.additional_directories)
1478 + .title(entry.title)
1479 + .updated_at(session_store::iso8601(entry.modified)),
1480 + )
1481 + })
1482 + .collect();
1483 +
1484 + log::info!(
1485 + "list_sessions: {} session(s){}",
1486 + sessions.len(),
1487 + args.cwd
1488 + .as_ref()
1489 + .map(|cwd| format!(" for cwd={}", cwd.display()))
1490 + .unwrap_or_default()
1491 + );
1492 +
1493 + Ok(ListSessionsResponse::new(sessions))
1494 + }
1495 +
1496 async fn handle_load_session(
1497 &self,
1498 cx: &ConnectionTo<Client>,
@@ -2101,9 +2176,7 @@ impl SiGitAgent {
2176 // Persist the completed turn so a restart (or session/load) can pick
2177 // the conversation back up.
2178 let snapshot = backend.history_snapshot().await;
2104 - if let Err(error) = session_store::save(&session_id.to_string(), &snapshot) {
2105 - log::warn!("prompt({}) session save failed: {error}", session_id);
2106 - }
2179 + self.persist_session(&session_id, &snapshot).await;
2180
2181 log::info!("prompt({}) complete — {} tool round(s)", session_id, round);
2182 Ok(PromptResponse::new(StopReason::EndTurn))
@@ -3228,9 +3301,7 @@ async fn exec_slash_acp(
3301 let snapshot = backend.history_snapshot().await;
3302 let after = backend::estimate_tokens(&snapshot);
3303 // Keep the saved session in step with the compacted state.
3231 - if let Err(error) = session_store::save(&session_id.to_string(), &snapshot) {
3232 - log::warn!("session save after /compact failed: {error}");
3233 - }
3304 + agent.persist_session(&session_id, &snapshot).await;
3305 format!("Compacted history: ~{before} → ~{after} tokens (estimated).")
3306 }
3307 Err(error) => format!("Compaction failed: {error}"),
@@ -3865,6 +3936,15 @@ async fn run_acp_server(auto_load_local_model: bool) -> anyhow::Result<()> {
3936 },
3937 agent_client_protocol::on_receive_request!(),
3938 )
3939 + .on_receive_request(
3940 + {
3941 + let state = Arc::clone(&state);
3942 + async move |req: ListSessionsRequest, responder, _cx: ConnectionTo<Client>| {
3943 + handle_response(responder, state.handle_list_sessions(req).await)
3944 + }
3945 + },
3946 + agent_client_protocol::on_receive_request!(),
3947 + )
3948 // Turn-affecting handlers below run in spawned tasks, serialized by
3949 // `turn_lock`, so the dispatch loop stays free to route client
3950 // responses (permission answers) while a turn is in flight. Awaiting a
src/session_store.rs
+243 -27
@@ -6,12 +6,20 @@
6 //! as produced by `InferenceBackend::history_snapshot`, so a saved file can be
7 //! restored into either backend.
8 //!
9 +//! A session also carries a small sidecar at `<id>.meta.json` holding the
10 +//! directories it ran in. History alone can't say where a thread belongs, and
11 +//! ACP's `session/list` — the "Import Threads" picker in editors — must report
12 +//! an absolute `cwd` per session and may ask for only the sessions of one
13 +//! directory. A session without a sidecar is therefore not listable (it still
14 +//! loads fine); that is the case for threads saved before sidecars existed.
15 +//!
16 //! Writes are atomic (temp file + rename) so a crash mid-save never leaves a
17 //! truncated session behind. Session ids are sanitized to a filename-safe
18 //! alphabet before touching the filesystem.
19
13 -use std::path::PathBuf;
20 +use std::path::{Path, PathBuf};
21
22 +use serde::{Deserialize, Serialize};
23 use serde_json::Value;
24
25 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
@@ -50,36 +58,81 @@ fn session_path(session_id: &str) -> Option<PathBuf> {
58 sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id))))
59 }
60
53 -/// Persist a history snapshot for `session_id`, replacing any previous save.
54 -/// The write is atomic: a temp file in the same directory is renamed over the
55 -/// final path.
56 -pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> {
57 - let path =
58 - session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?;
61 +fn meta_path(session_id: &str) -> Option<PathBuf> {
62 + sessions_dir().map(|dir| dir.join(format!("{}.meta.json", sanitize_id(session_id))))
63 +}
64 +
65 +/// Write `body` to `path` through a uniquely named temp file in the same
66 +/// directory, then rename it into place. The unique name keeps two processes
67 +/// saving the same session from clobbering each other's half-written file;
68 +/// rename is atomic on the same filesystem.
69 +fn write_atomic(path: &Path, session_id: &str, body: &str) -> Result<(), String> {
70 let dir = path
71 .parent()
72 .ok_or_else(|| "session path has no parent".to_string())?;
73 std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?;
74
64 - let mut body = String::new();
65 - for message in history {
66 - body.push_str(&message.to_string());
67 - body.push('\n');
68 - }
69 -
70 - // Unique temp name so two processes saving the same session can't clobber
71 - // each other's half-written file; rename is atomic on the same filesystem.
75 let tmp = dir.join(format!(
76 ".{}.{}.tmp",
77 sanitize_id(session_id),
78 std::process::id()
79 ));
80 std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?;
78 - std::fs::rename(&tmp, &path).map_err(|error| {
81 + std::fs::rename(&tmp, path).map_err(|error| {
82 let _ = std::fs::remove_file(&tmp);
83 format!("rename {tmp:?} -> {path:?}: {error}")
81 - })?;
82 - Ok(())
84 + })
85 +}
86 +
87 +/// The directories a saved session ran in, as stored in its sidecar.
88 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
89 +pub struct SessionMeta {
90 + /// The session's primary working directory (absolute).
91 + pub cwd: PathBuf,
92 + /// Extra workspace roots of a multi-root project, in order.
93 + #[serde(default)]
94 + pub additional_directories: Vec<PathBuf>,
95 +}
96 +
97 +/// Record where `session_id` is running so it can be listed later. Cheap enough
98 +/// to call on every save; the sidecar is a few hundred bytes.
99 +pub fn save_meta(session_id: &str, cwd: &Path, additional_directories: &[PathBuf]) {
100 + let Some(path) = meta_path(session_id) else {
101 + return;
102 + };
103 + let meta = SessionMeta {
104 + cwd: cwd.to_path_buf(),
105 + additional_directories: additional_directories.to_vec(),
106 + };
107 + let Ok(body) = serde_json::to_string(&meta) else {
108 + return;
109 + };
110 + if let Err(error) = write_atomic(&path, session_id, &body) {
111 + log::warn!("session meta save failed for {session_id}: {error}");
112 + }
113 +}
114 +
115 +/// Read the sidecar for `session_id`, or `None` when there isn't a readable one.
116 +fn load_meta(session_id: &str) -> Option<SessionMeta> {
117 + let path = meta_path(session_id)?;
118 + let contents = std::fs::read_to_string(&path).ok()?;
119 + serde_json::from_str(&contents).ok()
120 +}
121 +
122 +/// Persist a history snapshot for `session_id`, replacing any previous save.
123 +/// The write is atomic: a temp file in the same directory is renamed over the
124 +/// final path.
125 +pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> {
126 + let path =
127 + session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?;
128 +
129 + let mut body = String::new();
130 + for message in history {
131 + body.push_str(&message.to_string());
132 + body.push('\n');
133 + }
134 +
135 + write_atomic(&path, session_id, &body)
136 }
137
138 /// Load the saved history for `session_id`, or `None` when no save exists (or
@@ -97,19 +150,21 @@ pub fn load(session_id: &str) -> Option<Vec<Value>> {
150 )
151 }
152
100 -/// Remove the saved history for `session_id`. Missing files are fine.
153 +/// Remove the saved history for `session_id`, sidecar included. Missing files
154 +/// are fine.
155 pub fn delete(session_id: &str) {
156 if let Some(path) = session_path(session_id) {
157 let _ = std::fs::remove_file(path);
158 }
159 + if let Some(path) = meta_path(session_id) {
160 + let _ = std::fs::remove_file(path);
161 + }
162 }
163
164 /// One saved session as seen on disk.
165 ///
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))]
166 +/// Consumed by the Unix-only TUI (`chat.rs` History tab) and, on every
167 +/// platform, by the ACP `session/list` handler in `main.rs`.
168 #[derive(Debug, Clone, PartialEq, Eq)]
169 pub struct SessionEntry {
170 /// The sanitized id (the file stem), which `load`/`delete` accept as-is.
@@ -118,12 +173,60 @@ pub struct SessionEntry {
173 pub modified: std::time::SystemTime,
174 /// Number of history messages (non-empty lines) in the file.
175 pub message_count: usize,
176 + /// The session's working directory, when a sidecar recorded one.
177 + pub cwd: Option<PathBuf>,
178 + /// Extra workspace roots recorded alongside `cwd`.
179 + pub additional_directories: Vec<PathBuf>,
180 + /// A one-line title taken from the first user message, when there is one.
181 + pub title: Option<String>,
182 +}
183 +
184 +/// Longest title handed out by [`session_title`] before it is cut short.
185 +const TITLE_MAX_CHARS: usize = 80;
186 +
187 +/// The plain text of a history message, whether `content` is a string or the
188 +/// structured `[{ "type": "text", "text": … }]` form.
189 +fn message_text(message: &Value) -> String {
190 + match &message["content"] {
191 + Value::String(text) => text.clone(),
192 + Value::Array(blocks) => blocks
193 + .iter()
194 + .filter_map(|block| block["text"].as_str())
195 + .collect::<Vec<_>>()
196 + .join("\n"),
197 + _ => String::new(),
198 + }
199 +}
200 +
201 +/// A human-readable title for a session: the first line of its first user
202 +/// message, trimmed and cut to [`TITLE_MAX_CHARS`].
203 +fn session_title(contents: &str) -> Option<String> {
204 + for line in contents.lines() {
205 + if line.trim().is_empty() {
206 + continue;
207 + }
208 + let Ok(message) = serde_json::from_str::<Value>(line) else {
209 + continue;
210 + };
211 + if message["role"] != "user" {
212 + continue;
213 + }
214 + let text = message_text(&message);
215 + let Some(first_line) = text.lines().map(str::trim).find(|l| !l.is_empty()) else {
216 + continue;
217 + };
218 + let mut title: String = first_line.chars().take(TITLE_MAX_CHARS).collect();
219 + if first_line.chars().count() > TITLE_MAX_CHARS {
220 + title.push('…');
221 + }
222 + return Some(title);
223 + }
224 + None
225 }
226
227 /// List the saved sessions, newest first. Non-`.jsonl` entries (temp files,
124 -/// stray junk) are skipped. A missing or unreadable sessions dir yields an
125 -/// empty list.
126 -#[cfg_attr(not(unix), allow(dead_code))]
228 +/// sidecars, stray junk) are skipped. A missing or unreadable sessions dir
229 +/// yields an empty list.
230 pub fn list() -> Vec<SessionEntry> {
231 let Some(dir) = sessions_dir() else {
232 return Vec::new();
@@ -139,18 +242,26 @@ pub fn list() -> Vec<SessionEntry> {
242 return None;
243 }
244 let id = path.file_stem()?.to_str()?.to_string();
142 - // Cheap line count: no JSON parsing, just non-empty lines.
245 let contents = std::fs::read_to_string(&path).ok()?;
246 let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count();
247 + let title = session_title(&contents);
248 let modified = entry
249 .metadata()
250 .ok()
251 .and_then(|m| m.modified().ok())
252 .unwrap_or(std::time::UNIX_EPOCH);
253 + let meta = load_meta(&id);
254 + let (cwd, additional_directories) = match meta {
255 + Some(meta) => (Some(meta.cwd), meta.additional_directories),
256 + None => (None, Vec::new()),
257 + };
258 Some(SessionEntry {
259 id,
260 modified,
261 message_count,
262 + cwd,
263 + additional_directories,
264 + title,
265 })
266 })
267 .collect();
@@ -158,6 +269,38 @@ pub fn list() -> Vec<SessionEntry> {
269 sessions
270 }
271
272 +/// Format a timestamp as an ISO 8601 UTC instant (`2026-09-16T10:08:15Z`),
273 +/// which is what ACP's `SessionInfo.updatedAt` expects. Hand-rolled rather than
274 +/// pulling in a date crate for one field; pre-epoch times clamp to the epoch.
275 +pub fn iso8601(time: std::time::SystemTime) -> String {
276 + let secs = time
277 + .duration_since(std::time::UNIX_EPOCH)
278 + .map(|d| d.as_secs())
279 + .unwrap_or(0);
280 + let days = (secs / 86_400) as i64;
281 + let seconds_of_day = secs % 86_400;
282 + let (hour, minute, second) = (
283 + seconds_of_day / 3600,
284 + (seconds_of_day % 3600) / 60,
285 + seconds_of_day % 60,
286 + );
287 +
288 + // Howard Hinnant's civil_from_days: shift the epoch to 0000-03-01 so leap
289 + // days land at the end of the era and the month arithmetic stays branchless.
290 + let z = days + 719_468;
291 + let era = z.div_euclid(146_097);
292 + let day_of_era = z.rem_euclid(146_097);
293 + let year_of_era =
294 + (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
295 + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
296 + let mp = (5 * day_of_year + 2) / 153;
297 + let day = day_of_year - (153 * mp + 2) / 5 + 1;
298 + let month = if mp < 10 { mp + 3 } else { mp - 9 };
299 + let year = year_of_era + era * 400 + i64::from(month <= 2);
300 +
301 + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
302 +}
303 +
304 #[cfg(test)]
305 mod tests {
306 use super::*;
@@ -254,8 +397,81 @@ mod tests {
397 assert_eq!(listed[1].id, "older");
398 assert_eq!(listed[1].message_count, 3);
399 assert!(listed[0].modified >= listed[1].modified);
400 + // Titles come from the first user message; no sidecar means no cwd.
401 + assert_eq!(listed[0].title.as_deref(), Some("x"));
402 + assert_eq!(listed[1].title.as_deref(), Some("a"));
403 + assert_eq!(listed[0].cwd, None);
404 + assert!(listed[0].additional_directories.is_empty());
405 +
406 + // A sidecar makes the session listable with its directories, and
407 + // deleting the session takes the sidecar with it.
408 + save_meta(
409 + "newer",
410 + Path::new("/tmp/project"),
411 + &[PathBuf::from("/tmp/lib")],
412 + );
413 + let listed = list();
414 + assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/tmp/project")));
415 + assert_eq!(
416 + listed[0].additional_directories,
417 + vec![PathBuf::from("/tmp/lib")]
418 + );
419 + assert!(sessions.join("newer.meta.json").is_file());
420 + delete("newer");
421 + assert!(!sessions.join("newer.meta.json").exists());
422 + assert_eq!(list().len(), 1);
423
424 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
425 let _ = std::fs::remove_dir_all(&dir);
426 }
427 +
428 + #[test]
429 + fn title_uses_the_first_line_of_the_first_user_message() {
430 + let contents = "{\"role\":\"system\",\"content\":\"sys\"}\n\
431 + {\"role\":\"user\",\"content\":\" fix the parser\\nmore detail\"}\n\
432 + {\"role\":\"user\",\"content\":\"second\"}\n";
433 + assert_eq!(session_title(contents).as_deref(), Some("fix the parser"));
434 +
435 + // Structured content blocks, not just plain strings.
436 + let blocks =
437 + "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"from a block\"}]}\n";
438 + assert_eq!(session_title(blocks).as_deref(), Some("from a block"));
439 +
440 + // Unparseable lines are skipped, not fatal.
441 + let junk = "not json\n{\"role\":\"user\",\"content\":\"still found\"}\n";
442 + assert_eq!(session_title(junk).as_deref(), Some("still found"));
443 +
444 + // Long titles are cut short with an ellipsis.
445 + let long = format!(
446 + "{{\"role\":\"user\",\"content\":\"{}\"}}\n",
447 + "x".repeat(TITLE_MAX_CHARS + 10)
448 + );
449 + let title = session_title(&long).unwrap();
450 + assert_eq!(title.chars().count(), TITLE_MAX_CHARS + 1);
451 + assert!(title.ends_with('…'));
452 +
453 + // Nothing from the user yet → no title.
454 + assert_eq!(
455 + session_title("{\"role\":\"assistant\",\"content\":\"hi\"}\n"),
456 + None
457 + );
458 + assert_eq!(session_title(""), None);
459 + }
460 +
461 + #[test]
462 + fn iso8601_formats_utc_instants() {
463 + use std::time::{Duration, UNIX_EPOCH};
464 +
465 + assert_eq!(iso8601(UNIX_EPOCH), "1970-01-01T00:00:00Z");
466 + // 2026-09-16T10:08:15Z
467 + assert_eq!(
468 + iso8601(UNIX_EPOCH + Duration::from_secs(1_789_553_295)),
469 + "2026-09-16T10:08:15Z"
470 + );
471 + // A leap day, to exercise the civil-date arithmetic.
472 + assert_eq!(
473 + iso8601(UNIX_EPOCH + Duration::from_secs(1_709_164_800)),
474 + "2024-02-29T00:00:00Z"
475 + );
476 + }
477 }
tests/acp_session_list.rs new
+246
@@ -0,0 +1,246 @@
1 +//! End-to-end check that saved threads can be imported through `session/list`.
2 +//!
3 +//! Editors offer an "Import Threads" picker only to an agent that advertises
4 +//! ACP's `sessionCapabilities.list` and answers `session/list` with the
5 +//! sessions it has stored — before this, Zed said siGit Code doesn't support
6 +//! the capability (issue #93).
7 +//!
8 +//! This drives the real binary against a scripted OpenAI-compatible endpoint:
9 +//! run one prompt so a session gets saved, then list and assert the thread
10 +//! comes back with its directory, title, and timestamp — and that the `cwd`
11 +//! filter keeps another project's threads out.
12 +
13 +use std::collections::VecDeque;
14 +use std::io::{BufRead, BufReader, Read, Write};
15 +use std::net::TcpListener;
16 +use std::process::{Child, ChildStdin, Command, Stdio};
17 +use std::sync::Mutex;
18 +use std::sync::mpsc::{Receiver, channel};
19 +use std::time::{Duration, Instant};
20 +
21 +use serde_json::{Value, json};
22 +
23 +const TIMEOUT: Duration = Duration::from_secs(60);
24 +
25 +fn sse_text(text: &str) -> String {
26 + format!(
27 + "data: {}\n\ndata: [DONE]\n\n",
28 + json!({"choices": [{"delta": {"content": text}}]})
29 + )
30 +}
31 +
32 +/// Serves one scripted SSE response per request.
33 +fn start_fake_endpoint(responses: Vec<String>) -> u16 {
34 + let listener = TcpListener::bind("127.0.0.1:0").expect("bind fake endpoint");
35 + let port = listener.local_addr().unwrap().port();
36 + let queue = Mutex::new(VecDeque::from(responses));
37 +
38 + std::thread::spawn(move || {
39 + for stream in listener.incoming() {
40 + let Ok(mut stream) = stream else { continue };
41 + let mut reader = BufReader::new(match stream.try_clone() {
42 + Ok(clone) => clone,
43 + Err(_) => continue,
44 + });
45 + let mut content_length = 0usize;
46 + loop {
47 + let mut line = String::new();
48 + if reader.read_line(&mut line).unwrap_or(0) == 0 {
49 + break;
50 + }
51 + let line = line.trim();
52 + if line.is_empty() {
53 + break;
54 + }
55 + if let Some(length) = line.to_ascii_lowercase().strip_prefix("content-length:") {
56 + content_length = length.trim().parse().unwrap_or(0);
57 + }
58 + }
59 + let mut body = vec![0u8; content_length];
60 + if reader.read_exact(&mut body).is_err() {
61 + continue;
62 + }
63 + let payload = queue
64 + .lock()
65 + .unwrap()
66 + .pop_front()
67 + .unwrap_or_else(|| "data: [DONE]\n\n".to_string());
68 + let response = format!(
69 + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\
70 + content-length: {}\r\nconnection: close\r\n\r\n{}",
71 + payload.len(),
72 + payload
73 + );
74 + let _ = stream.write_all(response.as_bytes());
75 + }
76 + });
77 +
78 + port
79 +}
80 +
81 +struct AgentUnderTest {
82 + child: Child,
83 + stdin: ChildStdin,
84 + incoming: Receiver<Value>,
85 + next_id: u64,
86 +}
87 +
88 +fn spawn_agent(port: u16, config_dir: &std::path::Path) -> AgentUnderTest {
89 + let mut child = Command::new(env!("CARGO_BIN_EXE_sigit"))
90 + .env("OPENAI_BASE_URL", format!("http://127.0.0.1:{port}"))
91 + .env("OPENAI_API_KEY", "test-key")
92 + .env("SIGIT_MODEL", "scripted-model")
93 + .env("SIGIT_CONFIG_DIR", config_dir)
94 + .env("SIGIT_MCP", "off")
95 + .env("SIGIT_PERMISSIONS", "allow")
96 + .env_remove("SIGIT_LOCAL_INFERENCE")
97 + .stdin(Stdio::piped())
98 + .stdout(Stdio::piped())
99 + .stderr(Stdio::null())
100 + .spawn()
101 + .expect("spawn sigit in ACP mode");
102 +
103 + let stdout = child.stdout.take().unwrap();
104 + let (message_tx, incoming) = channel();
105 + std::thread::spawn(move || {
106 + for line in BufReader::new(stdout).lines() {
107 + let Ok(line) = line else { break };
108 + if let Ok(message) = serde_json::from_str::<Value>(&line)
109 + && message_tx.send(message).is_err()
110 + {
111 + break;
112 + }
113 + }
114 + });
115 +
116 + let stdin = child.stdin.take().unwrap();
117 + AgentUnderTest {
118 + child,
119 + stdin,
120 + incoming,
121 + next_id: 0,
122 + }
123 +}
124 +
125 +impl AgentUnderTest {
126 + fn request(&mut self, method: &str, params: Value) -> u64 {
127 + self.next_id += 1;
128 + let id = self.next_id;
129 + let mut line =
130 + json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
131 + line.push('\n');
132 + self.stdin
133 + .write_all(line.as_bytes())
134 + .expect("write to agent stdin");
135 + self.stdin.flush().expect("flush agent stdin");
136 + id
137 + }
138 +
139 + fn wait_for_response(&mut self, id: u64) -> Value {
140 + let deadline = Instant::now() + TIMEOUT;
141 + loop {
142 + let remaining = deadline.saturating_duration_since(Instant::now());
143 + let Ok(message) = self.incoming.recv_timeout(remaining) else {
144 + panic!("timed out waiting for the response to request {id}");
145 + };
146 + if message["id"] == id && message.get("method").is_none() {
147 + assert!(
148 + message.get("error").is_none(),
149 + "request {id} failed: {message}"
150 + );
151 + return message;
152 + }
153 + }
154 + }
155 +}
156 +
157 +impl Drop for AgentUnderTest {
158 + fn drop(&mut self) {
159 + let _ = self.child.kill();
160 + let _ = self.child.wait();
161 + }
162 +}
163 +
164 +#[test]
165 +fn saved_sessions_are_listed_for_their_project() {
166 + let scratch = std::env::temp_dir().join(format!("sigit_acp_list_{}", std::process::id()));
167 + let config_dir = scratch.join("config");
168 + let project = scratch.join("project");
169 + let other = scratch.join("other");
170 + std::fs::create_dir_all(&config_dir).unwrap();
171 + std::fs::create_dir_all(&project).unwrap();
172 + std::fs::create_dir_all(&other).unwrap();
173 +
174 + let port = start_fake_endpoint(vec![sse_text("Sure thing.")]);
175 + let mut agent = spawn_agent(port, &config_dir);
176 +
177 + let id = agent.request(
178 + "initialize",
179 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
180 + );
181 + let initialize = agent.wait_for_response(id);
182 + assert!(
183 + initialize["result"]["agentCapabilities"]["sessionCapabilities"]["list"].is_object(),
184 + "the import picker is gated on the session/list capability: {initialize}"
185 + );
186 +
187 + // Nothing saved yet.
188 + let id = agent.request("session/list", json!({}));
189 + let empty = agent.wait_for_response(id);
190 + assert_eq!(
191 + empty["result"]["sessions"].as_array().map(Vec::len),
192 + Some(0),
193 + "no sessions have been saved yet: {empty}"
194 + );
195 +
196 + let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
197 + let new_session = agent.wait_for_response(id);
198 + let session_id = new_session["result"]["sessionId"]
199 + .as_str()
200 + .expect("session id")
201 + .to_string();
202 +
203 + let id = agent.request(
204 + "session/prompt",
205 + json!({
206 + "sessionId": session_id,
207 + "prompt": [{"type": "text", "text": "rename the parser module"}],
208 + }),
209 + );
210 + agent.wait_for_response(id);
211 +
212 + // The saved thread shows up for its own project …
213 + let id = agent.request("session/list", json!({"cwd": project}));
214 + let listed = agent.wait_for_response(id);
215 + let sessions = listed["result"]["sessions"]
216 + .as_array()
217 + .unwrap_or_else(|| panic!("sessions array: {listed}"));
218 + assert_eq!(sessions.len(), 1, "one saved thread expected: {listed}");
219 + let info = &sessions[0];
220 + assert_eq!(info["sessionId"], json!(session_id));
221 + assert_eq!(
222 + std::fs::canonicalize(info["cwd"].as_str().unwrap()).unwrap(),
223 + std::fs::canonicalize(&project).unwrap()
224 + );
225 + assert_eq!(
226 + info["title"], "rename the parser module",
227 + "the title comes from the first user message: {info}"
228 + );
229 + let updated_at = info["updatedAt"].as_str().unwrap_or_default();
230 + assert!(
231 + updated_at.len() == 20 && updated_at.ends_with('Z'),
232 + "updatedAt should be an ISO 8601 UTC instant, got {updated_at:?}"
233 + );
234 +
235 + // … and not for a different one.
236 + let id = agent.request("session/list", json!({"cwd": other}));
237 + let elsewhere = agent.wait_for_response(id);
238 + assert_eq!(
239 + elsewhere["result"]["sessions"].as_array().map(Vec::len),
240 + Some(0),
241 + "another project's threads must not be offered: {elsewhere}"
242 + );
243 +
244 + drop(agent);
245 + let _ = std::fs::remove_dir_all(&scratch);
246 +}