Tell the client when a saved thread's title or updatedAt changes

After each save, sigit sends a session_info_update carrying a fresh updatedAt, plus the title the first time there is one (or when it changes, as after /clear). A new thread used to stay untitled in the editor's sidebar until the next session/list. The title is worked out from the history in memory with the same rule session/list uses, and the update never carries sessionId, cwd or additionalDirectories. Closes #193 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 9, 2026 at 00:12 UTC 6d5d194083b1a865363d54670174f6cbd3889f70
4 files changed +179 -14
.agents/AGENTS.md
+4 -1
@@ -395,7 +395,10 @@ feeds results back. Neither the loop nor ACP/TUI surfaces depend on a concrete b
395 editor's "Import Threads" picker) must report an absolute `cwd` per session and may filter on
396 it, so a session without one is skipped there while still reopening by id through
397 `session/load`. Sidecars are written at save time, not at session start, so a thread nobody
398 - spoke in leaves nothing behind.
398 + spoke in leaves nothing behind. Each ACP save (`persist_session` in `main.rs`) is also sent to
399 + the client as a `session_info_update` carrying a fresh `updatedAt`, plus the title when it
400 + differs from the last one sent (`announced_titles`), so a new thread gets its title in the
401 + sidebar without waiting for the next `session/list`.
402 - **`src/setup.rs`** — model cache location, local model discovery, selected-model persistence.
403 Must run (`setup_shared_model_cache`) *before* anything touches `ChatEngine`/`hf-hub`, since
404 those read env vars once at init.
src/main.rs
+50 -6
@@ -89,8 +89,8 @@ use agent_client_protocol::schema::v1::{
89 SessionAdditionalDirectoriesCapabilities, SessionCapabilities, SessionCloseCapabilities,
90 SessionConfigOption, SessionConfigOptionCategory, SessionConfigSelectOption,
91 SessionConfigValueId, SessionDeleteCapabilities, SessionForkCapabilities, SessionId,
92 - SessionInfo, SessionListCapabilities, SessionMode, SessionModeState, SessionNotification,
93 - SessionResumeCapabilities, SessionUpdate, SetSessionConfigOptionRequest,
92 + SessionInfo, SessionInfoUpdate, SessionListCapabilities, SessionMode, SessionModeState,
93 + SessionNotification, SessionResumeCapabilities, SessionUpdate, SetSessionConfigOptionRequest,
94 SetSessionConfigOptionResponse, SetSessionModeRequest, SetSessionModeResponse, StopReason,
95 Terminal, TerminalOutputRequest, ToolCall, ToolCallContent, ToolCallLocation, ToolCallStatus,
96 ToolCallUpdate, ToolCallUpdateFields, ToolKind, UnstructuredCommandInput,
@@ -1265,6 +1265,9 @@ struct SiGitAgent {
1265 /// Sessions a `session/close` is waiting to tear down. A prompt for one of
1266 /// these that reaches the front of its session lock first is cancelled.
1267 closing_sessions: std::sync::Mutex<std::collections::HashSet<String>>,
1268 + /// The title last sent to the client for each session in a
1269 + /// `session_info_update`, so an unchanged title is not sent again.
1270 + announced_titles: std::sync::Mutex<std::collections::HashMap<String, String>>,
1271 current_model: std::sync::Mutex<GgufModelConfig>,
1272 /// flipped once the startup model finishes (success or failure)
1273 model_ready: Arc<AtomicBool>,
@@ -1318,6 +1321,7 @@ impl SiGitAgent {
1321 active_session: std::sync::Mutex::new(None),
1322 prompt_cancellations: std::sync::Mutex::new(std::collections::HashMap::new()),
1323 closing_sessions: std::sync::Mutex::new(std::collections::HashSet::new()),
1324 + announced_titles: std::sync::Mutex::new(std::collections::HashMap::new()),
1325 current_model: std::sync::Mutex::new(initial_model),
1326 model_ready,
1327 startup_model_load_started,
@@ -2660,12 +2664,22 @@ impl SiGitAgent {
2664 /// session request is recorded — never the process cwd, which a later
2665 /// session in the same process may have moved: a thread listed under the
2666 /// wrong project is worse than one that isn't listed at all.
2663 - async fn persist_session(&self, session_id: &SessionId, snapshot: &[serde_json::Value]) {
2667 + ///
2668 + /// Each save is also reported to the client in a `session_info_update`, so
2669 + /// the thread's title and `updatedAt` reach the editor's sidebar without
2670 + /// waiting for the next `session/list`.
2671 + async fn persist_session(
2672 + &self,
2673 + cx: &ConnectionTo<Client>,
2674 + session_id: &SessionId,
2675 + snapshot: &[serde_json::Value],
2676 + ) {
2677 let key = session_id.to_string();
2678 if let Err(error) = session_store::save(&key, snapshot) {
2679 log::warn!("session({session_id}) save failed: {error}");
2680 return;
2681 }
2682 + self.send_session_info(cx, session_id, snapshot);
2683 let state = self.sessions.lock().unwrap().get(&key).cloned();
2684 match state {
2685 Some(state) => session_store::save_meta(
@@ -2680,6 +2694,35 @@ impl SiGitAgent {
2694 }
2695 }
2696
2697 + /// Tell the client a session was just saved: always a fresh `updatedAt`,
2698 + /// and the title only when it differs from the one last sent. The update
2699 + /// names no `sessionId`, `cwd` or `additionalDirectories`; the spec keeps
2700 + /// those out of it.
2701 + fn send_session_info(
2702 + &self,
2703 + cx: &ConnectionTo<Client>,
2704 + session_id: &SessionId,
2705 + snapshot: &[serde_json::Value],
2706 + ) {
2707 + let key = session_id.to_string();
2708 + let mut update = SessionInfoUpdate::new();
2709 + if let Some(modified) = session_store::modified(&key) {
2710 + update = update.updated_at(session_store::iso8601(modified));
2711 + }
2712 + if let Some(title) = session_store::history_title(snapshot) {
2713 + let mut announced = self.announced_titles.lock().unwrap();
2714 + if announced.get(&key) != Some(&title) {
2715 + announced.insert(key, title.clone());
2716 + update = update.title(title);
2717 + }
2718 + }
2719 + cx.send_notification(SessionNotification::new(
2720 + session_id.clone(),
2721 + SessionUpdate::SessionInfoUpdate(update),
2722 + ))
2723 + .ok();
2724 + }
2725 +
2726 /// ACP `session/list`: the saved threads an editor can reopen.
2727 ///
2728 /// Only sessions with a recorded working directory qualify — `SessionInfo`
@@ -2909,6 +2952,7 @@ impl SiGitAgent {
2952 }
2953
2954 permissions::reset_session(&key);
2955 + self.announced_titles.lock().unwrap().remove(&key);
2956 let killed = tools::kill_session_tasks(&key);
2957 self.closing_sessions.lock().unwrap().remove(&key);
2958
@@ -3426,7 +3470,7 @@ impl SiGitAgent {
3470 // The client keeps this prompt in its thread after the error,
3471 // so keep it in the saved conversation too.
3472 let snapshot = backend.history_snapshot().await;
3429 - self.persist_session(&session_id, &snapshot).await;
3473 + self.persist_session(cx, &session_id, &snapshot).await;
3474 self.finish_prompt(&session_id, &cancellation);
3475 return Err(agent_client_protocol::Error::new(-32603, error));
3476 }
@@ -3910,7 +3954,7 @@ impl SiGitAgent {
3954 // Persist the completed turn so a restart (or session/load) can pick
3955 // the conversation back up.
3956 let snapshot = backend.history_snapshot().await;
3913 - self.persist_session(&session_id, &snapshot).await;
3957 + self.persist_session(cx, &session_id, &snapshot).await;
3958 self.finish_prompt(&session_id, &cancellation);
3959
3960 log::info!(
@@ -5226,7 +5270,7 @@ async fn exec_slash_acp(
5270 let snapshot = backend.history_snapshot().await;
5271 let after = backend::estimate_tokens(&snapshot);
5272 // Keep the saved session in step with the compacted state.
5229 - agent.persist_session(&session_id, &snapshot).await;
5273 + agent.persist_session(cx, &session_id, &snapshot).await;
5274 format!("Compacted history: ~{before} → ~{after} tokens (estimated).")
5275 }
5276 Err(error) => format!("Compaction failed: {error}"),
src/session_store.rs
+35 -7
@@ -170,6 +170,14 @@ pub fn delete(session_id: &str) {
170 }
171 }
172
173 +/// When `session_id` was last saved, as [`list`] reports it.
174 +pub fn modified(session_id: &str) -> Option<std::time::SystemTime> {
175 + std::fs::metadata(session_path(session_id)?)
176 + .ok()?
177 + .modified()
178 + .ok()
179 +}
180 +
181 /// One saved session as seen on disk.
182 ///
183 /// Consumed by the Unix-only TUI (`chat.rs` History tab) and, on every
@@ -218,13 +226,21 @@ fn message_text(message: &Value) -> String {
226 /// there is no title, and the client falls back to whatever it shows for an
227 /// untitled thread.
228 fn session_title(contents: &str) -> Option<String> {
221 - for line in contents.lines() {
222 - if line.trim().is_empty() {
223 - continue;
224 - }
225 - let Ok(message) = serde_json::from_str::<Value>(line) else {
226 - continue;
227 - };
229 + let messages = contents
230 + .lines()
231 + .filter(|line| !line.trim().is_empty())
232 + .filter_map(|line| serde_json::from_str::<Value>(line).ok());
233 + title_of(messages)
234 +}
235 +
236 +/// The title [`list`] will report for a session holding `history`, worked
237 +/// out from the history in memory rather than the file it was saved to.
238 +pub fn history_title(history: &[Value]) -> Option<String> {
239 + title_of(history.iter().cloned())
240 +}
241 +
242 +fn title_of(messages: impl Iterator<Item = Value>) -> Option<String> {
243 + for message in messages {
244 if message["role"] != "user" {
245 continue;
246 }
@@ -459,6 +475,18 @@ mod tests {
475 let _ = std::fs::remove_dir_all(&dir);
476 }
477
478 + #[test]
479 + fn history_title_matches_the_title_of_the_saved_file() {
480 + let history = vec![
481 + serde_json::json!({"role": "system", "content": "sys"}),
482 + serde_json::json!({"role": "user", "content": " fix the parser\nmore detail"}),
483 + ];
484 + let saved: String = history.iter().map(|m| format!("{m}\n")).collect();
485 + assert_eq!(history_title(&history), session_title(&saved));
486 + assert_eq!(history_title(&history).as_deref(), Some("fix the parser"));
487 + assert_eq!(history_title(&history[..1]), None);
488 + }
489 +
490 #[test]
491 fn title_uses_the_first_line_of_the_first_user_message() {
492 let contents = "{\"role\":\"system\",\"content\":\"sys\"}\n\
tests/acp_session_list.rs
+90
@@ -333,3 +333,93 @@ fn a_deleted_session_no_longer_lists_or_loads() {
333 drop(agent);
334 let _ = std::fs::remove_dir_all(&scratch);
335 }
336 +
337 +/// Every notification for `session_id` that arrives before the response to
338 +/// request `id`, in order.
339 +fn updates_until_response(agent: &mut AgentUnderTest, id: u64) -> Vec<Value> {
340 + let mut updates = Vec::new();
341 + let deadline = Instant::now() + TIMEOUT;
342 + loop {
343 + let remaining = deadline.saturating_duration_since(Instant::now());
344 + let message = agent
345 + .incoming
346 + .recv_timeout(remaining)
347 + .unwrap_or_else(|_| panic!("timed out waiting for the response to request {id}"));
348 + if message["method"] == "session/update" {
349 + updates.push(message["params"]["update"].clone());
350 + }
351 + if message["id"] == id && message.get("method").is_none() {
352 + assert!(message.get("error").is_none(), "{message}");
353 + return updates;
354 + }
355 + }
356 +}
357 +
358 +/// A saved turn reaches the editor's sidebar right away: the title the first
359 +/// time there is one, and a fresh `updatedAt` on every save (issue #193).
360 +#[test]
361 +fn a_saved_turn_sends_session_info_to_the_client() {
362 + let scratch = std::env::temp_dir().join(format!("sigit_acp_info_{}", std::process::id()));
363 + let config_dir = scratch.join("config");
364 + let project = scratch.join("project");
365 + std::fs::create_dir_all(&config_dir).unwrap();
366 + std::fs::create_dir_all(&project).unwrap();
367 +
368 + let port = start_fake_endpoint(vec![sse_text("First."), sse_text("Second.")]);
369 + let mut agent = spawn_agent(port, &config_dir);
370 +
371 + let id = agent.request(
372 + "initialize",
373 + json!({"protocolVersion": 1, "clientCapabilities": {}}),
374 + );
375 + agent.wait_for_response(id);
376 + let id = agent.request("session/new", json!({"cwd": project, "mcpServers": []}));
377 + let session_id = agent.wait_for_response(id)["result"]["sessionId"]
378 + .as_str()
379 + .expect("session id")
380 + .to_string();
381 +
382 + let info_updates = |updates: Vec<Value>| -> Vec<Value> {
383 + updates
384 + .into_iter()
385 + .filter(|update| update["sessionUpdate"] == "session_info_update")
386 + .collect()
387 + };
388 +
389 + let id = agent.request(
390 + "session/prompt",
391 + json!({
392 + "sessionId": session_id,
393 + "prompt": [{"type": "text", "text": "tidy up the build script"}],
394 + }),
395 + );
396 + let first = info_updates(updates_until_response(&mut agent, id));
397 + assert_eq!(first.len(), 1, "{first:?}");
398 + assert_eq!(first[0]["title"], "tidy up the build script");
399 + let updated_at = first[0]["updatedAt"].as_str().unwrap_or_default();
400 + assert!(
401 + updated_at.len() == 24 && updated_at.ends_with('Z'),
402 + "updatedAt is an ISO 8601 instant: {updated_at:?}"
403 + );
404 + for field in ["sessionId", "cwd", "additionalDirectories"] {
405 + assert!(first[0].get(field).is_none(), "{field} must not be sent");
406 + }
407 +
408 + let id = agent.request(
409 + "session/prompt",
410 + json!({
411 + "sessionId": session_id,
412 + "prompt": [{"type": "text", "text": "and the tests"}],
413 + }),
414 + );
415 + let second = info_updates(updates_until_response(&mut agent, id));
416 + assert_eq!(second.len(), 1, "{second:?}");
417 + assert!(
418 + second[0].get("title").is_none(),
419 + "an unchanged title is not sent again: {second:?}"
420 + );
421 + assert!(second[0]["updatedAt"].is_string(), "{second:?}");
422 +
423 + drop(agent);
424 + let _ = std::fs::remove_dir_all(&scratch);
425 +}