//! Shared acceptance matrix for the session control plane (#2934, #4397). //! //! One table, one row per contract line, each row exercised by a test in this //! module. The table exists so the contract is greppable and so a reviewer can //! see what is covered *and what is not* without reverse-engineering it from //! test names — an acceptance list that lives only in an issue body drifts the //! moment the code moves. //! //! Scope note, stated here rather than implied: these are integration-level //! checks over the durable session store and the pure projection/decision //! layers. They do not drive a terminal. Rendering questions — how the rail //! looks at 40 columns, whether the archived label is legible in a given //! theme — are listed in [`HUMAN_VERIFICATION`] as explicitly human work, not //! quietly claimed as covered. use std::path::{Path, PathBuf}; use codewhale_engine::session_manager::{ SavedSession, SessionListFilter, SessionManager, SessionMutator, create_saved_session_with_id_and_mode, }; use codewhale_engine::session_projection::{SessionQuery, SessionSortMode, project_sessions}; use codewhale_engine::session_resume::{AutoResumeDecision, ResumeRequest, decide_auto_resume}; use codewhale_models::Role; use codewhale_models::{ContentBlock, Message}; /// Which issue's acceptance list a row comes from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Contract { /// #2934 — persistent multi-session TUI experience. PersistentSessions, /// #4397 — multi-session dashboard + approval/input control plane. ControlPlane, /// Constraints both issues share (offline browsing, truthfulness). Shared, } /// One acceptance line and the test that holds it up. #[derive(Debug, Clone, Copy)] pub struct AcceptanceCase { pub contract: Contract, /// The behaviour promised, in the issue's own terms. pub behavior: &'static str, /// The test function in this module that exercises it. pub test: &'static str, } /// The matrix. Every entry must name a test that exists in this module — /// `every_matrix_row_names_a_real_test` enforces it, so a row cannot become a /// claim with nothing behind it. pub const ACCEPTANCE_MATRIX: &[AcceptanceCase] = &[ AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Sessions persist across a restart and are found again by a fresh manager", test: "sessions_persist_across_restart", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Browsing is scoped to the current workspace; another project's sessions are not listed", test: "browsing_is_scoped_to_the_selected_workspace", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Auto-resume with a valid session reattaches to it", test: "auto_resume_valid_session", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Auto-resume with no session for this workspace starts fresh with a receipt", test: "auto_resume_missing_session", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Auto-resume with a corrupt session file starts fresh with a receipt", test: "auto_resume_corrupt_session", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Auto-resume disabled (the default) never reattaches and says nothing", test: "auto_resume_disabled_by_default", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Auto-resume never resumes a session recorded against a different workspace", test: "auto_resume_never_crosses_a_workspace", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Search, sort, and preview inputs come from one projection shared by every surface", test: "search_sort_and_preview_are_one_projection", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Rename persists to disk and survives a reload", test: "rename_persists_and_survives_reload", }, AcceptanceCase { contract: Contract::ControlPlane, behavior: "Archive is durable, hides the session from default listings, and is reversible", test: "archive_is_durable_reversible_and_hidden_by_default", }, AcceptanceCase { contract: Contract::ControlPlane, behavior: "The real picker's filtered/sorted view equals the API projection of the same query", test: "tui_and_api_listings_agree", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "One workspace matcher: a nested path resolves to its repository root's scope", test: "a_nested_path_resolves_to_the_same_scope_as_its_repository_root", }, AcceptanceCase { contract: Contract::ControlPlane, behavior: "An archive applied to the active session survives the next autosave", test: "archive_survives_the_next_autosave", }, AcceptanceCase { contract: Contract::ControlPlane, behavior: "A rename applied to the active session survives the next autosave", test: "rename_survives_the_next_autosave", }, AcceptanceCase { contract: Contract::ControlPlane, behavior: "An external writer is refused with a typed conflict while a session is live", test: "an_external_writer_is_refused_while_a_session_is_live", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Auto-resume skips corrupt newer candidates and names the skipped count", test: "auto_resume_skips_a_corrupt_newest_and_reports_how_many", }, AcceptanceCase { contract: Contract::PersistentSessions, behavior: "Auto-resume reports honestly when every candidate is unreadable", test: "auto_resume_reports_when_every_candidate_is_unreadable", }, AcceptanceCase { contract: Contract::Shared, behavior: "The auto-resume candidate walk is bounded", test: "auto_resume_candidate_walk_is_bounded", }, AcceptanceCase { contract: Contract::ControlPlane, behavior: "Session archive filters resolve the include_archived/archived_only pair like threads", test: "session_and_thread_archive_filters_share_one_resolution", }, AcceptanceCase { contract: Contract::Shared, behavior: "Browsing and resume decisions perform no provider or network call", test: "browsing_and_resume_are_offline", }, AcceptanceCase { contract: Contract::Shared, behavior: "History and search results are bounded, never unbounded reads", test: "history_and_search_results_are_bounded", }, AcceptanceCase { contract: Contract::Shared, behavior: "Projections report only recorded state — no fabricated live status", test: "projections_never_fabricate_live_state", }, ]; /// Acceptance lines that are **not** covered here, and why. /// /// Listed rather than omitted: an untested claim that looks tested is worse /// than an honest gap. Each of these needs eyes on a real terminal or a real /// running runtime. pub const HUMAN_VERIFICATION: &[&str] = &[ "Sessions rail legibility and truncation at narrow widths (40/60/80 columns) and short heights", "Rail row, archived label, and current-session marker contrast in each shipped theme", "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", "Localized rail and archive strings render without clipping in ja / zh-Hans / ko / vi / pt-BR / es-419", "Web dashboard saved-sessions section: layout at mobile widths, and resume-into-thread behaviour against a live runtime", "SSE gap/reconnect state in the dashboard after resuming a session into a new thread", "Approval and user-input targeting from the dashboard against a live pending approval", ]; #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; struct Fixture { dir: TempDir, workspace: PathBuf, manager: SessionManager, } impl Fixture { fn new() -> Self { let dir = TempDir::new().expect("tempdir"); let workspace = dir.path().join("workspace"); std::fs::create_dir_all(&workspace).expect("workspace"); let manager = SessionManager::new(dir.path().join("sessions")).expect("session manager"); Self { dir, workspace, manager, } } /// Reopen the same directory with a new manager — the closest thing to /// a process restart that a unit test can honestly claim. fn reopen(&self) -> SessionManager { SessionManager::new(self.manager.sessions_dir().to_path_buf()) .expect("reopen session manager") } fn save(&self, id: &str, title: &str, workspace: &Path) { let session = saved(id, title, workspace); self.manager.save_session(&session).expect("save session"); } } /// Metadata with every fuzzy-search haystack field (title, id, workspace) /// fixed, so a search assertion is about the matcher and not about /// whatever path `TempDir` happened to hand out. fn metadata_row( id: &str, title: &str, workspace: &str, ) -> codewhale_engine::session_manager::SessionMetadata { let mut session = saved(id, title, Path::new(workspace)); session.metadata.workspace = PathBuf::from(workspace); session.metadata } fn saved(id: &str, title: &str, workspace: &Path) -> SavedSession { let messages = vec![ Message { role: Role::User, content: vec![ContentBlock::Text { text: format!("prompt for {title}"), cache_control: None, }], }, Message { role: Role::Assistant, content: vec![ContentBlock::Text { text: format!("reply for {title}"), cache_control: None, }], }, ]; let mut session = create_saved_session_with_id_and_mode( id.to_string(), &messages, "deepseek-chat", workspace, 42, None, Some("agent"), ); session.metadata.title = title.to_string(); session } #[test] fn every_matrix_row_names_a_real_test() { // The module's own source is the registry; a row naming a test that // does not exist would otherwise read as coverage. let source = include_str!("session_control_acceptance.rs"); for case in ACCEPTANCE_MATRIX { assert!( source.contains(&format!("fn {}(", case.test)), "matrix row `{}` names missing test `{}`", case.behavior, case.test ); } assert!( !HUMAN_VERIFICATION.is_empty(), "the human-verification list must stay explicit, not silently empty" ); } #[test] fn matrix_covers_both_issues_and_their_shared_constraints() { for contract in [ Contract::PersistentSessions, Contract::ControlPlane, Contract::Shared, ] { assert!( ACCEPTANCE_MATRIX .iter() .any(|case| case.contract == contract), "no acceptance rows for {contract:?}" ); } } #[test] fn sessions_persist_across_restart() { let fx = Fixture::new(); fx.save("persisted", "Whale migration", &fx.workspace); let reopened = fx.reopen(); let listed = reopened.list_sessions().expect("list"); assert_eq!(listed.len(), 1); assert_eq!(listed[0].title, "Whale migration"); let loaded = reopened.load_session("persisted").expect("load"); assert_eq!(loaded.messages.len(), 2); } #[test] fn browsing_is_scoped_to_the_selected_workspace() { let fx = Fixture::new(); let other = fx.dir.path().join("other"); std::fs::create_dir_all(&other).expect("other workspace"); fx.save("mine", "Mine", &fx.workspace); fx.save("theirs", "Theirs", &other); let all = fx.manager.list_sessions().expect("list"); let scoped = project_sessions( &all, &SessionQuery::default().scoped_to(&fx.workspace), None, ); assert_eq!( scoped.iter().map(|s| s.id.as_str()).collect::>(), vec!["mine"] ); } #[test] fn auto_resume_valid_session() { let fx = Fixture::new(); fx.save("valid", "Resumable work", &fx.workspace); let decision = decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager); assert_eq!(decision.session_id(), Some("valid")); assert!(!decision.starts_fresh()); } #[test] fn auto_resume_missing_session() { let fx = Fixture::new(); let decision = decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager); assert_eq!(decision, AutoResumeDecision::NoSession); assert!( decision.status_message().is_some(), "fallback needs a receipt" ); } #[test] fn auto_resume_corrupt_session() { let fx = Fixture::new(); fx.save("corrupt", "Half-written", &fx.workspace); // Metadata still parses from the prefix; the full document does not. let path = fx.manager.sessions_dir().join("corrupt.json"); let content = std::fs::read_to_string(&path).expect("read"); let truncated = content .trim_end() .strip_suffix('}') .expect("session JSON ends with }"); std::fs::write(&path, truncated).expect("truncate"); let decision = decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager); assert!( decision.starts_fresh(), "a corrupt session must not block startup" ); assert!(matches!(decision, AutoResumeDecision::Unreadable { .. })); } #[test] fn auto_resume_disabled_by_default() { let fx = Fixture::new(); fx.save("ignored", "Not resumed", &fx.workspace); let decision = decide_auto_resume(false, &ResumeRequest::default(), &fx.workspace, &fx.manager); assert_eq!(decision, AutoResumeDecision::Disabled); assert_eq!( decision.status_message(), None, "the default must be silent" ); } #[test] fn auto_resume_never_crosses_a_workspace() { let fx = Fixture::new(); let other = fx.dir.path().join("other"); std::fs::create_dir_all(&other).expect("other workspace"); fx.save("foreign", "Someone else's project", &other); let decision = decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager); assert!(decision.starts_fresh()); assert_ne!(decision.session_id(), Some("foreign")); } #[test] fn search_sort_and_preview_are_one_projection() { let fx = Fixture::new(); fx.save("alpha", "Alpha lane work", &fx.workspace); fx.save("beta", "Beta lane work", &fx.workspace); let all = fx.manager.list_sessions().expect("list"); // Search is asserted over a hand-built list rather than the temp-dir // fixture on purpose: the fuzzy matcher's haystack includes the // workspace path, and a random temp path can subsequence-match almost // any query. Fixing all three haystack fields is what makes this an // assertion about the matcher instead of about `TempDir`. let deterministic = vec![ metadata_row("alpha", "Alpha lane work", "/repo"), metadata_row("beta", "Beta lane work", "/repo"), ]; let searched = project_sessions( &deterministic, &SessionQuery::default() .scoped_to(Path::new("/repo")) .with_search("alpha lane"), None, ); assert_eq!( searched.iter().map(|s| s.id.as_str()).collect::>(), vec!["alpha"], "a substring of one title must not drag the other row in" ); assert!( project_sessions( &deterministic, &SessionQuery::default().with_search("no-such-session"), None, ) .is_empty() ); let by_name = project_sessions( &all, &SessionQuery::default() .scoped_to(&fx.workspace) .with_sort(SessionSortMode::Name), None, ); assert_eq!( by_name.iter().map(|s| s.title.as_str()).collect::>(), vec!["Alpha lane work", "Beta lane work"] ); // Preview is the recorded title, so it is stable and cheap. See the // `session_projection` module docs for why it is not a last message. assert!(by_name.iter().all(|s| s.preview == s.title)); } #[test] fn rename_persists_and_survives_reload() { let fx = Fixture::new(); fx.save("renameable", "Original title", &fx.workspace); let renamed = fx .manager .rename_session("renameable", " Renamed title ", SessionMutator::Owner) .expect("rename"); assert_eq!(renamed.title, "Renamed title", "titles are trimmed"); let reloaded = fx.reopen().load_session("renameable").expect("reload"); assert_eq!(reloaded.metadata.title, "Renamed title"); assert_eq!( reloaded.metadata.created_at, renamed.created_at, "rename must not disturb creation time" ); assert!( fx.manager .rename_session("renameable", " ", SessionMutator::Owner) .is_err(), "an empty title must be rejected, not silently applied" ); assert!( fx.manager .rename_session("renameable", &"x".repeat(101), SessionMutator::Owner) .is_err(), "an over-long title must be rejected" ); } #[test] fn archive_is_durable_reversible_and_hidden_by_default() { let fx = Fixture::new(); fx.save("keep", "Active work", &fx.workspace); fx.save("putaway", "Finished work", &fx.workspace); let archived = fx .manager .set_session_archived("putaway", true, SessionMutator::Owner) .expect("archive"); assert!(archived.archived); // Durable: a fresh manager sees the flag. let reopened = fx.reopen(); assert!( reopened .load_session("putaway") .expect("reload") .metadata .archived ); // Hidden by default, visible on request — asserted through the same // selection seam the picker, the rail, and `/v1/sessions` run, not a // test-only listing helper that would prove nothing about them. let listed = reopened.list_sessions().expect("list"); let ids_for = |filter| { codewhale_engine::session_projection::select_sessions( &listed, &codewhale_engine::session_projection::SessionQuery::default().with_filter(filter), ) .into_iter() .map(|s| s.id.clone()) .collect::>() }; assert_eq!(ids_for(SessionListFilter::ActiveOnly), vec!["keep"]); assert_eq!(ids_for(SessionListFilter::ArchivedOnly), vec!["putaway"]); assert_eq!(ids_for(SessionListFilter::IncludeArchived).len(), 2); // Not an auto-resume candidate while archived. assert_ne!( decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &reopened) .session_id(), Some("putaway") ); // Reversible. let restored = reopened .set_session_archived("putaway", false, SessionMutator::Owner) .expect("restore"); assert!(!restored.archived); let relisted = reopened.list_sessions().expect("list"); assert_eq!( codewhale_engine::session_projection::select_sessions( &relisted, &codewhale_engine::session_projection::SessionQuery::default() .with_filter(SessionListFilter::ActiveOnly), ) .len(), 2 ); } #[test] fn tui_and_api_listings_agree() { // The previous version of this test projected the same query twice and // asserted the results matched, which proves nothing. This one drives // the *actual picker* — its filtering, its sort cycling, its workspace // scope — and compares against the API's projection of the same store. // // The store is a private directory, not `default_location()` behind a // redirected `CODEWHALE_HOME`: that redirect is process-global, and a // concurrent test resolving the store without the env barrier could // observe it mid-test and write sessions into this fixture between the // two listings (#5929). The picker is fed the same list through // `new_with_sessions`, so both sides still see one store snapshot. let tmp = TempDir::new().expect("tempdir"); let manager = SessionManager::new(tmp.path().join("sessions")).expect("manager"); let workspace = PathBuf::from("/repo"); let other = PathBuf::from("/other"); for (id, title, ws) in [ ("alpha", "Alpha work", &workspace), ("bravo", "Bravo work", &workspace), ("charlie", "Charlie work", &workspace), ("foreign", "Another project", &other), ] { manager.save_session(&saved(id, title, ws)).expect("save"); } let all = manager.list_sessions().expect("list"); let mut picker = crate::tui::session_picker::SessionPickerView::new_with_sessions( &workspace, codewhale_localization::Locale::En, all.clone(), ); assert_eq!( picker.visible_session_ids(), api_ids(&all, &picker.view_query_for_test()), "picker default view must equal the API projection of the same query" ); assert!( !picker .visible_session_ids() .contains(&"foreign".to_string()), "workspace scope must exclude another project" ); // Every sort mode, including tie-breaks, must still agree. for _ in 0..3 { picker.cycle_sort_for_test(); assert_eq!( picker.visible_session_ids(), api_ids(&all, &picker.view_query_for_test()), "picker and API must agree after cycling sort" ); } picker.set_search_for_test("brav"); assert_eq!(picker.visible_session_ids(), vec!["bravo".to_string()]); assert_eq!( picker.visible_session_ids(), api_ids(&all, &picker.view_query_for_test()) ); picker.set_search_for_test(""); picker.toggle_all_workspaces(); assert_eq!( picker.visible_session_ids(), api_ids(&all, &picker.view_query_for_test()) ); assert!( picker .visible_session_ids() .contains(&"foreign".to_string()), "broadening scope must reach the other workspace" ); } /// The API's answer for a query, as ids. fn api_ids( sessions: &[codewhale_engine::session_manager::SessionMetadata], query: &SessionQuery, ) -> Vec { project_sessions(sessions, query, None) .into_iter() .map(|row| row.id) .collect() } #[test] fn a_nested_path_resolves_to_the_same_scope_as_its_repository_root() { // Plain path equality — what the picker used to do — would treat these // as different projects. The shared matcher walks to the git root, so a // linked worktree or a nested crate dir stays in scope. let fx = Fixture::new(); let repo = fx.dir.path().join("repo"); let nested = repo.join("crates").join("tui"); std::fs::create_dir_all(&nested).expect("nested"); std::fs::create_dir_all(repo.join(".git")).expect("git dir"); std::fs::write(repo.join(".git").join("HEAD"), "ref: refs/heads/main\n").expect("HEAD"); fx.save("root", "At the repo root", &repo); let all = fx.manager.list_sessions().expect("list"); let from_nested = project_sessions(&all, &SessionQuery::default().scoped_to(&nested), None); assert_eq!( from_nested .iter() .map(|r| r.id.as_str()) .collect::>(), vec!["root"], "a session saved at the repo root must be in scope from a nested path" ); } #[test] fn archive_survives_the_next_autosave() { // The autosave-survival gate. An autosave rebuilds metadata from // in-memory state; a stale copy would revert the archive. The writer // re-reads persisted lifecycle fields first. let fx = Fixture::new(); fx.save("live", "Being worked on", &fx.workspace); let mut stale = fx.manager.load_session("live").expect("load"); assert!(!stale.metadata.archived); fx.manager .set_session_archived("live", true, SessionMutator::Owner) .expect("archive"); let mut messages = stale.messages.clone(); messages.push(Message { role: Role::User, content: vec![ContentBlock::Text { text: "newest conversation content".to_string(), cache_control: None, }], }); stale = codewhale_engine::session_manager::update_session(stale, &messages, 99, None); fx.manager.save_session_bounded(stale).expect("autosave"); let autosaved = fx.manager.load_session_snapshot("live").expect("session"); assert!( autosaved.metadata.archived, "autosave must preserve a later archive" ); assert_eq!(autosaved.messages, messages); assert_eq!(autosaved.metadata.total_tokens, 99); } #[test] fn rename_survives_the_next_autosave() { let fx = Fixture::new(); fx.save("live", "Before", &fx.workspace); let stale = fx.manager.load_session("live").expect("load"); fx.manager .rename_session("live", "New Session", SessionMutator::Owner) .expect("rename"); fx.manager.save_session_bounded(stale).expect("autosave"); let autosaved = fx .manager .load_session_metadata_by_id("live") .expect("metadata"); assert_eq!(autosaved.title, "New Session"); assert!(autosaved.title_is_custom); } #[test] fn an_external_writer_is_refused_while_a_session_is_live() { // The archive-race gate. The TUI owns the in-memory copy, so an // out-of-band write must fail closed rather than be reverted later. let _lock = crate::test_support::lock_test_env(); let fx = Fixture::new(); fx.save("owned", "Open in the TUI", &fx.workspace); codewhale_engine::session_manager::set_live_session(Some("owned")); let refused = fx .manager .set_session_archived("owned", true, SessionMutator::External) .expect_err("external write must be refused while the session is live"); assert_eq!(refused.kind(), std::io::ErrorKind::ResourceBusy); assert!( !fx.manager .load_session("owned") .expect("reload") .metadata .archived, "a refused write must not have partially applied" ); let refused_rename = fx .manager .rename_session("owned", "Nope", SessionMutator::External) .expect_err("external rename must be refused too"); assert_eq!(refused_rename.kind(), std::io::ErrorKind::ResourceBusy); // The owner is still allowed. assert!( fx.manager .set_session_archived("owned", true, SessionMutator::Owner) .is_ok() ); // Releasing the claim re-opens external writes. codewhale_engine::session_manager::set_live_session(None); assert!( fx.manager .rename_session("owned", "Now allowed", SessionMutator::External) .is_ok() ); } #[test] fn auto_resume_skips_a_corrupt_newest_and_reports_how_many() { let fx = Fixture::new(); fx.save("older", "Older but readable", &fx.workspace); for id in ["broken-a", "broken-b"] { let mut session = saved(id, "Damaged", &fx.workspace); session.metadata.updated_at = chrono::Utc::now() + chrono::Duration::minutes(10); fx.manager.save_session(&session).expect("save"); let path = fx.manager.sessions_dir().join(format!("{id}.json")); let content = std::fs::read_to_string(&path).expect("read"); let truncated = content.trim_end().strip_suffix('}').expect("closing brace"); std::fs::write(&path, truncated).expect("truncate"); } let decision = decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager); assert_eq!( decision.session_id(), Some("older"), "one corrupt newest session must not cost the user every older one" ); assert!( matches!( decision, AutoResumeDecision::Resume { skipped_unreadable: 2, .. } ), "the receipt must count what was skipped, got {decision:?}" ); let receipt = decision.status_message().expect("receipt"); assert!( receipt.contains("skipped 2 unreadable"), "receipt must name the skipped count: {receipt}" ); } #[test] fn auto_resume_reports_when_every_candidate_is_unreadable() { let fx = Fixture::new(); for id in ["aa", "bb"] { fx.save(id, "Damaged", &fx.workspace); let path = fx.manager.sessions_dir().join(format!("{id}.json")); let content = std::fs::read_to_string(&path).expect("read"); let truncated = content.trim_end().strip_suffix('}').expect("closing brace"); std::fs::write(&path, truncated).expect("truncate"); } let decision = decide_auto_resume(true, &ResumeRequest::default(), &fx.workspace, &fx.manager); assert!(decision.starts_fresh()); assert!(matches!( decision, AutoResumeDecision::Unreadable { skipped_unreadable: 2, .. } )); } #[test] fn auto_resume_candidate_walk_is_bounded() { const { assert!( codewhale_engine::session_resume::MAX_AUTO_RESUME_CANDIDATES <= 16, "a damaged sessions directory must not turn startup into a long scan" ); } } #[test] fn session_and_thread_archive_filters_share_one_resolution() { use codewhale_engine::runtime_threads::manager::ThreadListFilter; for (include, only, expected_session, expected_thread) in [ ( None, None, SessionListFilter::ActiveOnly, ThreadListFilter::ActiveOnly, ), ( Some(true), None, SessionListFilter::IncludeArchived, ThreadListFilter::IncludeArchived, ), ( None, Some(true), SessionListFilter::ArchivedOnly, ThreadListFilter::ArchivedOnly, ), ( Some(true), Some(true), SessionListFilter::ArchivedOnly, ThreadListFilter::ArchivedOnly, ), ] { assert_eq!( SessionListFilter::from_query(include, only), expected_session ); // Thread-side expectation is spelled out so a future change to // either resolver breaks this test rather than drifting quietly. let thread = if only.unwrap_or(false) { ThreadListFilter::ArchivedOnly } else if include.unwrap_or(false) { ThreadListFilter::IncludeArchived } else { ThreadListFilter::ActiveOnly }; assert_eq!(thread, expected_thread); } } #[test] fn browsing_and_resume_are_offline() { // Structural, not behavioural: the browse/resume path must not name a // provider or HTTP client. A test that "made no network call" during // one run would prove nothing about the next one. for (name, source) in [ ( "session_projection", include_str!("../../engine/src/session_projection.rs"), ), ( "session_resume", include_str!("../../engine/src/session_resume.rs"), ), ] { for forbidden in [ "reqwest", "llm_client", "ProviderKind", "http://", "https://", ] { assert!( !source.contains(forbidden), "{name} must stay offline but references `{forbidden}`" ); } } } #[test] fn history_and_search_results_are_bounded() { let fx = Fixture::new(); for i in 0..30 { fx.save(&format!("s{i:02}"), &format!("Session {i}"), &fx.workspace); } let all = fx.manager.list_sessions().expect("list"); let capped = project_sessions( &all, &SessionQuery::default() .scoped_to(&fx.workspace) .with_limit(5), None, ); assert_eq!(capped.len(), 5); // Even an unbounded request is clamped by the projection cap. let unbounded = project_sessions( &all, &SessionQuery::default() .scoped_to(&fx.workspace) .with_limit(usize::MAX), None, ); assert!(unbounded.len() <= codewhale_engine::session_projection::MAX_PROJECTED_SESSIONS); // Row text is bounded too, so one pathological title cannot blow up a // row or a response. let long = "x".repeat(5_000); fx.save("long", &long, &fx.workspace); let rows = project_sessions( &fx.manager.list_sessions().expect("list"), &SessionQuery::default().scoped_to(&fx.workspace), None, ); let long_row = rows.iter().find(|r| r.id == "long").expect("long row"); assert!(long_row.title.chars().count() <= 140); assert!(long_row.preview.chars().count() <= 140); } #[test] fn projections_never_fabricate_live_state() { let fx = Fixture::new(); fx.save("recorded", "Recorded work", &fx.workspace); let all = fx.manager.list_sessions().expect("list"); // No caller-supplied active id: nothing may claim to be current. let anonymous = project_sessions( &all, &SessionQuery::default().scoped_to(&fx.workspace), None, ); assert!( anonymous.iter().all(|row| !row.is_current), "current-ness comes from the caller, never inferred from disk" ); // Counts and timestamps are the recorded ones, not derived guesses. let row = &anonymous[0]; let metadata = all.iter().find(|m| m.id == row.id).expect("metadata"); assert_eq!(row.message_count, metadata.message_count); assert_eq!(row.updated_at, metadata.updated_at); assert_eq!(row.total_tokens, metadata.total_tokens); assert_eq!(row.archived, metadata.archived); } }