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