feature/sigit-agent-profile
claude/github-issue-40-0au5ua
claude/linked-issue-acp-tui-u4vvpa
claude/minor-release-cut-vkugow
claude/sigit-acp-local-chat-cx6380
claude/sigit-cloud-agent-expansion-reox0a
claude/tool-permission-system
claude/zen-feynman-0u78dk
development
feature/acp-cancel-request
feature/acp-session-list-cursor
feature/acp-v1-conformance
feature/agent-tools-multiedit-glob-todos-remember
feature/cargo-workspace
feature/hooks
feature/load-local-model-explicitly
feature/sigit-agent-profile
feature/sigit-code-cloud
feature/xcode-mcp-server
fix/acp-replay-loaded-session-history
fix/acp-replay-reasoning-and-attachments
fix/dsml-doubled-bar-markers
main
release/v1.5.10
release/v1.5.6
release/v1.6.1
| 1 | //! Durable session storage. |
| 2 | //! |
| 3 | //! One JSON-lines file per session at `$SIGIT_CONFIG_DIR/sessions/<id>.jsonl` |
| 4 | //! (config dir resolution matches [`crate::settings`] / [`crate::credentials`]: |
| 5 | //! `$SIGIT_CONFIG_DIR` or `~/.config/sigit`). Each line is one history message |
| 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 | |
| 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`. |
| 26 | fn config_dir() -> Option<PathBuf> { |
| 27 | if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") { |
| 28 | return Some(PathBuf::from(dir)); |
| 29 | } |
| 30 | let home = std::env::var("HOME").ok()?; |
| 31 | Some(PathBuf::from(home).join(".config/sigit")) |
| 32 | } |
| 33 | |
| 34 | fn sessions_dir() -> Option<PathBuf> { |
| 35 | config_dir().map(|dir| dir.join("sessions")) |
| 36 | } |
| 37 | |
| 38 | /// Reduce a session id to a filename-safe form: `[A-Za-z0-9._-]` pass through, |
| 39 | /// anything else becomes `_`. An empty id maps to `_` so the file name never |
| 40 | /// collapses to just the extension. |
| 41 | fn sanitize_id(session_id: &str) -> String { |
| 42 | if session_id.is_empty() { |
| 43 | return "_".to_string(); |
| 44 | } |
| 45 | session_id |
| 46 | .chars() |
| 47 | .map(|c| { |
| 48 | if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { |
| 49 | c |
| 50 | } else { |
| 51 | '_' |
| 52 | } |
| 53 | }) |
| 54 | .collect() |
| 55 | } |
| 56 | |
| 57 | fn session_path(session_id: &str) -> Option<PathBuf> { |
| 58 | sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id)))) |
| 59 | } |
| 60 | |
| 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 | |
| 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}"))?; |
| 81 | std::fs::rename(&tmp, path).map_err(|error| { |
| 82 | let _ = std::fs::remove_file(&tmp); |
| 83 | format!("rename {tmp:?} -> {path:?}: {error}") |
| 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 | /// Model selection last used by this session. Older sidecars omit it. |
| 96 | #[serde(default, skip_serializing_if = "Option::is_none")] |
| 97 | pub model_id: Option<String>, |
| 98 | } |
| 99 | |
| 100 | /// Record where `session_id` is running so it can be listed later. Cheap enough |
| 101 | /// to call on every save; the sidecar is a few hundred bytes. |
| 102 | pub fn save_meta( |
| 103 | session_id: &str, |
| 104 | cwd: &Path, |
| 105 | additional_directories: &[PathBuf], |
| 106 | model_id: Option<&str>, |
| 107 | ) { |
| 108 | let Some(path) = meta_path(session_id) else { |
| 109 | return; |
| 110 | }; |
| 111 | let meta = SessionMeta { |
| 112 | cwd: cwd.to_path_buf(), |
| 113 | additional_directories: additional_directories.to_vec(), |
| 114 | model_id: model_id.map(str::to_string), |
| 115 | }; |
| 116 | let Ok(body) = serde_json::to_string(&meta) else { |
| 117 | return; |
| 118 | }; |
| 119 | if let Err(error) = write_atomic(&path, session_id, &body) { |
| 120 | log::warn!("session meta save failed for {session_id}: {error}"); |
| 121 | } |
| 122 | } |
| 123 | |
| 124 | /// Read the sidecar for `session_id`, or `None` when there isn't a readable one. |
| 125 | fn load_meta(session_id: &str) -> Option<SessionMeta> { |
| 126 | let path = meta_path(session_id)?; |
| 127 | let contents = std::fs::read_to_string(&path).ok()?; |
| 128 | serde_json::from_str(&contents).ok() |
| 129 | } |
| 130 | |
| 131 | /// Persist a history snapshot for `session_id`, replacing any previous save. |
| 132 | /// The write is atomic: a temp file in the same directory is renamed over the |
| 133 | /// final path. |
| 134 | pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> { |
| 135 | let path = |
| 136 | session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?; |
| 137 | |
| 138 | let mut body = String::new(); |
| 139 | for message in history { |
| 140 | body.push_str(&message.to_string()); |
| 141 | body.push('\n'); |
| 142 | } |
| 143 | |
| 144 | write_atomic(&path, session_id, &body) |
| 145 | } |
| 146 | |
| 147 | /// Load the saved history for `session_id`, or `None` when no save exists (or |
| 148 | /// it cannot be read). Unparseable lines are skipped rather than failing the |
| 149 | /// whole restore. |
| 150 | pub fn load(session_id: &str) -> Option<Vec<Value>> { |
| 151 | let path = session_path(session_id)?; |
| 152 | let contents = std::fs::read_to_string(&path).ok()?; |
| 153 | Some( |
| 154 | contents |
| 155 | .lines() |
| 156 | .filter(|line| !line.trim().is_empty()) |
| 157 | .filter_map(|line| serde_json::from_str::<Value>(line).ok()) |
| 158 | .collect(), |
| 159 | ) |
| 160 | } |
| 161 | |
| 162 | /// Remove the saved history for `session_id`, sidecar included. Missing files |
| 163 | /// are fine. |
| 164 | pub fn delete(session_id: &str) { |
| 165 | if let Some(path) = session_path(session_id) { |
| 166 | let _ = std::fs::remove_file(path); |
| 167 | } |
| 168 | if let Some(path) = meta_path(session_id) { |
| 169 | let _ = std::fs::remove_file(path); |
| 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 |
| 184 | /// platform, by the ACP `session/list` handler in `main.rs`. |
| 185 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 186 | pub struct SessionEntry { |
| 187 | /// The sanitized id (the file stem), which `load`/`delete` accept as-is. |
| 188 | pub id: String, |
| 189 | /// Last-modified time of the session file; `UNIX_EPOCH` when unreadable. |
| 190 | pub modified: std::time::SystemTime, |
| 191 | /// Number of history messages (non-empty lines) in the file. |
| 192 | pub message_count: usize, |
| 193 | /// The session's working directory, when a sidecar recorded one. |
| 194 | pub cwd: Option<PathBuf>, |
| 195 | /// Extra workspace roots recorded alongside `cwd`. |
| 196 | pub additional_directories: Vec<PathBuf>, |
| 197 | /// Model selection saved alongside the session, when available. |
| 198 | pub model_id: Option<String>, |
| 199 | /// A one-line title taken from the first user message, when there is one. |
| 200 | pub title: Option<String>, |
| 201 | } |
| 202 | |
| 203 | /// Longest title handed out by [`session_title`] before it is cut short. |
| 204 | const TITLE_MAX_CHARS: usize = 80; |
| 205 | |
| 206 | /// The plain text of a history message, whether `content` is a string or the |
| 207 | /// structured `[{ "type": "text", "text": … }]` form. |
| 208 | fn message_text(message: &Value) -> String { |
| 209 | match &message["content"] { |
| 210 | Value::String(text) => text.clone(), |
| 211 | Value::Array(blocks) => blocks |
| 212 | .iter() |
| 213 | .filter_map(|block| block["text"].as_str()) |
| 214 | .collect::<Vec<_>>() |
| 215 | .join("\n"), |
| 216 | _ => String::new(), |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | /// A human-readable title for a session: the first line of its first user |
| 221 | /// message, trimmed and cut to [`TITLE_MAX_CHARS`]. |
| 222 | /// |
| 223 | /// A user message with no text at all — one that carried only an attachment, |
| 224 | /// say — is passed over for the next one rather than producing a blank title; |
| 225 | /// a message with any text, however short, wins. When no user message has text |
| 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> { |
| 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 | } |
| 247 | let text = message_text(&message); |
| 248 | let Some(first_line) = text.lines().map(str::trim).find(|l| !l.is_empty()) else { |
| 249 | continue; |
| 250 | }; |
| 251 | let mut title: String = first_line.chars().take(TITLE_MAX_CHARS).collect(); |
| 252 | if first_line.chars().count() > TITLE_MAX_CHARS { |
| 253 | title.push('…'); |
| 254 | } |
| 255 | return Some(title); |
| 256 | } |
| 257 | None |
| 258 | } |
| 259 | |
| 260 | /// List the saved sessions, newest first. Non-`.jsonl` entries (temp files, |
| 261 | /// sidecars, stray junk) are skipped. A missing or unreadable sessions dir |
| 262 | /// yields an empty list. |
| 263 | pub fn list() -> Vec<SessionEntry> { |
| 264 | let Some(dir) = sessions_dir() else { |
| 265 | return Vec::new(); |
| 266 | }; |
| 267 | let Ok(entries) = std::fs::read_dir(&dir) else { |
| 268 | return Vec::new(); |
| 269 | }; |
| 270 | let mut sessions: Vec<SessionEntry> = entries |
| 271 | .flatten() |
| 272 | .filter_map(|entry| { |
| 273 | let path = entry.path(); |
| 274 | if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") { |
| 275 | return None; |
| 276 | } |
| 277 | let id = path.file_stem()?.to_str()?.to_string(); |
| 278 | let contents = std::fs::read_to_string(&path).ok()?; |
| 279 | let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count(); |
| 280 | let title = session_title(&contents); |
| 281 | let modified = entry |
| 282 | .metadata() |
| 283 | .ok() |
| 284 | .and_then(|m| m.modified().ok()) |
| 285 | .unwrap_or(std::time::UNIX_EPOCH); |
| 286 | let meta = load_meta(&id); |
| 287 | let (cwd, additional_directories, model_id) = match meta { |
| 288 | Some(meta) => (Some(meta.cwd), meta.additional_directories, meta.model_id), |
| 289 | None => (None, Vec::new(), None), |
| 290 | }; |
| 291 | Some(SessionEntry { |
| 292 | id, |
| 293 | modified, |
| 294 | message_count, |
| 295 | cwd, |
| 296 | additional_directories, |
| 297 | model_id, |
| 298 | title, |
| 299 | }) |
| 300 | }) |
| 301 | .collect(); |
| 302 | sessions.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.id.cmp(&b.id))); |
| 303 | sessions |
| 304 | } |
| 305 | |
| 306 | /// Format a timestamp as an ISO 8601 UTC instant with milliseconds |
| 307 | /// (`2026-09-16T10:08:15.123Z`), which is what ACP's `SessionInfo.updatedAt` |
| 308 | /// expects. The sub-second part is not decoration: two sessions saved in the |
| 309 | /// same second would otherwise carry identical timestamps and a client sorting |
| 310 | /// its import list by `updatedAt` could not tell which thread is the recent |
| 311 | /// one. Hand-rolled rather than pulling in a date crate for one field; |
| 312 | /// pre-epoch times clamp to the epoch. |
| 313 | pub fn iso8601(time: std::time::SystemTime) -> String { |
| 314 | let since_epoch = time |
| 315 | .duration_since(std::time::UNIX_EPOCH) |
| 316 | .unwrap_or_default(); |
| 317 | let secs = since_epoch.as_secs(); |
| 318 | let millis = since_epoch.subsec_millis(); |
| 319 | let days = (secs / 86_400) as i64; |
| 320 | let seconds_of_day = secs % 86_400; |
| 321 | let (hour, minute, second) = ( |
| 322 | seconds_of_day / 3600, |
| 323 | (seconds_of_day % 3600) / 60, |
| 324 | seconds_of_day % 60, |
| 325 | ); |
| 326 | |
| 327 | // Howard Hinnant's civil_from_days: shift the epoch to 0000-03-01 so leap |
| 328 | // days land at the end of the era and the month arithmetic stays branchless. |
| 329 | let z = days + 719_468; |
| 330 | let era = z.div_euclid(146_097); |
| 331 | let day_of_era = z.rem_euclid(146_097); |
| 332 | let year_of_era = |
| 333 | (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; |
| 334 | let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); |
| 335 | let mp = (5 * day_of_year + 2) / 153; |
| 336 | let day = day_of_year - (153 * mp + 2) / 5 + 1; |
| 337 | let month = if mp < 10 { mp + 3 } else { mp - 9 }; |
| 338 | let year = year_of_era + era * 400 + i64::from(month <= 2); |
| 339 | |
| 340 | format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") |
| 341 | } |
| 342 | |
| 343 | #[cfg(test)] |
| 344 | mod tests { |
| 345 | use super::*; |
| 346 | |
| 347 | #[test] |
| 348 | fn sanitize_keeps_safe_chars_and_replaces_the_rest() { |
| 349 | assert_eq!(sanitize_id("abc-DEF_123.z"), "abc-DEF_123.z"); |
| 350 | assert_eq!(sanitize_id("a/b\\c:d e"), "a_b_c_d_e"); |
| 351 | assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd"); |
| 352 | assert_eq!(sanitize_id(""), "_"); |
| 353 | } |
| 354 | |
| 355 | // One test for the filesystem behavior because it mutates the |
| 356 | // process-global `SIGIT_CONFIG_DIR` env var (same pattern as the settings |
| 357 | // tests): splitting would race under the parallel test runner. |
| 358 | #[test] |
| 359 | fn save_load_delete_round_trip() { |
| 360 | let _guard = crate::ENV_TEST_LOCK |
| 361 | .lock() |
| 362 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 363 | let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id())); |
| 364 | let _ = std::fs::remove_dir_all(&dir); |
| 365 | // SAFETY: serialized by ENV_TEST_LOCK; restored below. |
| 366 | unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; |
| 367 | |
| 368 | // Missing file → None. |
| 369 | assert_eq!(load("nope"), None); |
| 370 | |
| 371 | let history = vec![ |
| 372 | serde_json::json!({ "role": "system", "content": "sys" }), |
| 373 | serde_json::json!({ "role": "user", "content": "hi\nthere" }), |
| 374 | serde_json::json!({ |
| 375 | "role": "assistant", "content": null, |
| 376 | "tool_calls": [{ "id": "call_1", "type": "function", |
| 377 | "function": { "name": "read_file", "arguments": "{}" } }], |
| 378 | }), |
| 379 | ]; |
| 380 | save("sess-1", &history).unwrap(); |
| 381 | assert_eq!(load("sess-1"), Some(history.clone())); |
| 382 | |
| 383 | // Saving again replaces, not appends. |
| 384 | let shorter = vec![serde_json::json!({ "role": "user", "content": "only" })]; |
| 385 | save("sess-1", &shorter).unwrap(); |
| 386 | assert_eq!(load("sess-1"), Some(shorter)); |
| 387 | |
| 388 | // A hostile id stays inside the sessions dir via sanitization. |
| 389 | save("../escape", &history).unwrap(); |
| 390 | assert!(dir.join("sessions").join(".._escape.jsonl").is_file()); |
| 391 | assert_eq!(load("../escape"), Some(history)); |
| 392 | delete("../escape"); |
| 393 | assert_eq!(load("../escape"), None); |
| 394 | |
| 395 | delete("sess-1"); |
| 396 | assert_eq!(load("sess-1"), None); |
| 397 | // Deleting a missing session is a no-op. |
| 398 | delete("sess-1"); |
| 399 | |
| 400 | unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; |
| 401 | let _ = std::fs::remove_dir_all(&dir); |
| 402 | } |
| 403 | |
| 404 | // Same single-test-per-env-var pattern as `save_load_delete_round_trip`: |
| 405 | // this mutates `SIGIT_CONFIG_DIR`, so everything runs under one lock hold. |
| 406 | #[test] |
| 407 | fn list_returns_sessions_newest_first_and_skips_junk() { |
| 408 | let _guard = crate::ENV_TEST_LOCK |
| 409 | .lock() |
| 410 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 411 | let dir = std::env::temp_dir().join(format!("sigit_sessions_list_{}", std::process::id())); |
| 412 | let _ = std::fs::remove_dir_all(&dir); |
| 413 | // SAFETY: serialized by ENV_TEST_LOCK; restored below. |
| 414 | unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) }; |
| 415 | |
| 416 | // No sessions dir at all → empty list, not an error. |
| 417 | assert!(list().is_empty()); |
| 418 | |
| 419 | let msg = |text: &str| serde_json::json!({ "role": "user", "content": text }); |
| 420 | save("older", &[msg("a"), msg("b"), msg("c")]).unwrap(); |
| 421 | // Distinct mtimes so the newest-first order is deterministic. |
| 422 | std::thread::sleep(std::time::Duration::from_millis(50)); |
| 423 | save("newer", &[msg("x")]).unwrap(); |
| 424 | |
| 425 | // Junk the lister must skip: wrong extension, a stray temp file, and a |
| 426 | // subdirectory. |
| 427 | let sessions = dir.join("sessions"); |
| 428 | std::fs::write(sessions.join("notes.txt"), "not a session\n").unwrap(); |
| 429 | std::fs::write(sessions.join(".older.999.tmp"), "half-written\n").unwrap(); |
| 430 | std::fs::create_dir_all(sessions.join("nested.jsonl")).unwrap(); |
| 431 | |
| 432 | let listed = list(); |
| 433 | assert_eq!(listed.len(), 2); |
| 434 | assert_eq!(listed[0].id, "newer"); |
| 435 | assert_eq!(listed[0].message_count, 1); |
| 436 | assert_eq!(listed[1].id, "older"); |
| 437 | assert_eq!(listed[1].message_count, 3); |
| 438 | assert!(listed[0].modified >= listed[1].modified); |
| 439 | // Titles come from the first user message; no sidecar means no cwd. |
| 440 | assert_eq!(listed[0].title.as_deref(), Some("x")); |
| 441 | assert_eq!(listed[1].title.as_deref(), Some("a")); |
| 442 | assert_eq!(listed[0].cwd, None); |
| 443 | assert!(listed[0].additional_directories.is_empty()); |
| 444 | |
| 445 | // Sidecars written before model persistence remain readable. |
| 446 | std::fs::write( |
| 447 | sessions.join("newer.meta.json"), |
| 448 | r#"{"cwd":"/tmp/project","additional_directories":["/tmp/lib"]}"#, |
| 449 | ) |
| 450 | .unwrap(); |
| 451 | let listed = list(); |
| 452 | assert_eq!(listed[0].model_id, None); |
| 453 | |
| 454 | // A sidecar makes the session listable with its directories, and |
| 455 | // deleting the session takes the sidecar with it. |
| 456 | save_meta( |
| 457 | "newer", |
| 458 | Path::new("/tmp/project"), |
| 459 | &[PathBuf::from("/tmp/lib")], |
| 460 | Some("test-model"), |
| 461 | ); |
| 462 | let listed = list(); |
| 463 | assert_eq!(listed[0].cwd.as_deref(), Some(Path::new("/tmp/project"))); |
| 464 | assert_eq!( |
| 465 | listed[0].additional_directories, |
| 466 | vec![PathBuf::from("/tmp/lib")] |
| 467 | ); |
| 468 | assert_eq!(listed[0].model_id.as_deref(), Some("test-model")); |
| 469 | assert!(sessions.join("newer.meta.json").is_file()); |
| 470 | delete("newer"); |
| 471 | assert!(!sessions.join("newer.meta.json").exists()); |
| 472 | assert_eq!(list().len(), 1); |
| 473 | |
| 474 | unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") }; |
| 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\ |
| 493 | {\"role\":\"user\",\"content\":\" fix the parser\\nmore detail\"}\n\ |
| 494 | {\"role\":\"user\",\"content\":\"second\"}\n"; |
| 495 | assert_eq!(session_title(contents).as_deref(), Some("fix the parser")); |
| 496 | |
| 497 | // Structured content blocks, not just plain strings. |
| 498 | let blocks = |
| 499 | "{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"from a block\"}]}\n"; |
| 500 | assert_eq!(session_title(blocks).as_deref(), Some("from a block")); |
| 501 | |
| 502 | // Unparseable lines are skipped, not fatal. |
| 503 | let junk = "not json\n{\"role\":\"user\",\"content\":\"still found\"}\n"; |
| 504 | assert_eq!(session_title(junk).as_deref(), Some("still found")); |
| 505 | |
| 506 | // Long titles are cut short with an ellipsis. |
| 507 | let long = format!( |
| 508 | "{{\"role\":\"user\",\"content\":\"{}\"}}\n", |
| 509 | "x".repeat(TITLE_MAX_CHARS + 10) |
| 510 | ); |
| 511 | let title = session_title(&long).unwrap(); |
| 512 | assert_eq!(title.chars().count(), TITLE_MAX_CHARS + 1); |
| 513 | assert!(title.ends_with('…')); |
| 514 | |
| 515 | // Nothing from the user yet → no title. |
| 516 | assert_eq!( |
| 517 | session_title("{\"role\":\"assistant\",\"content\":\"hi\"}\n"), |
| 518 | None |
| 519 | ); |
| 520 | assert_eq!(session_title(""), None); |
| 521 | |
| 522 | // A user message with no text of its own — an attachment-only turn — |
| 523 | // hands the title to the next one rather than coming back blank. |
| 524 | let attachment_only = "{\"role\":\"user\",\"content\":\" \"}\n\ |
| 525 | {\"role\":\"user\",\"content\":\"the real question\"}\n"; |
| 526 | assert_eq!( |
| 527 | session_title(attachment_only).as_deref(), |
| 528 | Some("the real question") |
| 529 | ); |
| 530 | // However short, any text wins. |
| 531 | assert_eq!( |
| 532 | session_title("{\"role\":\"user\",\"content\":\"?\"}\n").as_deref(), |
| 533 | Some("?") |
| 534 | ); |
| 535 | } |
| 536 | |
| 537 | #[test] |
| 538 | fn iso8601_formats_utc_instants() { |
| 539 | use std::time::{Duration, UNIX_EPOCH}; |
| 540 | |
| 541 | assert_eq!(iso8601(UNIX_EPOCH), "1970-01-01T00:00:00.000Z"); |
| 542 | // 2026-09-16T10:08:15.123Z — milliseconds keep two saves in the same |
| 543 | // second apart when a client sorts its import list. |
| 544 | assert_eq!( |
| 545 | iso8601(UNIX_EPOCH + Duration::from_millis(1_789_553_295_123)), |
| 546 | "2026-09-16T10:08:15.123Z" |
| 547 | ); |
| 548 | // A leap day, to exercise the civil-date arithmetic. |
| 549 | assert_eq!( |
| 550 | iso8601(UNIX_EPOCH + Duration::from_secs(1_709_164_800)), |
| 551 | "2024-02-29T00:00:00.000Z" |
| 552 | ); |
| 553 | } |
| 554 | } |