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
+}