返回 CodeWhale
session_control_acceptance.rs
根目录 / crates / tui / src / session_control_acceptance.rs
1 //! Shared acceptance matrix for the session control plane (#2934, #4397).
2 //!
3 //! One table, one row per contract line, each row exercised by a test in this
4 //! module. The table exists so the contract is greppable and so a reviewer can
5 //! see what is covered *and what is not* without reverse-engineering it from
6 //! test names — an acceptance list that lives only in an issue body drifts the
7 //! moment the code moves.
8 //!
9 //! Scope note, stated here rather than implied: these are integration-level
10 //! checks over the durable session store and the pure projection/decision
11 //! layers. They do not drive a terminal. Rendering questions — how the rail
12 //! looks at 40 columns, whether the archived label is legible in a given
13 //! theme — are listed in [`HUMAN_VERIFICATION`] as explicitly human work, not
14 //! quietly claimed as covered.
15
16 use std::path::{Path, PathBuf};
17
18 use crate::models::{ContentBlock, Message};
19 use crate::session_manager::{
20 SavedSession, SessionListFilter, SessionManager, SessionMutator,
21 create_saved_session_with_id_and_mode,
22 };
23 use crate::session_projection::{SessionQuery, SessionSortMode, project_sessions};
24 use crate::session_resume::{AutoResumeDecision, ResumeRequest, decide_auto_resume};
25
26 /// Which issue's acceptance list a row comes from.
27 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
28 pub enum Contract {
29 /// #2934 — persistent multi-session TUI experience.
30 PersistentSessions,
31 /// #4397 — multi-session dashboard + approval/input control plane.
32 ControlPlane,
33 /// Constraints both issues share (offline browsing, truthfulness).
34 Shared,
35 }
36
37 /// One acceptance line and the test that holds it up.
38 #[derive(Debug, Clone, Copy)]
39 pub struct AcceptanceCase {
40 pub contract: Contract,
41 /// The behaviour promised, in the issue's own terms.
42 pub behavior: &'static str,
43 /// The test function in this module that exercises it.
44 pub test: &'static str,
45 }
46
47 /// The matrix. Every entry must name a test that exists in this module —
48 /// `every_matrix_row_names_a_real_test` enforces it, so a row cannot become a
49 /// claim with nothing behind it.
50 pub const ACCEPTANCE_MATRIX: &[AcceptanceCase] = &[
51 AcceptanceCase {
52 contract: Contract::PersistentSessions,
53 behavior: "Sessions persist across a restart and are found again by a fresh manager",
54 test: "sessions_persist_across_restart",
55 },
56 AcceptanceCase {
57 contract: Contract::PersistentSessions,
58 behavior: "Browsing is scoped to the current workspace; another project's sessions are not listed",
59 test: "browsing_is_scoped_to_the_selected_workspace",
60 },
61 AcceptanceCase {
62 contract: Contract::PersistentSessions,
63 behavior: "Auto-resume with a valid session reattaches to it",
64 test: "auto_resume_valid_session",
65 },
66 AcceptanceCase {
67 contract: Contract::PersistentSessions,
68 behavior: "Auto-resume with no session for this workspace starts fresh with a receipt",
69 test: "auto_resume_missing_session",
70 },
71 AcceptanceCase {
72 contract: Contract::PersistentSessions,
73 behavior: "Auto-resume with a corrupt session file starts fresh with a receipt",
74 test: "auto_resume_corrupt_session",
75 },
76 AcceptanceCase {
77 contract: Contract::PersistentSessions,
78 behavior: "Auto-resume disabled (the default) never reattaches and says nothing",
79 test: "auto_resume_disabled_by_default",
80 },
81 AcceptanceCase {
82 contract: Contract::PersistentSessions,
83 behavior: "Auto-resume never resumes a session recorded against a different workspace",
84 test: "auto_resume_never_crosses_a_workspace",
85 },
86 AcceptanceCase {
87 contract: Contract::PersistentSessions,
88 behavior: "Search, sort, and preview inputs come from one projection shared by every surface",
89 test: "search_sort_and_preview_are_one_projection",
90 },
91 AcceptanceCase {
92 contract: Contract::PersistentSessions,
93 behavior: "Rename persists to disk and survives a reload",
94 test: "rename_persists_and_survives_reload",
95 },
96 AcceptanceCase {
97 contract: Contract::ControlPlane,
98 behavior: "Archive is durable, hides the session from default listings, and is reversible",
99 test: "archive_is_durable_reversible_and_hidden_by_default",
100 },
101 AcceptanceCase {
102 contract: Contract::ControlPlane,
103 behavior: "The real picker's filtered/sorted view equals the API projection of the same query",
104 test: "tui_and_api_listings_agree",
105 },
106 AcceptanceCase {
107 contract: Contract::PersistentSessions,
108 behavior: "One workspace matcher: a nested path resolves to its repository root's scope",
109 test: "a_nested_path_resolves_to_the_same_scope_as_its_repository_root",
110 },
111 AcceptanceCase {
112 contract: Contract::ControlPlane,
113 behavior: "An archive applied to the active session survives the next autosave",
114 test: "archive_survives_the_next_autosave",
115 },
116 AcceptanceCase {
117 contract: Contract::ControlPlane,
118 behavior: "A rename applied to the active session survives the next autosave",
119 test: "rename_survives_the_next_autosave",
120 },
121 AcceptanceCase {
122 contract: Contract::ControlPlane,
123 behavior: "An external writer is refused with a typed conflict while a session is live",
124 test: "an_external_writer_is_refused_while_a_session_is_live",
125 },
126 AcceptanceCase {
127 contract: Contract::PersistentSessions,
128 behavior: "Auto-resume skips corrupt newer candidates and names the skipped count",
129 test: "auto_resume_skips_a_corrupt_newest_and_reports_how_many",
130 },
131 AcceptanceCase {
132 contract: Contract::PersistentSessions,
133 behavior: "Auto-resume reports honestly when every candidate is unreadable",
134 test: "auto_resume_reports_when_every_candidate_is_unreadable",
135 },
136 AcceptanceCase {
137 contract: Contract::Shared,
138 behavior: "The auto-resume candidate walk is bounded",
139 test: "auto_resume_candidate_walk_is_bounded",
140 },
141 AcceptanceCase {
142 contract: Contract::ControlPlane,
143 behavior: "Session archive filters resolve the include_archived/archived_only pair like threads",
144 test: "session_and_thread_archive_filters_share_one_resolution",
145 },
146 AcceptanceCase {
147 contract: Contract::Shared,
148 behavior: "Browsing and resume decisions perform no provider or network call",
149 test: "browsing_and_resume_are_offline",
150 },
151 AcceptanceCase {
152 contract: Contract::Shared,
153 behavior: "History and search results are bounded, never unbounded reads",
154 test: "history_and_search_results_are_bounded",
155 },
156 AcceptanceCase {
157 contract: Contract::Shared,
158 behavior: "Projections report only recorded state — no fabricated live status",
159 test: "projections_never_fabricate_live_state",
160 },
161 ];
162
163 /// Acceptance lines that are **not** covered here, and why.
164 ///
165 /// Listed rather than omitted: an untested claim that looks tested is worse
166 /// than an honest gap. Each of these needs eyes on a real terminal or a real
167 /// running runtime.
168 pub const HUMAN_VERIFICATION: &[&str] = &[
169 "Sessions rail legibility and truncation at narrow widths (40/60/80 columns) and short heights",
170 "Rail row, archived label, and current-session marker contrast in each shipped theme",
171 "Keyboard/modal ownership: `e`/`x` inside the picker do not leak to the composer, and the rail's row activation does not steal focus mid-turn",
172 "Localized rail and archive strings render without clipping in ja / zh-Hans / ko / vi / pt-BR / es-419",
173 "Web dashboard saved-sessions section: layout at mobile widths, and resume-into-thread behaviour against a live runtime",
174 "SSE gap/reconnect state in the dashboard after resuming a session into a new thread",
175 "Approval and user-input targeting from the dashboard against a live pending approval",
176 ];
177
178 #[cfg(test)]
179 mod tests {
180 use super::*;
181 use tempfile::TempDir;
182
183 struct Fixture {
184 dir: TempDir,
185 workspace: PathBuf,
186 manager: SessionManager,
187 }
188
189 impl Fixture {
190 fn new() -> Self {
191 let dir = TempDir::new().expect("tempdir");
192 let workspace = dir.path().join("workspace");
193 std::fs::create_dir_all(&workspace).expect("workspace");
194 let manager =
195 SessionManager::new(dir.path().join("sessions")).expect("session manager");
196 Self {
197 dir,
198 workspace,
199 manager,
200 }
201 }
202
203 /// Reopen the same directory with a new manager — the closest thing to
204 /// a process restart that a unit test can honestly claim.
205 fn reopen(&self) -> SessionManager {
206 SessionManager::new(self.manager.sessions_dir().to_path_buf())
207 .expect("reopen session manager")
208 }
209
210 fn save(&self, id: &str, title: &str, workspace: &Path) {
211 let session = saved(id, title, workspace);
212 self.manager.save_session(&session).expect("save session");
213 }
214 }
215
216 /// Metadata with every fuzzy-search haystack field (title, id, workspace)
217 /// fixed, so a search assertion is about the matcher and not about
218 /// whatever path `TempDir` happened to hand out.
219 fn metadata_row(
220 id: &str,
221 title: &str,
222 workspace: &str,
223 ) -> crate::session_manager::SessionMetadata {
224 let mut session = saved(id, title, Path::new(workspace));
225 session.metadata.workspace = PathBuf::from(workspace);
226 session.metadata
227 }
228
229 fn saved(id: &str, title: &str, workspace: &Path) -> SavedSession {
230 let messages = vec![
231 Message {
232 role: "user".to_string(),
233 content: vec![ContentBlock::Text {
234 text: format!("prompt for {title}"),
235 cache_control: None,
236 }],
237 },
238 Message {
239 role: "assistant".to_string(),
240 content: vec![ContentBlock::Text {
241 text: format!("reply for {title}"),
242 cache_control: None,
243 }],
244 },
245 ];
246 let mut session = create_saved_session_with_id_and_mode(
247 id.to_string(),
248 &messages,
249 "deepseek-chat",
250 workspace,
251 42,
252 None,
253 Some("agent"),
254 );
255 session.metadata.title = title.to_string();
256 session
257 }
258
259 #[test]
260 fn every_matrix_row_names_a_real_test() {
261 // The module's own source is the registry; a row naming a test that
262 // does not exist would otherwise read as coverage.
263 let source = include_str!("session_control_acceptance.rs");
264 for case in ACCEPTANCE_MATRIX {
265 assert!(
266 source.contains(&format!("fn {}(", case.test)),
267 "matrix row `{}` names missing test `{}`",
268 case.behavior,
269 case.test
270 );
271 }
272 assert!(
273 !HUMAN_VERIFICATION.is_empty(),
274 "the human-verification list must stay explicit, not silently empty"
275 );
276 }
277
278 #[test]
279 fn matrix_covers_both_issues_and_their_shared_constraints() {
280 for contract in [
281 Contract::PersistentSessions,
282 Contract::ControlPlane,
283 Contract::Shared,
284 ] {
285 assert!(
286 ACCEPTANCE_MATRIX
287 .iter()
288 .any(|case| case.contract == contract),
289 "no acceptance rows for {contract:?}"
290 );
291 }
292 }
293
294 #[test]
295 fn sessions_persist_across_restart() {
296 let fx = Fixture::new();
297 fx.save("persisted", "Whale migration", &fx.workspace);
298
299 let reopened = fx.reopen();
300 let listed = reopened.list_sessions().expect("list");
301
302 assert_eq!(listed.len(), 1);
303 assert_eq!(listed[0].title, "Whale migration");
304 let loaded = reopened.load_session("persisted").expect("load");
305 assert_eq!(loaded.messages.len(), 2);
306 }
307
308 #[test]
309 fn browsing_is_scoped_to_the_selected_workspace() {
310 let fx = Fixture::new();
311 let other = fx.dir.path().join("other");
312 std::fs::create_dir_all(&other).expect("other workspace");
313 fx.save("mine", "Mine", &fx.workspace);
314 fx.save("theirs", "Theirs", &other);
315
316 let all = fx.manager.list_sessions().expect("list");
317 let scoped = project_sessions(
318 &all,
319 &SessionQuery::default().scoped_to(&fx.workspace),
320 None,
321 );
322
323 assert_eq!(
324 scoped.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
325 vec!["mine"]
326 );
327 }
328
329 #[test]
330 fn auto_resume_valid_session() {
331 let fx = Fixture::new();
332 fx.save("valid", "Resumable work", &fx.workspace);
333
334 let decision =
335 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
336
337 assert_eq!(decision.session_id(), Some("valid"));
338 assert!(!decision.starts_fresh());
339 }
340
341 #[test]
342 fn auto_resume_missing_session() {
343 let fx = Fixture::new();
344
345 let decision =
346 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
347
348 assert_eq!(decision, AutoResumeDecision::NoSession);
349 assert!(
350 decision.status_message().is_some(),
351 "fallback needs a receipt"
352 );
353 }
354
355 #[test]
356 fn auto_resume_corrupt_session() {
357 let fx = Fixture::new();
358 fx.save("corrupt", "Half-written", &fx.workspace);
359 // Metadata still parses from the prefix; the full document does not.
360 let path = fx.manager.sessions_dir().join("corrupt.json");
361 let content = std::fs::read_to_string(&path).expect("read");
362 let truncated = content
363 .trim_end()
364 .strip_suffix('}')
365 .expect("session JSON ends with }");
366 std::fs::write(&path, truncated).expect("truncate");
367
368 let decision =
369 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
370
371 assert!(
372 decision.starts_fresh(),
373 "a corrupt session must not block startup"
374 );
375 assert!(matches!(decision, AutoResumeDecision::Unreadable { .. }));
376 }
377
378 #[test]
379 fn auto_resume_disabled_by_default() {
380 let fx = Fixture::new();
381 fx.save("ignored", "Not resumed", &fx.workspace);
382
383 let decision =
384 decide_auto_resume(false, &ResumeRequest::default(), &fx.workspace, &fx.manager);
385
386 assert_eq!(decision, AutoResumeDecision::Disabled);
387 assert_eq!(
388 decision.status_message(),
389 None,
390 "the default must be silent"
391 );
392 }
393
394 #[test]
395 fn auto_resume_never_crosses_a_workspace() {
396 let fx = Fixture::new();
397 let other = fx.dir.path().join("other");
398 std::fs::create_dir_all(&other).expect("other workspace");
399 fx.save("foreign", "Someone else's project", &other);
400
401 let decision =
402 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
403
404 assert!(decision.starts_fresh());
405 assert_ne!(decision.session_id(), Some("foreign"));
406 }
407
408 #[test]
409 fn search_sort_and_preview_are_one_projection() {
410 let fx = Fixture::new();
411 fx.save("alpha", "Alpha lane work", &fx.workspace);
412 fx.save("beta", "Beta lane work", &fx.workspace);
413 let all = fx.manager.list_sessions().expect("list");
414
415 // Search is asserted over a hand-built list rather than the temp-dir
416 // fixture on purpose: the fuzzy matcher's haystack includes the
417 // workspace path, and a random temp path can subsequence-match almost
418 // any query. Fixing all three haystack fields is what makes this an
419 // assertion about the matcher instead of about `TempDir`.
420 let deterministic = vec![
421 metadata_row("alpha", "Alpha lane work", "/repo"),
422 metadata_row("beta", "Beta lane work", "/repo"),
423 ];
424 let searched = project_sessions(
425 &deterministic,
426 &SessionQuery::default()
427 .scoped_to(Path::new("/repo"))
428 .with_search("alpha lane"),
429 None,
430 );
431 assert_eq!(
432 searched.iter().map(|s| s.id.as_str()).collect::<Vec<_>>(),
433 vec!["alpha"],
434 "a substring of one title must not drag the other row in"
435 );
436 assert!(
437 project_sessions(
438 &deterministic,
439 &SessionQuery::default().with_search("no-such-session"),
440 None,
441 )
442 .is_empty()
443 );
444
445 let by_name = project_sessions(
446 &all,
447 &SessionQuery::default()
448 .scoped_to(&fx.workspace)
449 .with_sort(SessionSortMode::Name),
450 None,
451 );
452 assert_eq!(
453 by_name.iter().map(|s| s.title.as_str()).collect::<Vec<_>>(),
454 vec!["Alpha lane work", "Beta lane work"]
455 );
456
457 // Preview is the recorded title, so it is stable and cheap. See the
458 // `session_projection` module docs for why it is not a last message.
459 assert!(by_name.iter().all(|s| s.preview == s.title));
460 }
461
462 #[test]
463 fn rename_persists_and_survives_reload() {
464 let fx = Fixture::new();
465 fx.save("renameable", "Original title", &fx.workspace);
466
467 let renamed = fx
468 .manager
469 .rename_session("renameable", " Renamed title ", SessionMutator::Owner)
470 .expect("rename");
471 assert_eq!(renamed.title, "Renamed title", "titles are trimmed");
472
473 let reloaded = fx.reopen().load_session("renameable").expect("reload");
474 assert_eq!(reloaded.metadata.title, "Renamed title");
475 assert_eq!(
476 reloaded.metadata.created_at, renamed.created_at,
477 "rename must not disturb creation time"
478 );
479
480 assert!(
481 fx.manager
482 .rename_session("renameable", " ", SessionMutator::Owner)
483 .is_err(),
484 "an empty title must be rejected, not silently applied"
485 );
486 assert!(
487 fx.manager
488 .rename_session("renameable", &"x".repeat(101), SessionMutator::Owner)
489 .is_err(),
490 "an over-long title must be rejected"
491 );
492 }
493
494 #[test]
495 fn archive_is_durable_reversible_and_hidden_by_default() {
496 let fx = Fixture::new();
497 fx.save("keep", "Active work", &fx.workspace);
498 fx.save("putaway", "Finished work", &fx.workspace);
499
500 let archived = fx
501 .manager
502 .set_session_archived("putaway", true, SessionMutator::Owner)
503 .expect("archive");
504 assert!(archived.archived);
505
506 // Durable: a fresh manager sees the flag.
507 let reopened = fx.reopen();
508 assert!(
509 reopened
510 .load_session("putaway")
511 .expect("reload")
512 .metadata
513 .archived
514 );
515
516 // Hidden by default, visible on request — asserted through the same
517 // selection seam the picker, the rail, and `/v1/sessions` run, not a
518 // test-only listing helper that would prove nothing about them.
519 let listed = reopened.list_sessions().expect("list");
520 let ids_for = |filter| {
521 crate::session_projection::select_sessions(
522 &listed,
523 &crate::session_projection::SessionQuery::default().with_filter(filter),
524 )
525 .into_iter()
526 .map(|s| s.id.clone())
527 .collect::<Vec<_>>()
528 };
529 assert_eq!(ids_for(SessionListFilter::ActiveOnly), vec!["keep"]);
530 assert_eq!(ids_for(SessionListFilter::ArchivedOnly), vec!["putaway"]);
531 assert_eq!(ids_for(SessionListFilter::IncludeArchived).len(), 2);
532
533 // Not an auto-resume candidate while archived.
534 assert_ne!(
535 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &reopened)
536 .session_id(),
537 Some("putaway")
538 );
539
540 // Reversible.
541 let restored = reopened
542 .set_session_archived("putaway", false, SessionMutator::Owner)
543 .expect("restore");
544 assert!(!restored.archived);
545 let relisted = reopened.list_sessions().expect("list");
546 assert_eq!(
547 crate::session_projection::select_sessions(
548 &relisted,
549 &crate::session_projection::SessionQuery::default()
550 .with_filter(SessionListFilter::ActiveOnly),
551 )
552 .len(),
553 2
554 );
555 }
556
557 #[test]
558 fn tui_and_api_listings_agree() {
559 // The previous version of this test projected the same query twice and
560 // asserted the results matched, which proves nothing. This one drives
561 // the *actual picker* — its filtering, its sort cycling, its workspace
562 // scope — and compares against the API's projection of the same store.
563 let _lock = crate::test_support::lock_test_env();
564 let tmp = TempDir::new().expect("tempdir");
565 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", tmp.path());
566 let manager = SessionManager::default_location().expect("manager");
567 let workspace = tmp.path().join("workspace");
568 let other = tmp.path().join("other");
569 std::fs::create_dir_all(&workspace).expect("workspace");
570 std::fs::create_dir_all(&other).expect("other");
571
572 for (id, title, ws) in [
573 ("alpha", "Alpha work", &workspace),
574 ("bravo", "Bravo work", &workspace),
575 ("charlie", "Charlie work", &workspace),
576 ("foreign", "Another project", &other),
577 ] {
578 manager.save_session(&saved(id, title, ws)).expect("save");
579 }
580 let all = manager.list_sessions().expect("list");
581
582 let mut picker = crate::tui::session_picker::SessionPickerView::new(
583 &workspace,
584 crate::localization::Locale::En,
585 );
586
587 assert_eq!(
588 picker.visible_session_ids(),
589 api_ids(&all, &picker.view_query_for_test()),
590 "picker default view must equal the API projection of the same query"
591 );
592 assert!(
593 !picker
594 .visible_session_ids()
595 .contains(&"foreign".to_string()),
596 "workspace scope must exclude another project"
597 );
598
599 // Every sort mode, including tie-breaks, must still agree.
600 for _ in 0..3 {
601 picker.cycle_sort_for_test();
602 assert_eq!(
603 picker.visible_session_ids(),
604 api_ids(&all, &picker.view_query_for_test()),
605 "picker and API must agree after cycling sort"
606 );
607 }
608
609 picker.set_search_for_test("brav");
610 assert_eq!(picker.visible_session_ids(), vec!["bravo".to_string()]);
611 assert_eq!(
612 picker.visible_session_ids(),
613 api_ids(&all, &picker.view_query_for_test())
614 );
615
616 picker.set_search_for_test("");
617 picker.toggle_all_workspaces();
618 assert_eq!(
619 picker.visible_session_ids(),
620 api_ids(&all, &picker.view_query_for_test())
621 );
622 assert!(
623 picker
624 .visible_session_ids()
625 .contains(&"foreign".to_string()),
626 "broadening scope must reach the other workspace"
627 );
628 }
629
630 /// The API's answer for a query, as ids.
631 fn api_ids(
632 sessions: &[crate::session_manager::SessionMetadata],
633 query: &SessionQuery,
634 ) -> Vec<String> {
635 project_sessions(sessions, query, None)
636 .into_iter()
637 .map(|row| row.id)
638 .collect()
639 }
640
641 #[test]
642 fn a_nested_path_resolves_to_the_same_scope_as_its_repository_root() {
643 // Plain path equality — what the picker used to do — would treat these
644 // as different projects. The shared matcher walks to the git root, so a
645 // linked worktree or a nested crate dir stays in scope.
646 let fx = Fixture::new();
647 let repo = fx.dir.path().join("repo");
648 let nested = repo.join("crates").join("tui");
649 std::fs::create_dir_all(&nested).expect("nested");
650 std::fs::create_dir_all(repo.join(".git")).expect("git dir");
651 std::fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("HEAD");
652
653 fx.save("root", "At the repo root", &repo);
654 let all = fx.manager.list_sessions().expect("list");
655
656 let from_nested = project_sessions(&all, &SessionQuery::default().scoped_to(&nested), None);
657 assert_eq!(
658 from_nested
659 .iter()
660 .map(|r| r.id.as_str())
661 .collect::<Vec<_>>(),
662 vec!["root"],
663 "a session saved at the repo root must be in scope from a nested path"
664 );
665 }
666
667 #[test]
668 fn archive_survives_the_next_autosave() {
669 // The autosave-survival gate. An autosave rebuilds metadata from
670 // in-memory state; a stale copy would revert the archive. The writer
671 // re-reads persisted lifecycle fields first.
672 let fx = Fixture::new();
673 fx.save("live", "Being worked on", &fx.workspace);
674
675 let stale = fx
676 .manager
677 .list_sessions()
678 .expect("list")
679 .into_iter()
680 .find(|m| m.id == "live")
681 .expect("metadata");
682 assert!(!stale.archived);
683
684 fx.manager
685 .set_session_archived("live", true, SessionMutator::Owner)
686 .expect("archive");
687
688 // Simulate the autosave: rebuild from the stale snapshot, then merge.
689 let mut autosaved = stale.clone();
690 autosaved.message_count = 99;
691 assert!(fx.manager.merge_persisted_lifecycle(&mut autosaved));
692 assert!(
693 autosaved.archived,
694 "autosave must not revert an archive applied after its snapshot"
695 );
696 assert_eq!(
697 autosaved.message_count, 99,
698 "merging lifecycle state must not clobber conversation state"
699 );
700 }
701
702 #[test]
703 fn rename_survives_the_next_autosave() {
704 let fx = Fixture::new();
705 fx.save("live", "Before", &fx.workspace);
706 let stale = fx
707 .manager
708 .list_sessions()
709 .expect("list")
710 .into_iter()
711 .find(|m| m.id == "live")
712 .expect("metadata");
713
714 fx.manager
715 .rename_session("live", "After", SessionMutator::Owner)
716 .expect("rename");
717
718 let mut autosaved = stale.clone();
719 assert!(fx.manager.merge_persisted_lifecycle(&mut autosaved));
720 assert_eq!(autosaved.title, "After");
721 }
722
723 #[test]
724 fn an_external_writer_is_refused_while_a_session_is_live() {
725 // The archive-race gate. The TUI owns the in-memory copy, so an
726 // out-of-band write must fail closed rather than be reverted later.
727 let _lock = crate::test_support::lock_test_env();
728 let fx = Fixture::new();
729 fx.save("owned", "Open in the TUI", &fx.workspace);
730 crate::session_manager::set_live_session(Some("owned"));
731
732 let refused = fx
733 .manager
734 .set_session_archived("owned", true, SessionMutator::External)
735 .expect_err("external write must be refused while the session is live");
736 assert_eq!(refused.kind(), std::io::ErrorKind::ResourceBusy);
737 assert!(
738 !fx.manager
739 .load_session("owned")
740 .expect("reload")
741 .metadata
742 .archived,
743 "a refused write must not have partially applied"
744 );
745
746 let refused_rename = fx
747 .manager
748 .rename_session("owned", "Nope", SessionMutator::External)
749 .expect_err("external rename must be refused too");
750 assert_eq!(refused_rename.kind(), std::io::ErrorKind::ResourceBusy);
751
752 // The owner is still allowed.
753 assert!(
754 fx.manager
755 .set_session_archived("owned", true, SessionMutator::Owner)
756 .is_ok()
757 );
758
759 // Releasing the claim re-opens external writes.
760 crate::session_manager::set_live_session(None);
761 assert!(
762 fx.manager
763 .rename_session("owned", "Now allowed", SessionMutator::External)
764 .is_ok()
765 );
766 }
767
768 #[test]
769 fn auto_resume_skips_a_corrupt_newest_and_reports_how_many() {
770 let fx = Fixture::new();
771 fx.save("older", "Older but readable", &fx.workspace);
772 for id in ["broken-a", "broken-b"] {
773 let mut session = saved(id, "Damaged", &fx.workspace);
774 session.metadata.updated_at = chrono::Utc::now() + chrono::Duration::minutes(10);
775 fx.manager.save_session(&session).expect("save");
776 let path = fx.manager.sessions_dir().join(format!("{id}.json"));
777 let content = std::fs::read_to_string(&path).expect("read");
778 let truncated = content.trim_end().strip_suffix('}').expect("closing brace");
779 std::fs::write(&path, truncated).expect("truncate");
780 }
781
782 let decision =
783 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
784
785 assert_eq!(
786 decision.session_id(),
787 Some("older"),
788 "one corrupt newest session must not cost the user every older one"
789 );
790 assert!(
791 matches!(
792 decision,
793 AutoResumeDecision::Resume {
794 skipped_unreadable: 2,
795 ..
796 }
797 ),
798 "the receipt must count what was skipped, got {decision:?}"
799 );
800 let receipt = decision.status_message().expect("receipt");
801 assert!(
802 receipt.contains("skipped 2 unreadable"),
803 "receipt must name the skipped count: {receipt}"
804 );
805 }
806
807 #[test]
808 fn auto_resume_reports_when_every_candidate_is_unreadable() {
809 let fx = Fixture::new();
810 for id in ["aa", "bb"] {
811 fx.save(id, "Damaged", &fx.workspace);
812 let path = fx.manager.sessions_dir().join(format!("{id}.json"));
813 let content = std::fs::read_to_string(&path).expect("read");
814 let truncated = content.trim_end().strip_suffix('}').expect("closing brace");
815 std::fs::write(&path, truncated).expect("truncate");
816 }
817
818 let decision =
819 decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager);
820
821 assert!(decision.starts_fresh());
822 assert!(matches!(
823 decision,
824 AutoResumeDecision::Unreadable {
825 skipped_unreadable: 2,
826 ..
827 }
828 ));
829 }
830
831 #[test]
832 fn auto_resume_candidate_walk_is_bounded() {
833 const {
834 assert!(
835 crate::session_resume::MAX_AUTO_RESUME_CANDIDATES <= 16,
836 "a damaged sessions directory must not turn startup into a long scan"
837 );
838 }
839 }
840
841 #[test]
842 fn session_and_thread_archive_filters_share_one_resolution() {
843 use crate::runtime_threads::ThreadListFilter;
844
845 for (include, only, expected_session, expected_thread) in [
846 (
847 None,
848 None,
849 SessionListFilter::ActiveOnly,
850 ThreadListFilter::ActiveOnly,
851 ),
852 (
853 Some(true),
854 None,
855 SessionListFilter::IncludeArchived,
856 ThreadListFilter::IncludeArchived,
857 ),
858 (
859 None,
860 Some(true),
861 SessionListFilter::ArchivedOnly,
862 ThreadListFilter::ArchivedOnly,
863 ),
864 (
865 Some(true),
866 Some(true),
867 SessionListFilter::ArchivedOnly,
868 ThreadListFilter::ArchivedOnly,
869 ),
870 ] {
871 assert_eq!(
872 SessionListFilter::from_query(include, only),
873 expected_session
874 );
875 // Thread-side expectation is spelled out so a future change to
876 // either resolver breaks this test rather than drifting quietly.
877 let thread = if only.unwrap_or(false) {
878 ThreadListFilter::ArchivedOnly
879 } else if include.unwrap_or(false) {
880 ThreadListFilter::IncludeArchived
881 } else {
882 ThreadListFilter::ActiveOnly
883 };
884 assert_eq!(thread, expected_thread);
885 }
886 }
887
888 #[test]
889 fn browsing_and_resume_are_offline() {
890 // Structural, not behavioural: the browse/resume path must not name a
891 // provider or HTTP client. A test that "made no network call" during
892 // one run would prove nothing about the next one.
893 for (name, source) in [
894 ("session_projection", include_str!("session_projection.rs")),
895 ("session_resume", include_str!("session_resume.rs")),
896 ] {
897 for forbidden in [
898 "reqwest",
899 "llm_client",
900 "ApiProvider",
901 "http://",
902 "https://",
903 ] {
904 assert!(
905 !source.contains(forbidden),
906 "{name} must stay offline but references `{forbidden}`"
907 );
908 }
909 }
910 }
911
912 #[test]
913 fn history_and_search_results_are_bounded() {
914 let fx = Fixture::new();
915 for i in 0..30 {
916 fx.save(&format!("s{i:02}"), &format!("Session {i}"), &fx.workspace);
917 }
918 let all = fx.manager.list_sessions().expect("list");
919
920 let capped = project_sessions(
921 &all,
922 &SessionQuery::default()
923 .scoped_to(&fx.workspace)
924 .with_limit(5),
925 None,
926 );
927 assert_eq!(capped.len(), 5);
928
929 // Even an unbounded request is clamped by the projection cap.
930 let unbounded = project_sessions(
931 &all,
932 &SessionQuery::default()
933 .scoped_to(&fx.workspace)
934 .with_limit(usize::MAX),
935 None,
936 );
937 assert!(unbounded.len() <= crate::session_projection::MAX_PROJECTED_SESSIONS);
938
939 // Row text is bounded too, so one pathological title cannot blow up a
940 // row or a response.
941 let long = "x".repeat(5_000);
942 fx.save("long", &long, &fx.workspace);
943 let rows = project_sessions(
944 &fx.manager.list_sessions().expect("list"),
945 &SessionQuery::default().scoped_to(&fx.workspace),
946 None,
947 );
948 let long_row = rows.iter().find(|r| r.id == "long").expect("long row");
949 assert!(long_row.title.chars().count() <= 140);
950 assert!(long_row.preview.chars().count() <= 140);
951 }
952
953 #[test]
954 fn projections_never_fabricate_live_state() {
955 let fx = Fixture::new();
956 fx.save("recorded", "Recorded work", &fx.workspace);
957 let all = fx.manager.list_sessions().expect("list");
958
959 // No caller-supplied active id: nothing may claim to be current.
960 let anonymous = project_sessions(
961 &all,
962 &SessionQuery::default().scoped_to(&fx.workspace),
963 None,
964 );
965 assert!(
966 anonymous.iter().all(|row| !row.is_current),
967 "current-ness comes from the caller, never inferred from disk"
968 );
969
970 // Counts and timestamps are the recorded ones, not derived guesses.
971 let row = &anonymous[0];
972 let metadata = all.iter().find(|m| m.id == row.id).expect("metadata");
973 assert_eq!(row.message_count, metadata.message_count);
974 assert_eq!(row.updated_at, metadata.updated_at);
975 assert_eq!(row.total_tokens, metadata.total_tokens);
976 assert_eq!(row.archived, metadata.archived);
977 }
978 }
979
979 lines RUST