返回 CodeWhale
session_resume.rs
根目录 / crates / tui / src / session_resume.rs
1 //! Truthful auto-resume of the last valid session (#2934).
2 //!
3 //! Plain `codewhale` has always started fresh. Issue #2934 asked for the
4 //! previous conversation to come back automatically; the review that followed
5 //! (see the 2026-07-16 comment) landed on a firm constraint: **no surprise
6 //! resume**. So this is an explicit, opt-in setting, and when it is on it
7 //! still refuses to guess.
8 //!
9 //! The rules, in the order they are applied:
10 //!
11 //! 1. An explicit `--resume <id>` or `--continue` always wins. Auto-resume
12 //! never overrides, reorders, or second-guesses what the user typed.
13 //! 2. `--fresh` always wins the other way.
14 //! 3. With the setting off (the default), the decision is
15 //! [`AutoResumeDecision::Disabled`] and startup is unchanged.
16 //! 4. With the setting on, the candidate is the newest non-archived session
17 //! recorded against *this* workspace. If there is none, startup is fresh
18 //! and says so.
19 //! 5. The candidate is then **verified**: it must load, and its recorded
20 //! workspace must still match the workspace we are launching in. A session
21 //! that fails to load (truncated, hand-edited, half-written) yields
22 //! [`AutoResumeDecision::Unreadable`] — fresh start, with the reason
23 //! surfaced rather than swallowed.
24 //!
25 //! Rule 5's workspace re-check is the one that matters most. The candidate
26 //! already came from a workspace-scoped query, but the metadata read there is
27 //! a 64 KB prefix extraction; re-checking against the fully loaded session is
28 //! what makes "we will never silently resume a different workspace" a property
29 //! of the code rather than a claim in a doc.
30 //!
31 //! Nothing here contacts a provider or the network. Deciding what to resume,
32 //! and resuming it, are pure disk operations.
33
34 use std::path::Path;
35
36 use crate::session_manager::{SessionManager, workspace_scope_matches};
37
38 /// How far down the candidate list auto-resume will walk before giving up.
39 ///
40 /// Bounded on purpose: a sessions directory full of damaged files must not
41 /// turn startup into a long scan, and the receipt's skipped count stays a
42 /// small, honest number rather than an unbounded tally.
43 pub const MAX_AUTO_RESUME_CANDIDATES: usize = 10;
44
45 /// Why startup is (or is not) attaching to a previous session.
46 #[derive(Debug, Clone, PartialEq, Eq)]
47 pub enum AutoResumeDecision {
48 /// The user asked for a specific session, or for `--continue`. Auto-resume
49 /// stands down and the explicit request is carried through untouched.
50 ExplicitRequest { session_id: String },
51 /// `--fresh` was passed. Start clean, no lookup performed.
52 ForcedFresh,
53 /// The setting is off. This is the shipped default.
54 Disabled,
55 /// A verified session for this workspace. Safe to resume.
56 ///
57 /// `skipped_unreadable` counts candidates newer than this one that failed
58 /// verification. It is reported rather than swallowed: silently landing on
59 /// an older session while newer ones rot is exactly the kind of quiet
60 /// degradation a user should be told about.
61 Resume {
62 session_id: String,
63 title: String,
64 skipped_unreadable: usize,
65 },
66 /// Auto-resume is on but this workspace has no eligible session yet.
67 /// Start fresh; this is a normal first-run state, not an error.
68 NoSession,
69 /// Every candidate for this workspace failed verification. Start fresh and
70 /// name the newest failure plus how many were tried.
71 Unreadable {
72 session_id: String,
73 reason: String,
74 skipped_unreadable: usize,
75 },
76 /// The newest candidate belongs to a different workspace than the one we
77 /// are launching in. Start fresh — resuming would silently move the user's
78 /// conversation to another project.
79 WorkspaceMismatch { session_id: String },
80 }
81
82 impl AutoResumeDecision {
83 /// The session id startup should actually load, if any.
84 #[must_use]
85 pub fn session_id(&self) -> Option<&str> {
86 match self {
87 Self::ExplicitRequest { session_id } | Self::Resume { session_id, .. } => {
88 Some(session_id.as_str())
89 }
90 Self::ForcedFresh
91 | Self::Disabled
92 | Self::NoSession
93 | Self::Unreadable { .. }
94 | Self::WorkspaceMismatch { .. } => None,
95 }
96 }
97
98 /// A short, user-facing receipt, or `None` when there is nothing worth
99 /// saying. Silence is correct for `Disabled` (the default posture) and for
100 /// `ExplicitRequest` (the existing resume path prints its own receipt).
101 #[must_use]
102 pub fn status_message(&self) -> Option<String> {
103 match self {
104 Self::ExplicitRequest { .. } | Self::ForcedFresh | Self::Disabled => None,
105 Self::Resume {
106 title,
107 skipped_unreadable: 0,
108 ..
109 } => Some(format!("Auto-resumed session: {title}")),
110 Self::Resume {
111 title,
112 skipped_unreadable,
113 ..
114 } => Some(format!(
115 "Auto-resumed session: {title} (skipped {skipped_unreadable} unreadable {} above it)",
116 plural_sessions(*skipped_unreadable)
117 )),
118 Self::NoSession => {
119 Some("Auto-resume: no previous session for this workspace — starting fresh".into())
120 }
121 Self::Unreadable {
122 session_id,
123 reason,
124 skipped_unreadable,
125 } => Some(format!(
126 "Auto-resume found no readable session ({skipped_unreadable} unreadable {}); newest was {}: {reason} — starting fresh",
127 plural_sessions(*skipped_unreadable),
128 crate::session_manager::truncate_id(session_id)
129 )),
130 Self::WorkspaceMismatch { session_id } => Some(format!(
131 "Auto-resume skipped session {}: it belongs to a different workspace — starting fresh",
132 crate::session_manager::truncate_id(session_id)
133 )),
134 }
135 }
136
137 /// True when startup will begin with an empty transcript.
138 ///
139 /// Test-only: startup itself branches on [`Self::session_id`], and this is
140 /// the same fact spelled the way the assertions read. Gating it keeps the
141 /// shipped surface to what the shipped code calls.
142 #[cfg(test)]
143 #[must_use]
144 pub fn starts_fresh(&self) -> bool {
145 self.session_id().is_none()
146 }
147 }
148
149 /// What the CLI asked for, independent of the persisted setting.
150 #[derive(Debug, Clone, Default)]
151 pub struct ResumeRequest {
152 /// `--resume <id>` (or a `--continue`-resolved id).
153 pub explicit_session_id: Option<String>,
154 /// `--fresh`.
155 pub force_fresh: bool,
156 }
157
158 /// Decide what, if anything, to auto-resume.
159 ///
160 /// `manager` is borrowed rather than opened here so tests and the `main`
161 /// startup path share one session directory, and so a caller that already has
162 /// a manager does not pay for a second directory resolution.
163 pub fn decide_auto_resume(
164 enabled: bool,
165 request: &ResumeRequest,
166 workspace: &Path,
167 manager: &SessionManager,
168 ) -> AutoResumeDecision {
169 if let Some(session_id) = request
170 .explicit_session_id
171 .as_deref()
172 .map(str::trim)
173 .filter(|id| !id.is_empty())
174 {
175 return AutoResumeDecision::ExplicitRequest {
176 session_id: session_id.to_string(),
177 };
178 }
179 if request.force_fresh {
180 return AutoResumeDecision::ForcedFresh;
181 }
182 if !enabled {
183 return AutoResumeDecision::Disabled;
184 }
185
186 let listed = match manager.list_sessions() {
187 Ok(listed) => listed,
188 // A sessions directory we cannot enumerate is not a reason to fail
189 // startup; it is a reason not to resume.
190 Err(err) => {
191 return AutoResumeDecision::Unreadable {
192 session_id: String::new(),
193 reason: format!("sessions directory unreadable ({err})"),
194 skipped_unreadable: 0,
195 };
196 }
197 };
198
199 // Candidates newest-first, scoped through the *same* matcher the picker,
200 // the rail, and the API use. `list_sessions` already sorts by `updated_at`
201 // descending.
202 let candidates = candidate_ids(&listed, workspace);
203 if candidates.is_empty() {
204 return AutoResumeDecision::NoSession;
205 }
206
207 let mut skipped_unreadable = 0usize;
208 let mut newest_failure: Option<(String, String)> = None;
209
210 // One corrupt newest session must not cost the user every older one. Walk
211 // down until something verifies, bounded so a directory full of damaged
212 // files cannot turn startup into a long scan.
213 for id in candidates.into_iter().take(MAX_AUTO_RESUME_CANDIDATES) {
214 // Verify against the real file before trusting it: the listing above
215 // only parsed each session's bounded metadata prefix.
216 let saved = match manager.load_session(&id) {
217 Ok(saved) => saved,
218 Err(err) => {
219 skipped_unreadable += 1;
220 if newest_failure.is_none() {
221 newest_failure = Some((id, describe_load_error(&err)));
222 }
223 continue;
224 }
225 };
226
227 // A workspace mismatch is a different failure from corruption: it means
228 // the candidate is fine but is not ours. Report it rather than counting
229 // it as damage, and stop — resuming past it would be guessing.
230 if !workspace_scope_matches(&saved.metadata.workspace, workspace) {
231 return AutoResumeDecision::WorkspaceMismatch {
232 session_id: saved.metadata.id,
233 };
234 }
235 if saved.metadata.archived {
236 continue;
237 }
238
239 return AutoResumeDecision::Resume {
240 title: saved.metadata.title.clone(),
241 session_id: saved.metadata.id,
242 skipped_unreadable,
243 };
244 }
245
246 match newest_failure {
247 Some((session_id, reason)) => AutoResumeDecision::Unreadable {
248 session_id,
249 reason,
250 skipped_unreadable,
251 },
252 None => AutoResumeDecision::NoSession,
253 }
254 }
255
256 /// Newest-first eligible session ids for `workspace`.
257 ///
258 /// Uses [`crate::session_projection::select_sessions`] so "which sessions
259 /// belong to this workspace" is answered by the same code the browse surfaces
260 /// use — auto-resume cannot pick something the rail would not have listed.
261 fn candidate_ids(
262 sessions: &[crate::session_manager::SessionMetadata],
263 workspace: &Path,
264 ) -> Vec<String> {
265 let query = crate::session_projection::SessionQuery::default()
266 .with_filter(crate::session_manager::SessionListFilter::ActiveOnly)
267 .with_sort(crate::session_projection::SessionSortMode::Recent)
268 .scoped_to(workspace)
269 .with_limit(MAX_AUTO_RESUME_CANDIDATES);
270 crate::session_projection::select_sessions(sessions, &query)
271 .into_iter()
272 .filter(|metadata| !is_empty_placeholder(metadata))
273 .map(|metadata| metadata.id.clone())
274 .collect()
275 }
276
277 /// An auto-created, never-used session is not something to resume into.
278 /// Mirrors the filter `get_latest_session_for_workspace` applies.
279 fn is_empty_placeholder(metadata: &crate::session_manager::SessionMetadata) -> bool {
280 metadata.message_count == 0 && metadata.title.trim().eq_ignore_ascii_case("New Session")
281 }
282
283 fn plural_sessions(count: usize) -> &'static str {
284 if count == 1 { "session" } else { "sessions" }
285 }
286
287 fn describe_load_error(err: &std::io::Error) -> String {
288 match err.kind() {
289 std::io::ErrorKind::NotFound => "session file is missing".to_string(),
290 std::io::ErrorKind::InvalidData => "session file is corrupt".to_string(),
291 std::io::ErrorKind::PermissionDenied => "session file is not readable".to_string(),
292 _ => err.to_string(),
293 }
294 }
295
296 #[cfg(test)]
297 mod tests {
298 use super::*;
299 use crate::models::{ContentBlock, Message};
300 use crate::session_manager::{SavedSession, create_saved_session_with_id_and_mode};
301 use std::path::PathBuf;
302 use tempfile::TempDir;
303
304 struct Fixture {
305 _dir: TempDir,
306 workspace: PathBuf,
307 manager: SessionManager,
308 }
309
310 fn fixture() -> Fixture {
311 let dir = TempDir::new().expect("tempdir");
312 let workspace = dir.path().join("workspace");
313 std::fs::create_dir_all(&workspace).expect("workspace dir");
314 let manager = SessionManager::new(dir.path().join("sessions")).expect("session manager");
315 Fixture {
316 _dir: dir,
317 workspace,
318 manager,
319 }
320 }
321
322 fn saved(id: &str, workspace: &Path, title: &str) -> SavedSession {
323 let messages = vec![Message {
324 role: "user".to_string(),
325 content: vec![ContentBlock::Text {
326 text: "hello".to_string(),
327 cache_control: None,
328 }],
329 }];
330 let mut session = create_saved_session_with_id_and_mode(
331 id.to_string(),
332 &messages,
333 "deepseek-chat",
334 workspace,
335 12,
336 None,
337 Some("agent"),
338 );
339 session.metadata.title = title.to_string();
340 session
341 }
342
343 #[test]
344 fn disabled_by_default_never_looks_at_disk() {
345 let fx = fixture();
346 fx.manager
347 .save_session(&saved("s1", &fx.workspace, "Prior work"))
348 .expect("save");
349
350 let decision =
351 decide_auto_resume(false, &ResumeRequest::default(), &fx.workspace, &fx.manager);
352
353 assert_eq!(decision, AutoResumeDecision::Disabled);
354 assert!(decision.starts_fresh());
355 assert_eq!(decision.status_message(), None);
356 }
357
358 #[test]
359 fn enabled_resumes_the_newest_valid_session_for_this_workspace() {
360 let fx = fixture();
361 fx.manager
362 .save_session(&saved("older", &fx.workspace, "Older work"))
363 .expect("save older");
364 let mut newer = saved("newer", &fx.workspace, "Newer work");
365 newer.metadata.updated_at = chrono::Utc::now() + chrono::Duration::minutes(5);
366 fx.manager.save_session(&newer).expect("save newer");
367
368 let decision =
369 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
370
371 assert_eq!(decision.session_id(), Some("newer"));
372 assert_eq!(
373 decision.status_message().as_deref(),
374 Some("Auto-resumed session: Newer work")
375 );
376 }
377
378 #[test]
379 fn missing_session_starts_fresh_with_a_receipt() {
380 let fx = fixture();
381
382 let decision =
383 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
384
385 assert_eq!(decision, AutoResumeDecision::NoSession);
386 assert!(decision.starts_fresh());
387 assert!(
388 decision
389 .status_message()
390 .expect("receipt")
391 .contains("starting fresh")
392 );
393 }
394
395 #[test]
396 fn corrupt_session_falls_back_to_fresh_instead_of_failing_startup() {
397 let fx = fixture();
398 fx.manager
399 .save_session(&saved("broken", &fx.workspace, "Broken work"))
400 .expect("save");
401 // Drop the closing brace: the metadata block still parses from the
402 // file prefix (so listing still surfaces the session), but the full
403 // load fails. That is exactly the shape auto-resume must survive.
404 let path = fx.manager.sessions_dir().join("broken.json");
405 let content = std::fs::read_to_string(&path).expect("read session");
406 let truncated = content
407 .trim_end()
408 .strip_suffix('}')
409 .expect("session JSON ends with }");
410 std::fs::write(&path, truncated).expect("truncate session");
411
412 let decision =
413 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
414
415 assert!(decision.starts_fresh());
416 assert!(
417 matches!(&decision, AutoResumeDecision::Unreadable { session_id, .. } if session_id == "broken"),
418 "expected an Unreadable decision, got {decision:?}"
419 );
420 assert!(
421 decision
422 .status_message()
423 .expect("receipt")
424 .contains("starting fresh")
425 );
426 }
427
428 #[test]
429 fn a_session_from_another_workspace_is_never_resumed() {
430 let fx = fixture();
431 let other = fx._dir.path().join("other-workspace");
432 std::fs::create_dir_all(&other).expect("other workspace");
433 fx.manager
434 .save_session(&saved("elsewhere", &other, "Someone else's project"))
435 .expect("save");
436
437 let decision =
438 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
439
440 assert!(decision.starts_fresh());
441 assert_eq!(decision, AutoResumeDecision::NoSession);
442 }
443
444 #[test]
445 fn archived_sessions_are_not_auto_resume_candidates() {
446 let fx = fixture();
447 let mut session = saved("put-away", &fx.workspace, "Put away");
448 session.metadata.archived = true;
449 fx.manager.save_session(&session).expect("save");
450
451 let decision =
452 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
453
454 assert_eq!(decision, AutoResumeDecision::NoSession);
455 }
456
457 #[test]
458 fn explicit_resume_wins_over_the_setting_in_both_directions() {
459 let fx = fixture();
460 fx.manager
461 .save_session(&saved("auto", &fx.workspace, "Auto candidate"))
462 .expect("save");
463
464 let explicit = ResumeRequest {
465 explicit_session_id: Some("chosen".to_string()),
466 force_fresh: false,
467 };
468 // Setting off: the explicit id still resumes.
469 assert_eq!(
470 decide_auto_resume(false, &explicit, &fx.workspace, &fx.manager).session_id(),
471 Some("chosen")
472 );
473 // Setting on: the explicit id is not replaced by the auto candidate.
474 assert_eq!(
475 decide_auto_resume(true, &explicit, &fx.workspace, &fx.manager).session_id(),
476 Some("chosen")
477 );
478 }
479
480 #[test]
481 fn fresh_flag_suppresses_auto_resume() {
482 let fx = fixture();
483 fx.manager
484 .save_session(&saved("auto", &fx.workspace, "Auto candidate"))
485 .expect("save");
486
487 let decision = decide_auto_resume(
488 true,
489 &ResumeRequest {
490 explicit_session_id: None,
491 force_fresh: true,
492 },
493 &fx.workspace,
494 &fx.manager,
495 );
496
497 assert_eq!(decision, AutoResumeDecision::ForcedFresh);
498 assert_eq!(decision.status_message(), None);
499 }
500
501 #[test]
502 fn blank_explicit_id_is_treated_as_absent() {
503 let fx = fixture();
504 let decision = decide_auto_resume(
505 false,
506 &ResumeRequest {
507 explicit_session_id: Some(" ".to_string()),
508 force_fresh: false,
509 },
510 &fx.workspace,
511 &fx.manager,
512 );
513 assert_eq!(decision, AutoResumeDecision::Disabled);
514 }
515 }
516
516 lines RUST