Ctrl K
ComunidadGuías…

crates/tui/src/session_control_acceptance.rs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
//! 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<_>>(),
            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<_>>(),
            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<_>>(),
            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::<Vec<_>>()
        };
        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<String> {
        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<_>>(),
            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);
    }
}