diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index 137ad27b28..891bed28ac 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -940,3 +940,171 @@ pub trait CommandSkillGroupContext { /// `/restore` trust gate posture (yolo / trust_mode). fn approval_state(&self) -> CommandApprovalState; } + +// --------------------------------------------------------------------------- +// Session lifecycle capability (FEAT-023). +// +// One contract-owned facet for the seven host-dependent lifecycle commands; +// `/compact` and `/purge` stay pure. The shared `CommandSessionContext` above +// stays unchanged: it +// serves commands outside this slice and must not gain persistence, +// navigation, picker, or lifecycle mutation authority (D2). No concrete App, +// SessionManager, session-journal, picker, configuration, or view-stack type +// crosses this boundary; successful results are structured portable fields so +// the handlers retain exact message composition (D2/D5). +// --------------------------------------------------------------------------- + +/// Portable synchronization fields a lifecycle handler maps into the +/// temporary `SyncSession` action payload. The conversation and prompt types +/// are `codewhale-core` request types shared by the contract and the TUI +/// (FEAT-037 will move shared outcome ownership; FEAT-023 keeps the bounded +/// reference only for `/fork` and `/new` transitions, D6). +#[derive(Clone, Debug, PartialEq)] +pub struct SessionSyncPayload { + pub session_id: Option, + pub messages: Vec, + pub system_prompt: Option, + pub model: String, + pub workspace: PathBuf, + pub mode: CommandMode, +} + +/// `/branch` success projection (`session/branch.rs`). The handler composes +/// the exact success line from these deterministic fields. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionBranchOutcome { + pub leaf_display: String, + pub journal_entries_before: usize, +} + +/// `/fork` success projection for an active-conversation fork. The handler +/// composes `Forked session {parent} -> {fork}` from these required fields. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionForkReceipt { + pub parent_label: String, + pub fork_label: String, + pub sync: SessionSyncPayload, +} + +/// `/fork ` success projection. Explicit-source forks +/// always report their spawn depth, so the contract makes that field required +/// rather than permitting an invalid missing-depth state. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionForkFromReceipt { + pub parent_label: String, + pub fork_label: String, + pub spawn_depth: u64, + pub sync: SessionSyncPayload, +} + +/// `/save` success projection. The host performs the full baseline sequence +/// (snapshot, serialization, atomic write, metadata application, work-state +/// publication); the handler renders `Session saved to {display_path} (ID: +/// {truncated_id})`. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionSaveReceipt { + pub display_path: String, + pub truncated_id: String, +} + +/// `/new` success projection. The handler renders +/// `Started new session {truncated_id} (New Session). Previous sessions +/// remain available via /resume.` +#[derive(Clone, Debug, PartialEq)] +pub struct SessionNewReceipt { + pub truncated_id: String, + pub sync: SessionSyncPayload, +} + +/// `/sessions archive|unarchive|restore` success projection. The handler +/// renders `Archived session {id} ({title})` or `Restored session ...` from +/// the verb it dispatched. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionArchiveReceipt { + pub truncated_id: String, + pub title: String, +} + +/// `/tree` body projection. The body rendering source (journal tree and +/// linear transcript) stays TUI-owned; the handler appends the exact +/// guidance lines (D5). +#[derive(Clone, Debug, PartialEq)] +pub enum TreeBodyProjection { + /// Journal render already includes the trailing newline before guidance. + Journal { + rendered: String, + }, + /// Linear pre-journal render (the marker lines). + Linear { + rendered: String, + }, + EmptySession, + NoSession, +} + +/// Lifecycle authority for the session command slice (FEAT-023 D2). +/// +/// Operation-granular synchronous delegates over the exact minimum host work +/// the nine commands consume. Delegates may return the explicit host-error +/// text the baseline surfaces for a failing stage; successful results are +/// structured portable fields so handlers retain byte-identical composition. +pub trait CommandSessionLifecycleContext { + /// Live transition gate. Handlers return their own blocked-error text + /// before invoking any mutating delegate, matching the baseline ordering + /// (`/branch`, `/fork`, `/load`, `/new`). `/fork picker` and `/tree` + /// never consult it in the baseline, so their paths must not either. + fn transition_blocked(&self) -> bool; + + /// `/branch` with no argument: the current leaf when an active journaled + /// session resolves, otherwise `None` (the baseline silently falls back + /// to the usage message on this path). + fn branch_current_leaf_hint(&self) -> Option; + + /// `/branch `: persist the leaf move and apply the branched + /// transcript. Errors are the exact baseline message for the failing + /// stage (no active session, directory open, load, persist, or branch + /// failure). + fn branch_to(&mut self, entry_id: &str) -> Result; + + /// `/tree`: produce the journal/linear/empty/no-session projection. + /// Errors are the exact baseline directory-open message. + fn tree_body(&self) -> Result; + + /// `/save [path]`: the full baseline persistence sequence. + fn save_session(&mut self, explicit_path: Option) + -> Result; + + /// `/fork` (active conversation): the full baseline parent/child save and + /// switch sequence. + fn fork_active(&mut self) -> Result; + + /// `/fork `: explicit-source fork. + fn fork_from(&mut self, session_id_or_prefix: &str) -> Result; + + /// `/new [--force]`: fresh-session transition. The caller has already + /// parsed the argument and applied the transition-blocked gate; blocker, + /// busy-work-state, and success handling match the baseline. + fn fresh_session(&mut self, force: bool) -> Result; + + /// `/load `: resolve the path (separator-bearing direct vs + /// workspace-relative) and validate the saved-session shape without + /// applying state or emitting a premature success receipt. + fn load_session(&mut self, path: &str) -> Result; + + /// `/sessions` picker open with optional preselection (bare, `show`, + /// `list`, `picker`, and `open ` forms). Picker construction and + /// locale selection stay host-side. + fn open_picker(&mut self, preselected: Option); + + /// `/sessions archive|unarchive|restore `: durable lifecycle state + /// update that also syncs the live cached metadata atomically. + fn set_archived( + &mut self, + session_id: &str, + archived: bool, + ) -> Result; + + /// `/sessions prune `: prune persisted sessions older than `days` + /// days while protecting the active session; returns the number pruned. + fn prune_sessions(&mut self, days: u64) -> Result; +} diff --git a/crates/command-contract/src/handler.rs b/crates/command-contract/src/handler.rs index 8c3c5aad3d..624c0c6aa9 100644 --- a/crates/command-contract/src/handler.rs +++ b/crates/command-contract/src/handler.rs @@ -7,8 +7,8 @@ use crate::facets::{ CommandCostContext, CommandMediaContext, CommandMemoryContext, CommandModePolicyContext, CommandModelContext, CommandPluginContext, CommandPresentationContext, CommandProjectContext, - CommandSessionContext, CommandSkillGroupContext, CommandSkillsContext, - CommandSystemPromptContext, CommandWorkspaceContext, + CommandSessionContext, CommandSessionLifecycleContext, CommandSkillGroupContext, + CommandSkillsContext, CommandSystemPromptContext, CommandWorkspaceContext, }; /// Exact host capabilities exposed to one contextual command handler. @@ -38,6 +38,11 @@ impl CommandCapabilities { pub const SKILL_GROUP: Self = Self(1 << 11); /// Plugin-group host data (FEAT-020 D1), appended after current main capabilities. pub const PLUGIN: Self = Self(1 << 12); + /// Session-lifecycle host data (FEAT-023 D3), the next non-conflicting bit + /// after `PLUGIN`. Required only by the seven host-dependent lifecycle + /// commands; `/compact` and `/purge` remain pure. Never widened by the + /// basic session capability. + pub const SESSION_LIFECYCLE: Self = Self(1 << 13); pub const fn union(self, other: Self) -> Self { Self(self.0 | other.0) @@ -85,6 +90,7 @@ pub struct CommandContexts<'a> { project: Option<&'a mut dyn CommandProjectContext>, skill_group: Option<&'a mut dyn CommandSkillGroupContext>, plugin: Option<&'a mut dyn CommandPluginContext>, + lifecycle: Option<&'a mut dyn CommandSessionLifecycleContext>, } /// Consumed envelope used when one handler needs several independent facets. @@ -102,6 +108,7 @@ pub struct ContextParts<'a> { pub project: Option<&'a mut dyn CommandProjectContext>, pub skill_group: Option<&'a mut dyn CommandSkillGroupContext>, pub plugin: Option<&'a mut dyn CommandPluginContext>, + pub lifecycle: Option<&'a mut dyn CommandSessionLifecycleContext>, } impl<'a> CommandContexts<'a> { @@ -120,6 +127,7 @@ impl<'a> CommandContexts<'a> { project: None, skill_group: None, plugin: None, + lifecycle: None, } } @@ -138,6 +146,7 @@ impl<'a> CommandContexts<'a> { project: self.project, skill_group: self.skill_group, plugin: self.plugin, + lifecycle: self.lifecycle, } } @@ -241,6 +250,14 @@ impl<'a> CommandContexts<'a> { ); self } + + pub fn with_lifecycle(mut self, value: &'a mut dyn CommandSessionLifecycleContext) -> Self { + assert!( + self.lifecycle.replace(value).is_none(), + "lifecycle facet already set" + ); + self + } } impl Default for CommandContexts<'_> { diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index 76a7142338..17566692a8 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -1,6 +1,7 @@ use std::path::{Path, PathBuf}; -use codewhale_core::request::{Message, SystemPrompt}; +use codewhale_core::request::{ContentBlock, Message, SystemPrompt}; +use codewhale_core::role::Role; use crate::*; @@ -1790,3 +1791,314 @@ fn shared_skills_facet_surface_remains_read_only_and_transportable() { assert!(parts.skills.is_some()); assert!(parts.skill_group.is_some()); } + +// --------------------------------------------------------------------------- +// FEAT-023: session lifecycle contract (D2/D3/D6). +// --------------------------------------------------------------------------- + +#[test] +fn lifecycle_capability_is_stable_distinct_and_non_conflicting() { + let lifecycle = CommandCapabilities::SESSION_LIFECYCLE; + for existing in [ + CommandCapabilities::NONE, + CommandCapabilities::SESSION, + CommandCapabilities::MODEL, + CommandCapabilities::COST, + CommandCapabilities::MODE_POLICY, + CommandCapabilities::SYSTEM_PROMPT, + CommandCapabilities::SKILLS, + CommandCapabilities::WORKSPACE, + CommandCapabilities::PRESENTATION, + CommandCapabilities::MEDIA, + CommandCapabilities::MEMORY, + CommandCapabilities::PROJECT, + CommandCapabilities::SKILL_GROUP, + CommandCapabilities::PLUGIN, + ] { + assert_ne!(lifecycle, existing, "SESSION_LIFECYCLE must not collide"); + } + assert!(!CommandCapabilities::NONE.contains(lifecycle)); + assert!(lifecycle.contains(lifecycle)); + assert!( + lifecycle + .union(CommandCapabilities::SESSION) + .contains(lifecycle) + ); + assert!( + lifecycle + .union(CommandCapabilities::SESSION) + .contains(CommandCapabilities::SESSION) + ); +} + +/// Deterministic fake lifecycle facet: every delegate returns canned portable +/// values or error text so the contract transport is exercised exactly. +#[derive(Default)] +struct FakeLifecycle { + blocked: bool, + leaf_hint: Option, + branch_outcome: Option, + branch_error: Option, + tree: Option>, + save: Option>, + fork_active: Option>, + fork_from: Option>, + fresh: Option>, + load: Option>, + picker: Option, + archived: Option>, + prune: Option>, +} + +impl CommandSessionLifecycleContext for FakeLifecycle { + fn transition_blocked(&self) -> bool { + self.blocked + } + fn branch_current_leaf_hint(&self) -> Option { + self.leaf_hint.clone() + } + fn branch_to(&mut self, entry_id: &str) -> Result { + if let Some(err) = &self.branch_error { + return Err(err.clone()); + } + self.branch_outcome + .clone() + .ok_or_else(|| format!("unexpected branch_to({entry_id}) on empty fake")) + } + fn tree_body(&self) -> Result { + self.tree + .clone() + .unwrap_or(Ok(TreeBodyProjection::NoSession)) + } + fn save_session( + &mut self, + explicit_path: Option, + ) -> Result { + self.save + .clone() + .ok_or_else(|| format!("unexpected save_session({explicit_path:?}) on empty fake"))? + } + fn fork_active(&mut self) -> Result { + self.fork_active + .clone() + .ok_or_else(|| "unexpected fork_active() on empty fake".to_string())? + } + fn fork_from(&mut self, id: &str) -> Result { + self.fork_from + .clone() + .ok_or_else(|| format!("unexpected fork_from({id}) on empty fake"))? + } + fn fresh_session(&mut self, force: bool) -> Result { + self.fresh + .clone() + .ok_or_else(|| format!("unexpected fresh_session({force}) on empty fake"))? + } + fn load_session(&mut self, path: &str) -> Result { + self.load + .clone() + .ok_or_else(|| format!("unexpected load_session({path}) on empty fake"))? + } + fn open_picker(&mut self, preselected: Option) { + self.picker = preselected; + } + fn set_archived( + &mut self, + session_id: &str, + archived: bool, + ) -> Result { + self.archived.clone().ok_or_else(|| { + format!("unexpected set_archived({session_id}, {archived}) on empty fake") + })? + } + fn prune_sessions(&mut self, days: u64) -> Result { + self.prune + .clone() + .ok_or_else(|| format!("unexpected prune_sessions({days}) on empty fake"))? + } +} + +fn lifecycle_sync_payload(session_id: Option<&str>) -> SessionSyncPayload { + SessionSyncPayload { + session_id: session_id.map(str::to_string), + messages: vec![Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "hello lifecycle".to_string(), + cache_control: None, + }], + }], + system_prompt: Some(SystemPrompt::Text("prompt".to_string())), + model: "lifecycle-model".to_string(), + workspace: PathBuf::from("/workspace/lifecycle"), + mode: CommandMode::Plan, + } +} + +#[test] +fn lifecycle_facet_is_object_safe_and_transports_every_outcome() { + // Object safety: usable behind a single `dyn` reference. + fn accepts_dyn(_: &dyn CommandSessionLifecycleContext) {} + fn accepts_dyn_mut(_: &mut dyn CommandSessionLifecycleContext) {} + + let mut fake = FakeLifecycle { + blocked: true, + leaf_hint: Some("entry-42".to_string()), + branch_outcome: Some(SessionBranchOutcome { + leaf_display: "entry-43".to_string(), + journal_entries_before: 7, + }), + tree: Some(Ok(TreeBodyProjection::Journal { + rendered: "rendered journal".to_string(), + })), + save: Some(Ok(SessionSaveReceipt { + display_path: "/tmp/session.json".to_string(), + truncated_id: "abc123".to_string(), + })), + fork_active: Some(Ok(SessionForkReceipt { + parent_label: "parent".to_string(), + fork_label: "child".to_string(), + sync: lifecycle_sync_payload(Some("child")), + })), + fork_from: Some(Ok(SessionForkFromReceipt { + parent_label: "source".to_string(), + fork_label: "sibling".to_string(), + spawn_depth: 3, + sync: lifecycle_sync_payload(Some("sibling")), + })), + fresh: Some(Ok(SessionNewReceipt { + truncated_id: "new-id".to_string(), + sync: lifecycle_sync_payload(Some("new-id")), + })), + load: Some(Ok(PathBuf::from("/tmp/loaded.json"))), + archived: Some(Ok(SessionArchiveReceipt { + truncated_id: "arch-1".to_string(), + title: "Archive Title".to_string(), + })), + prune: Some(Ok(3)), + ..FakeLifecycle::default() + }; + accepts_dyn(&fake); + accepts_dyn_mut(&mut fake); + + assert!(fake.transition_blocked()); + assert_eq!(fake.branch_current_leaf_hint().as_deref(), Some("entry-42")); + let branch = fake.branch_to("entry-43").expect("branch ok"); + assert_eq!(branch.leaf_display, "entry-43"); + assert_eq!(branch.journal_entries_before, 7); + match fake.tree_body().expect("tree ok") { + TreeBodyProjection::Journal { rendered } => assert_eq!(rendered, "rendered journal"), + other => panic!("expected Journal projection, got {other:?}"), + } + let save = fake + .save_session(Some("/tmp/session.json".to_string())) + .expect("save ok"); + assert_eq!(save.display_path, "/tmp/session.json"); + assert_eq!(save.truncated_id, "abc123"); + let active = fake.fork_active().expect("active fork ok"); + assert_eq!(active.parent_label, "parent"); + assert_eq!(active.fork_label, "child"); + assert_eq!(active.sync.session_id.as_deref(), Some("child")); + assert_eq!(active.sync.messages.len(), 1); + assert_eq!(active.sync.mode, CommandMode::Plan); + let explicit = fake.fork_from("source").expect("explicit fork ok"); + assert_eq!(explicit.spawn_depth, 3); + assert_eq!( + explicit.sync.workspace, + PathBuf::from("/workspace/lifecycle") + ); + let fresh = fake.fresh_session(true).expect("fresh ok"); + assert_eq!(fresh.truncated_id, "new-id"); + assert_eq!(fresh.sync.messages.len(), 1); + let loaded = fake.load_session("loaded.json").expect("load ok"); + assert_eq!(loaded, PathBuf::from("/tmp/loaded.json")); + fake.open_picker(Some("arch-1".to_string())); + assert_eq!(fake.picker.as_deref(), Some("arch-1")); + let archived = fake.set_archived("arch-1", true).expect("archive ok"); + assert_eq!(archived.truncated_id, "arch-1"); + assert_eq!(archived.title, "Archive Title"); + assert_eq!(fake.prune_sessions(30).expect("prune ok"), 3); +} + +#[test] +fn lifecycle_error_text_and_empty_states_transport_exactly() { + let mut fake = FakeLifecycle { + branch_error: Some("could not load session x: boom".to_string()), + tree: Some(Err("could not open sessions directory: boom".to_string())), + save: Some(Err("Failed to save session: boom".to_string())), + load: Some(Err("Failed to read session file: boom".to_string())), + archived: Some(Err("archive failed: boom".to_string())), + prune: Some(Err("prune failed: boom".to_string())), + ..FakeLifecycle::default() + }; + assert_eq!( + fake.branch_to("x").unwrap_err(), + "could not load session x: boom" + ); + assert_eq!( + fake.tree_body().unwrap_err(), + "could not open sessions directory: boom" + ); + assert_eq!( + fake.save_session(None).unwrap_err(), + "Failed to save session: boom" + ); + assert_eq!( + fake.load_session("missing.json").unwrap_err(), + "Failed to read session file: boom" + ); + assert_eq!( + fake.set_archived("a", false).unwrap_err(), + "archive failed: boom" + ); + assert_eq!(fake.prune_sessions(7).unwrap_err(), "prune failed: boom"); + + let mut empty = FakeLifecycle::default(); + assert!(!empty.transition_blocked()); + assert_eq!(empty.branch_current_leaf_hint(), None); + assert!(matches!( + empty.tree_body().expect("default tree"), + TreeBodyProjection::NoSession + )); + empty.open_picker(None); + assert_eq!(empty.picker, None); +} + +#[test] +fn envelope_lifecycle_slot_is_independent_and_rejects_duplicates() { + let mut first = FakeLifecycle::default(); + let mut second = FakeLifecycle::default(); + + let parts = CommandContexts::empty() + .with_lifecycle(&mut first) + .into_parts(); + assert!( + parts.lifecycle.is_some(), + "lifecycle slot must be present when declared" + ); + assert!( + parts.session.is_none() && parts.plugin.is_none() && parts.skill_group.is_none(), + "unrelated slots must stay absent (exact exposure)" + ); + + let bare = CommandContexts::empty().into_parts(); + assert!( + bare.lifecycle.is_none(), + "undeclared lifecycle stays absent" + ); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + CommandContexts::empty() + .with_lifecycle(&mut first) + .with_lifecycle(&mut second); + })); + assert!( + result.is_err(), + "duplicate lifecycle slot must assert deterministically" + ); + + // Reading through the dyn facet works after insertion. + first.blocked = true; + let inserted = CommandContexts::empty().with_lifecycle(&mut first); + let lifecycle = inserted.into_parts().lifecycle.expect("inserted lifecycle"); + assert!(lifecycle.transition_blocked()); +} diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index eca6f693f8..465e772fca 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -35,19 +35,21 @@ use codewhale_command_contract::facets::{ CommandApprovalState, CommandCostContext, CommandMediaContext, CommandMemoryContext, CommandModePolicyContext, CommandModelContext, CommandPluginContext, CommandPresentationContext, CommandProjectContext, CommandSessionContext, - CommandSkillGroupContext, CommandSkillsContext, CommandSystemPromptContext, - CommandWorkspaceContext, MediaAttachmentReceipt, MemoryDelete, MemoryDeleteScope, MemoryExport, - MemoryGetOutcome, MemoryHit, MemoryImportOutcome, MemoryReindex, MemoryRememberTarget, - MemoryRemembered, MemoryStatus, PluginDetail, PluginDiagnostic, PluginDiagnosticLevel, - PluginExportReceipt, PluginLegacyScan, PluginLegacyTool, PluginManagedCandidate, - PluginManagedScan, PluginMarketplaceAddReceipt, PluginMarketplaceCandidate, - PluginMarketplaceCatalog, PluginMarketplaceInstallPlan, PluginMarketplaceState, - PluginMcpServerDetail, PluginMcpTransport, PluginMutationOutcome, PluginMutationReceipt, - PluginSuggestion, PluginSummary, ProjectGoalState, ProjectGoalStatus, ProjectShareProjection, - RemoteRegistryOutcome, RemoteSkillEntry, ReviewOutcome, SkillActivationError, + CommandSessionLifecycleContext, CommandSkillGroupContext, CommandSkillsContext, + CommandSystemPromptContext, CommandWorkspaceContext, MediaAttachmentReceipt, MemoryDelete, + MemoryDeleteScope, MemoryExport, MemoryGetOutcome, MemoryHit, MemoryImportOutcome, + MemoryReindex, MemoryRememberTarget, MemoryRemembered, MemoryStatus, PluginDetail, + PluginDiagnostic, PluginDiagnosticLevel, PluginExportReceipt, PluginLegacyScan, + PluginLegacyTool, PluginManagedCandidate, PluginManagedScan, PluginMarketplaceAddReceipt, + PluginMarketplaceCandidate, PluginMarketplaceCatalog, PluginMarketplaceInstallPlan, + PluginMarketplaceState, PluginMcpServerDetail, PluginMcpTransport, PluginMutationOutcome, + PluginMutationReceipt, PluginSuggestion, PluginSummary, ProjectGoalState, ProjectGoalStatus, + ProjectShareProjection, RemoteRegistryOutcome, RemoteSkillEntry, ReviewOutcome, + SessionArchiveReceipt, SessionBranchOutcome, SessionForkFromReceipt, SessionForkReceipt, + SessionNewReceipt, SessionSaveReceipt, SessionSyncPayload, SkillActivationError, SkillActivationOutcome, SkillBundledTier, SkillEntry, SkillMutationOutcome, SkillMutationReceipt, SkillRecommendation, SkillRegistryProjection, SkillSourceKind, - SkillSyncEntry, SkillSyncOutcome, SkillTargetScope, SnapshotEntry, + SkillSyncEntry, SkillSyncOutcome, SkillTargetScope, SnapshotEntry, TreeBodyProjection, }; #[cfg(test)] use codewhale_command_contract::handler::ContextParts; @@ -95,7 +97,7 @@ pub(crate) fn to_command_mode(mode: AppMode) -> CommandMode { } } -fn from_command_mode(mode: CommandMode) -> AppMode { +pub(crate) fn from_command_mode(mode: CommandMode) -> AppMode { match mode { CommandMode::Agent => AppMode::Agent, CommandMode::Plan => AppMode::Plan, @@ -278,6 +280,550 @@ struct CommandHost<'a> { type SharedCommandHost<'a> = Rc>; +// --------------------------------------------------------------------------- +// Session lifecycle adapter (FEAT-023 D4) +// +// Sole host owner of concrete lifecycle machinery for the nine lifecycle +// commands: App reads/mutations, SessionManager, saved-session creation, +// journal load/branching, filesystem persistence, work-state snapshots/ +// publication, picker/view-stack construction, archive/prune, and the core +// `reset_conversation_state` call for `/new`. Every delegate reproduces the +// baseline check/mutation order exactly (blocked transitions fail before I/O, +// branching never rewrites journal history, publication failures retain their +// post-save semantics, archive state updates atomically) and returns portable +// receipts or the exact host-error text the baseline surfaces. The lifecycle +// bodies no longer live in `groups/session/session.rs`; adapter regressions and +// portable-handler tests preserve their host and presentation contracts. +// --------------------------------------------------------------------------- +pub(crate) struct SessionLifecycleAdapter<'a> { + host: SharedCommandHost<'a>, +} + +impl CommandSessionLifecycleContext for SessionLifecycleAdapter<'_> { + fn transition_blocked(&self) -> bool { + self.host.app.borrow().session_transition_blocked() + } + + fn branch_current_leaf_hint(&self) -> Option { + let app = self.host.app.borrow(); + let session_id = app.current_session_id.as_deref()?; + let manager = crate::session_manager::SessionManager::default_location().ok()?; + let mut session = manager.load_session(session_id).ok()?; + session.ensure_journal(); + session.journal.as_ref()?.leaf_id.clone() + } + + fn branch_to(&mut self, entry_id: &str) -> Result { + let mut app = self.host.app.borrow_mut(); + let session_id = match app.current_session_id.clone() { + Some(id) => id, + None => { + return Err( + "No active session to branch. Resume or create a session first.".to_string(), + ); + } + }; + let manager = match crate::session_manager::SessionManager::default_location() { + Ok(m) => m, + Err(e) => return Err(format!("could not open sessions directory: {e}")), + }; + let mut session = match manager.load_session(&session_id) { + Ok(s) => s, + Err(e) => return Err(format!("could not load session {session_id}: {e}")), + }; + session.ensure_journal(); + let journal_len_before = session + .journal + .as_ref() + .map(|j| j.entries.len()) + .unwrap_or(0); + match session.journal_branch_to(entry_id) { + Ok(()) => { + if let Err(e) = manager.save_session(&session) { + return Err(format!("branch saved but persist failed: {e}")); + } + app.api_messages = session.messages.clone(); + let leaf_display = session + .leaf_id + .clone() + .unwrap_or_else(|| "(none)".to_string()); + Ok(SessionBranchOutcome { + leaf_display, + journal_entries_before: journal_len_before, + }) + } + Err(e) => Err(format!( + "branch failed: {e}. Use `/tree` to see valid entry ids." + )), + } + } + + fn tree_body(&self) -> Result { + let app = self.host.app.borrow(); + let manager = match crate::session_manager::SessionManager::default_location() { + Ok(m) => m, + Err(e) => return Err(format!("could not open sessions directory: {e}")), + }; + if let Some(session_id) = app.current_session_id.clone() { + if let Ok(mut session) = manager.load_session(&session_id) { + session.ensure_journal(); + if let Some(journal) = session.journal.as_ref() { + let rendered = crate::session_tree::render_tree(journal); + return Ok(TreeBodyProjection::Journal { rendered }); + } + } + if app.api_messages.is_empty() { + return Ok(TreeBodyProjection::EmptySession); + } + let mut rendered = + String::from("Active branch (linear — journal will be created on save):\n"); + for (i, msg) in app.api_messages.iter().enumerate() { + let snippet: String = msg + .content + .iter() + .filter_map(|b| match b { + crate::models::ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect::>() + .join(" "); + let short: String = snippet.chars().take(60).collect(); + let marker = if i + 1 == app.api_messages.len() { + "*" + } else { + "●" + }; + rendered.push_str(&format!(" {marker} [{i}] {}: {short}\n", msg.role)); + } + Ok(TreeBodyProjection::Linear { rendered }) + } else { + Ok(TreeBodyProjection::NoSession) + } + } + + fn save_session( + &mut self, + explicit_path: Option, + ) -> Result { + let mut app = self.host.app.borrow_mut(); + let explicit_save_path = explicit_path.map(PathBuf::from); + + let messages = app.api_messages.clone(); + let mut session = crate::session_manager::create_saved_session_with_mode( + &messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.label()), + ); + session + .metadata + .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence()); + app.sync_cost_to_metadata(&mut session.metadata); + session.context_references = app.session_context_references.clone(); + session.artifacts = app.session_artifacts.clone(); + session.work_state = match app.work_state_snapshot() { + Ok(state) => state, + Err(err) => return Err(format!("Failed to snapshot Work state: {err}")), + }; + session.last_auto_route = app.auto_route_for_persistence(); + let save_path = explicit_save_path.unwrap_or_else(|| { + let dir = crate::session_manager::default_sessions_dir() + .unwrap_or_else(|_| app.workspace.clone()); + dir.join(format!("{}.json", session.metadata.id)) + }); + + let sessions_dir = save_path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map_or_else(|| app.workspace.clone(), std::path::Path::to_path_buf); + + match std::fs::create_dir_all(&sessions_dir) { + Ok(()) => { + let json = match serde_json::to_string_pretty(&session) { + Ok(j) => j, + Err(e) => return Err(format!("Failed to serialize session: {e}")), + }; + match crate::utils::write_atomic(&save_path, json.as_bytes()) { + Ok(()) => { + app.current_session_id = Some(session.metadata.id.clone()); + app.current_session_metadata = Some(session.metadata.clone()); + app.session_title = Some(session.metadata.title.clone()); + if let Err(err) = app.publish_pending_work_state() { + return Err(format!( + "Session saved, but Work views were not published: {err}" + )); + } + Ok(SessionSaveReceipt { + display_path: save_path.display().to_string(), + truncated_id: crate::session_manager::truncate_id(&session.metadata.id) + .to_string(), + }) + } + Err(e) => Err(format!("Failed to save session: {e}")), + } + } + Err(e) => Err(format!("Failed to create directory: {e}")), + } + } + + fn fork_active(&mut self) -> Result { + let mut app = self.host.app.borrow_mut(); + if app.api_messages.is_empty() { + return Err("Nothing to fork. Send or load a message first.".to_string()); + } + + let manager = match crate::session_manager::SessionManager::default_location() { + Ok(manager) => manager, + Err(err) => { + return Err(format!("could not open sessions directory: {err}")); + } + }; + + let parent_id = app + .current_session_id + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut parent = crate::session_manager::create_saved_session_with_id_and_mode( + parent_id, + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.label()), + ); + parent + .metadata + .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence()); + if let Some(cached) = app + .current_session_metadata + .as_ref() + .filter(|metadata| metadata.id == parent.metadata.id) + { + parent.metadata.created_at = cached.created_at; + parent.metadata.title.clone_from(&cached.title); + parent + .metadata + .parent_session_id + .clone_from(&cached.parent_session_id); + parent.metadata.forked_from_message_count = cached.forked_from_message_count; + } + app.sync_cost_to_metadata(&mut parent.metadata); + parent.context_references = app.session_context_references.clone(); + parent.artifacts = app.session_artifacts.clone(); + let work_state = match app.work_state_snapshot() { + Ok(state) => state, + Err(err) => return Err(format!("Failed to snapshot Work state: {err}")), + }; + parent.work_state = work_state.clone(); + parent.last_auto_route = app.auto_route_for_persistence(); + + if let Err(err) = manager.save_session(&parent) { + return Err(format!("Failed to save parent session: {err}")); + } + + let mut forked = crate::session_manager::create_saved_session_with_mode( + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.label()), + ); + forked + .metadata + .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence()); + forked.metadata.copy_cost_from(&parent.metadata); + forked.metadata.spawn_depth = parent.metadata.spawn_depth.saturating_add(1); + // Ensure journal for both sessions: parent already has one from factory, bump forked's journal depth + if let Some(j) = forked.journal.as_mut() { + j.spawn_depth = forked.metadata.spawn_depth; + } + if let Some(j) = parent.journal.as_mut() { + j.spawn_depth = parent.metadata.spawn_depth; + } + forked.metadata.mark_forked_from(&parent.metadata); + forked.context_references = app.session_context_references.clone(); + forked.artifacts = app.session_artifacts.clone(); + forked.work_state = work_state; + forked.last_auto_route = app.auto_route_for_persistence(); + + if let Err(err) = manager.save_session(&forked) { + return Err(format!("Failed to save forked session: {err}")); + } + if let Err(err) = app.publish_pending_work_state() { + return Err(format!( + "Sessions saved, but Work views were not published: {err}" + )); + } + + app.current_session_id = Some(forked.metadata.id.clone()); + app.current_session_metadata = Some(forked.metadata.clone()); + app.session_title = Some(forked.metadata.title.clone()); + // A fork starts as its own session: no inherited tab/window title. + app.window_title = None; + let fork_id = forked.metadata.id.clone(); + let parent_label = crate::session_manager::truncate_id(&parent.metadata.id).to_string(); + let fork_label = crate::session_manager::truncate_id(&fork_id).to_string(); + let mode = to_command_mode(app.mode); + Ok(SessionForkReceipt { + parent_label, + fork_label, + sync: SessionSyncPayload { + session_id: Some(fork_id), + messages: app.api_messages.clone(), + system_prompt: app.system_prompt.clone(), + model: app.model.clone(), + workspace: app.workspace.clone(), + mode, + }, + }) + } + + fn fork_from(&mut self, session_id_or_prefix: &str) -> Result { + let mut app = self.host.app.borrow_mut(); + let manager = match crate::session_manager::SessionManager::default_location() { + Ok(m) => m, + Err(err) => { + return Err(format!("could not open sessions directory: {err}")); + } + }; + let source = manager + .load_session(session_id_or_prefix) + .or_else(|_| manager.load_session_by_prefix(session_id_or_prefix)); + let mut source_session = match source { + Ok(s) => s, + Err(e) => { + return Err(format!( + "could not load session '{}': {e}", + session_id_or_prefix + )); + } + }; + source_session.ensure_journal(); + let journal = source_session.journal.clone().unwrap_or_else(|| { + crate::session_tree::SessionJournal::from_messages( + source_session.messages.clone(), + source_session.metadata.spawn_depth, + ) + }); + let forked_journal = journal.fork_from(None).unwrap_or_else(|_| { + crate::session_tree::SessionJournal::with_spawn_depth( + source_session.metadata.spawn_depth.saturating_add(1), + ) + }); + let messages = forked_journal.to_messages(); + let mut forked = crate::session_manager::create_saved_session_with_id_and_mode( + uuid::Uuid::new_v4().to_string(), + &messages, + &source_session.metadata.model, + &app.workspace, + source_session.metadata.total_tokens, + source_session + .system_prompt + .as_ref() + .map(|s| crate::models::SystemPrompt::Text(s.clone())) + .as_ref(), + source_session.metadata.mode.as_deref(), + ); + forked.journal = Some(forked_journal); + forked.leaf_id = forked.journal.as_ref().and_then(|j| j.leaf_id.clone()); + forked.messages = messages; + forked.metadata.spawn_depth = forked.journal.as_ref().map(|j| j.spawn_depth).unwrap_or(0); + forked.metadata.parent_session_id = Some(source_session.metadata.id.clone()); + forked.metadata.forked_from_message_count = Some(source_session.metadata.message_count); + forked.metadata.set_model_provider_route( + source_session.metadata.model_provider.as_str(), + source_session.metadata.model_provider_id.as_deref(), + ); + forked.metadata.copy_cost_from(&source_session.metadata); + forked.context_references = source_session.context_references.clone(); + forked.artifacts = source_session.artifacts.clone(); + forked.work_state = source_session.work_state.clone(); + forked.last_auto_route = source_session.last_auto_route.clone(); + if let Err(err) = manager.save_session(&forked) { + return Err(format!("Failed to save forked session: {err}")); + } + app.current_session_id = Some(forked.metadata.id.clone()); + app.current_session_metadata = Some(forked.metadata.clone()); + app.session_title = Some(forked.metadata.title.clone()); + // A fork starts as its own session: no inherited tab/window title. + app.window_title = None; + let parent_label = + crate::session_manager::truncate_id(&source_session.metadata.id).to_string(); + let fork_label = crate::session_manager::truncate_id(&forked.metadata.id).to_string(); + let mode = to_command_mode(app.mode); + Ok(SessionForkFromReceipt { + parent_label, + fork_label, + spawn_depth: forked.metadata.spawn_depth.into(), + sync: SessionSyncPayload { + session_id: Some(forked.metadata.id.clone()), + messages: forked.messages.clone(), + system_prompt: forked + .system_prompt + .as_ref() + .map(|s| crate::models::SystemPrompt::Text(s.clone())), + model: forked.metadata.model.clone(), + workspace: app.workspace.clone(), + mode, + }, + }) + } + + fn fresh_session(&mut self, force: bool) -> Result { + let mut app = self.host.app.borrow_mut(); + if !force { + let mut blockers: Vec<&'static str> = Vec::new(); + if !app.input.trim().is_empty() { + blockers.push("the composer has unsent text"); + } + if !app.queued_messages.is_empty() || app.queued_draft.is_some() { + blockers.push("queued messages are pending"); + } + if !blockers.is_empty() { + return Err(format!( + "Cannot start a new session while {}. Run `/new --force` to discard pending work and start a fresh session.", + blockers.join(", ") + )); + } + } + + let new_id = uuid::Uuid::new_v4().to_string(); + if !crate::commands::groups::core::reset_conversation_state(&mut app) { + return Err( + "Could not start a new session because Work state is busy; retry in a moment." + .to_string(), + ); + } + app.clear_input(); + app.session_artifacts.clear(); + app.session_context_references.clear(); + app.tool_evidence.clear(); + app.current_session_id = Some(new_id.clone()); + app.current_session_metadata = None; + app.session_title = Some(crate::session_manager::DEFAULT_SESSION_TITLE.to_string()); + // A new session has no tab/window title override yet; the `title` + // config default still applies. + app.window_title = None; + app.scroll_to_bottom(); + let mode = to_command_mode(app.mode); + Ok(SessionNewReceipt { + truncated_id: crate::session_manager::truncate_id(&new_id).to_string(), + sync: SessionSyncPayload { + session_id: Some(new_id), + messages: Vec::new(), + system_prompt: None, + model: app.model.clone(), + workspace: app.workspace.clone(), + mode, + }, + }) + } + + fn load_session(&mut self, path: &str) -> Result { + let app = self.host.app.borrow(); + let load_path = if path.contains('/') || path.contains('\\') { + PathBuf::from(path) + } else { + app.workspace.join(path) + }; + + let content = match std::fs::read_to_string(&load_path) { + Ok(c) => c, + Err(e) => { + return Err(format!("Failed to read session file: {e}")); + } + }; + + let _session: crate::session_manager::SavedSession = match serde_json::from_str(&content) { + Ok(s) => s, + Err(e) => { + return Err(format!("Failed to parse session file: {e}")); + } + }; + Ok(load_path) + } + + fn open_picker(&mut self, preselected: Option) { + let mut app = self.host.app.borrow_mut(); + // Materialize the picker inputs before mutating the view stack so the + // `RefCell` borrow of `App` is not simultaneously mutable and shared. + let workspace = app.workspace.clone(); + let ui_locale = app.ui_locale; + match preselected { + Some(session_id) => { + app.view_stack.push( + crate::tui::session_picker::SessionPickerView::new_selecting( + &workspace, + ui_locale, + &session_id, + ), + ); + } + None => { + app.view_stack + .push(crate::tui::session_picker::SessionPickerView::new( + &workspace, ui_locale, + )); + } + } + } + + fn set_archived( + &mut self, + session_id: &str, + archived: bool, + ) -> Result { + let verb = if archived { "archive" } else { "unarchive" }; + let mut app = self.host.app.borrow_mut(); + let manager = match crate::session_manager::SessionManager::default_location() { + Ok(manager) => manager, + Err(err) => { + return Err(format!("could not open sessions directory: {err}")); + } + }; + match manager.set_session_archived( + session_id, + archived, + crate::session_manager::SessionMutator::Owner, + ) { + Ok(metadata) => { + if let Some(cached) = app.current_session_metadata.as_mut() + && cached.id == metadata.id + { + cached.archived = metadata.archived; + } + Ok(SessionArchiveReceipt { + truncated_id: crate::session_manager::truncate_id(&metadata.id).to_string(), + title: metadata.title, + }) + } + Err(err) => Err(format!("{verb} failed: {err}")), + } + } + + fn prune_sessions(&mut self, days: u64) -> Result { + let app = self.host.app.borrow(); + let manager = match crate::session_manager::SessionManager::default_location() { + Ok(m) => m, + Err(err) => { + return Err(format!("could not open sessions directory: {err}")); + } + }; + + let max_age = std::time::Duration::from_secs(days.saturating_mul(24 * 60 * 60)); + // Never prune the active session, even if its timestamp is stale (a + // just-resumed session isn't re-saved until its first post-resume write). + let keep = app.current_session_id.as_deref(); + manager + .prune_sessions_older_than_keeping(max_age, keep) + .map_err(|err| format!("prune failed: {err}")) + } +} + /// Session identity, messages, queue operations, and token totals. pub(crate) struct SessionAdapter<'a> { host: SharedCommandHost<'a>, @@ -2708,7 +3254,7 @@ fn default_codewhale_tools_dir() -> Option { // Envelope construction (D1) // --------------------------------------------------------------------------- -/// Owns twelve facet objects sharing one synchronous TUI host proxy. +/// Owns thirteen facet objects sharing one synchronous TUI host proxy. /// /// Handlers borrow only these adapters. Every method delegates to the real App /// authority and releases its `RefCell` borrow before returning, so facets can @@ -2727,6 +3273,7 @@ pub(crate) struct CommandContextBundle<'a> { memory: MemoryAdapter<'a>, skill_group: SkillGroupAdapter<'a>, plugin: PluginAdapter<'a>, + lifecycle: SessionLifecycleAdapter<'a>, } impl<'a> CommandContextBundle<'a> { @@ -2772,6 +3319,9 @@ impl<'a> CommandContextBundle<'a> { if capabilities.contains(CommandCapabilities::PLUGIN) { contexts = contexts.with_plugin(&mut self.plugin); } + if capabilities.contains(CommandCapabilities::SESSION_LIFECYCLE) { + contexts = contexts.with_lifecycle(&mut self.lifecycle); + } contexts } @@ -2790,7 +3340,8 @@ impl<'a> CommandContextBundle<'a> { .union(CommandCapabilities::MEMORY) .union(CommandCapabilities::PROJECT) .union(CommandCapabilities::SKILL_GROUP) - .union(CommandCapabilities::PLUGIN); + .union(CommandCapabilities::PLUGIN) + .union(CommandCapabilities::SESSION_LIFECYCLE); self.contexts(all_test_capabilities).into_parts() } } @@ -2815,7 +3366,8 @@ impl App { project: ProjectAdapter { host: host.clone() }, memory: MemoryAdapter { host: host.clone() }, skill_group: SkillGroupAdapter { host: host.clone() }, - plugin: PluginAdapter { host }, + plugin: PluginAdapter { host: host.clone() }, + lifecycle: SessionLifecycleAdapter { host }, } } } @@ -3758,10 +4310,30 @@ mod tests { assert!(parts.presentation.is_none()); assert!(parts.media.is_none()); - // Unrelated capability: memory absent. + // Lifecycle-only: lifecycle present and every unrelated slot absent. + let parts = bundle + .contexts(CommandCapabilities::SESSION_LIFECYCLE) + .into_parts(); + assert!(parts.lifecycle.is_some()); + assert!(parts.session.is_none()); + assert!(parts.model.is_none()); + assert!(parts.cost.is_none()); + assert!(parts.mode_policy.is_none()); + assert!(parts.system_prompt.is_none()); + assert!(parts.skills.is_none()); + assert!(parts.workspace.is_none()); + assert!(parts.presentation.is_none()); + assert!(parts.media.is_none()); + assert!(parts.memory.is_none()); + assert!(parts.project.is_none()); + assert!(parts.skill_group.is_none()); + assert!(parts.plugin.is_none()); + + // Unrelated capability: memory and lifecycle both absent. let parts = bundle.contexts(CommandCapabilities::SESSION).into_parts(); assert!(parts.session.is_some()); assert!(parts.memory.is_none()); + assert!(parts.lifecycle.is_none()); } // ─── FEAT-022 skill-group adapter tests ─────────────────────────────────── @@ -4301,4 +4873,380 @@ mod tests { assert!(parts.session.is_some()); assert!(parts.plugin.is_none()); } + + // --------------------------------------------------------------------------- + // FEAT-023 Phase 3: SessionLifecycleAdapter tests (Tasks 3.2/3.4). + // Every delegate is exercised over the real App with an isolated CODEWHALE_HOME + // so SessionManager writes stay inside the temp directory. The bundle borrows + // `App` for its whole life, so each test scopes the facet and re-reads `App` + // only after dropping it (adapters borrow through the host `RefCell` at call + // time, but the bundle itself holds the `&mut App`). + // --------------------------------------------------------------------------- + + fn lifecycle_test_app(tmpdir: &TempDir) -> App { + let options = crate::test_support::test_tui_options(tmpdir.path()); + App::new(options, &crate::config::Config::default()) + } + + /// Point CODEWHALE_HOME at `tmp/home` with a pre-created sessions directory so + /// `SessionManager::default_location()` resolves inside the temp sandbox. + fn lifecycle_home_guard(tmpdir: &TempDir) -> crate::test_support::EnvVarGuard { + let home = tmpdir.path().join("home"); + let sessions = home.join("sessions"); + std::fs::create_dir_all(&sessions).expect("create sandbox sessions dir"); + crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home) + } + + fn user_message(text: &str) -> Message { + Message { + role: crate::models::Role::User, + content: vec![crate::models::ContentBlock::Text { + text: text.to_string(), + cache_control: None, + }], + } + } + + #[test] + fn lifecycle_dispatch_transition_blocking_wins_over_io() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let mut app = lifecycle_test_app(&tmpdir); + app.is_loading = true; + app.current_session_id = Some("active-session".to_string()); + app.api_messages.push(user_message("in flight")); + + for (command, expected) in [ + ("/fork", "Cannot fork a session"), + ("/fork other-session", "Cannot fork a session"), + ("/load does-not-exist.json", "Cannot load a session"), + ("/new", "Cannot start a new session"), + ("/branch entry-1", "Cannot branch"), + ] { + let result = crate::commands::execute(command, &mut app); + assert!(result.is_error, "{command}: {result:?}"); + assert!(result.action.is_none(), "{command}: {result:?}"); + assert!( + result + .message + .as_deref() + .is_some_and(|text| text.contains(expected)), + "{command}: {result:?}" + ); + assert_eq!(app.current_session_id.as_deref(), Some("active-session")); + assert_eq!(app.api_messages.len(), 1); + } + } + + #[test] + fn lifecycle_adapter_save_and_fork_roundtrip_preserves_history() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let _home = lifecycle_home_guard(&tmpdir); + let mut app = lifecycle_test_app(&tmpdir); + app.api_messages.push(user_message("try another path")); + + let save_path = tmpdir.path().join("parent.json"); + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + let saved = facet + .save_session(Some(save_path.display().to_string())) + .expect("save ok"); + assert!(save_path.exists()); + assert!(!saved.display_path.is_empty()); + assert!(!saved.truncated_id.is_empty()); + } + let parent_id = app + .current_session_id + .clone() + .expect("save sets session id"); + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + let forked = facet.fork_active().expect("fork ok"); + assert!(!forked.parent_label.is_empty()); + assert!(!forked.fork_label.is_empty()); + assert!(forked.sync.session_id.is_some()); + assert_eq!(forked.sync.messages.len(), 1); + assert_eq!(forked.sync.workspace, tmpdir.path()); + } + let child_id = app + .current_session_id + .clone() + .expect("fork switches session"); + assert_ne!(child_id, parent_id); + + let manager = crate::session_manager::SessionManager::default_location().unwrap(); + let parent = manager.load_session(&parent_id).expect("parent loadable"); + let child = manager.load_session(&child_id).expect("child loadable"); + assert_eq!(parent.messages.len(), 1, "parent history preserved"); + assert_eq!( + child.metadata.parent_session_id.as_deref(), + Some(parent_id.as_str()) + ); + assert_eq!(child.metadata.forked_from_message_count, Some(1)); + } + + #[test] + fn lifecycle_adapter_explicit_fork_reports_spawn_depth_and_preserves_source() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let _home = lifecycle_home_guard(&tmpdir); + let mut app = lifecycle_test_app(&tmpdir); + app.api_messages.push(user_message("parent turn")); + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + let saved = facet.save_session(None).expect("save into managed dir"); + assert!(!saved.truncated_id.is_empty()); + } + let parent_id = app + .current_session_id + .clone() + .expect("save sets session id"); + let source_len = { + let manager = crate::session_manager::SessionManager::default_location().unwrap(); + manager + .load_session(&parent_id) + .expect("saved parent") + .messages + .len() + }; + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + let forked = facet.fork_from(&parent_id).expect("explicit fork ok"); + assert_eq!(forked.spawn_depth, 1); + assert_eq!( + forked.parent_label, + crate::session_manager::truncate_id(&parent_id) + ); + assert_eq!(forked.sync.messages.len(), 1); + } + let manager = crate::session_manager::SessionManager::default_location().unwrap(); + let reloaded = manager + .load_session(&parent_id) + .expect("source still loadable"); + assert_eq!( + reloaded.messages.len(), + source_len, + "source history never rewritten by forking" + ); + } + + #[test] + fn lifecycle_adapter_new_session_is_all_or_nothing_when_work_state_is_busy() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let _home = lifecycle_home_guard(&tmpdir); + let mut app = lifecycle_test_app(&tmpdir); + app.current_session_id = Some("current-session".to_string()); + app.api_messages.push(user_message("work")); + let todos = app.todos.clone(); + let _held = todos.try_lock().expect("hold todos lock"); + + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + let err = facet.fresh_session(true).expect_err("busy work state"); + assert!(err.contains("Work state is busy"), "{err}"); + } + assert_eq!(app.api_messages.len(), 1); + assert_eq!(app.current_session_id.as_deref(), Some("current-session")); + } + + #[test] + fn lifecycle_adapter_new_session_blocks_unsent_input_without_force() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let _home = lifecycle_home_guard(&tmpdir); + let mut app = lifecycle_test_app(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.input = "draft text".to_string(); + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + let err = facet.fresh_session(false).expect_err("blocker text"); + assert!(err.contains("/new --force"), "{err}"); + } + assert_eq!(app.input, "draft text"); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + let ok = facet.fresh_session(true).expect("force discards draft"); + assert_ne!(app.current_session_id.as_deref(), Some("old-session")); + assert!(app.input.is_empty()); + assert!(!ok.truncated_id.is_empty()); + assert!(ok.sync.messages.is_empty()); + } + } + + #[test] + fn lifecycle_adapter_load_validates_shape_without_applying_state() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let _home = lifecycle_home_guard(&tmpdir); + let mut app = lifecycle_test_app(&tmpdir); + app.api_messages.push(user_message("checkpoint")); + let save_path = tmpdir.path().join("checkpoint.json"); + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + facet + .save_session(Some(save_path.display().to_string())) + .expect("seed session file"); + } + let before = app.api_messages.clone(); + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + let missing = facet + .load_session("does-not-exist.json") + .expect_err("missing file"); + assert!(missing.contains("Failed to read session file"), "{missing}"); + let bad = tmpdir.path().join("bad.json"); + std::fs::write(&bad, "not json").unwrap(); + let parse = facet + .load_session(bad.display().to_string().as_str()) + .expect_err("invalid json"); + assert!(parse.contains("Failed to parse session file"), "{parse}"); + let resolved = facet + .load_session(save_path.display().to_string().as_str()) + .expect("valid session resolves"); + assert_eq!(resolved, save_path); + } + assert_eq!( + app.api_messages, before, + "no state applied by /load delegate" + ); + } + + #[test] + fn lifecycle_adapter_picker_archive_and_prune_behavior() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let _home = lifecycle_home_guard(&tmpdir); + let mut app = lifecycle_test_app(&tmpdir); + let before_kind = app.view_stack.top_kind(); + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + facet.open_picker(None); + facet.open_picker(Some("pick-me".to_string())); + } + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + facet.save_session(None).expect("seed archive target"); + } + let archived_id = app.current_session_id.clone().unwrap(); + { + let mut bundle = app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + let receipt = facet.set_archived(&archived_id, true).expect("archive ok"); + assert_eq!( + receipt.truncated_id, + crate::session_manager::truncate_id(&archived_id) + ); + assert!(!receipt.title.is_empty()); + let restored = facet.set_archived(&archived_id, false).expect("restore ok"); + assert_eq!(restored.truncated_id, receipt.truncated_id); + let pruned = facet.prune_sessions(36500).expect("prune runs"); + assert_eq!(pruned, 0, "no inactive session older than the window"); + } + assert_ne!( + app.view_stack.top_kind(), + before_kind, + "picker pushed a view" + ); + let manager = crate::session_manager::SessionManager::default_location().unwrap(); + assert!( + manager.load_session(&archived_id).is_ok(), + "active session survives pruning" + ); + } + + #[test] + fn lifecycle_adapter_tree_projections_cover_all_states() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let _home = lifecycle_home_guard(&tmpdir); + + // No active session. + let mut no_session_app = lifecycle_test_app(&tmpdir); + { + let mut bundle = no_session_app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + assert!(matches!( + facet.tree_body().expect("tree ok"), + TreeBodyProjection::NoSession + )); + } + + // Active session with no messages and no saved journal. + let mut empty_app = lifecycle_test_app(&tmpdir); + empty_app.current_session_id = Some("empty-session".to_string()); + { + let mut bundle = empty_app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + assert!(matches!( + facet.tree_body().expect("tree ok"), + TreeBodyProjection::EmptySession + )); + } + + // Linear transcript before the journal exists. + let mut linear_app = lifecycle_test_app(&tmpdir); + linear_app.current_session_id = Some("linear-session".to_string()); + linear_app + .api_messages + .push(user_message("first message with a long tail")); + { + let mut bundle = linear_app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + match facet.tree_body().expect("tree ok") { + TreeBodyProjection::Linear { rendered } => { + assert!(rendered.contains("Active branch (linear"), "{rendered}"); + assert!(rendered.contains("[0]"), "{rendered}"); + } + other => panic!("expected Linear projection, got {other:?}"), + } + } + + // Journal projection once the session is saved with messages. + let mut journal_app = lifecycle_test_app(&tmpdir); + journal_app + .api_messages + .push(user_message("journaled turn")); + { + let mut bundle = journal_app.command_contexts(); + let mut parts = bundle.parts(); + let facet = parts.lifecycle.as_deref_mut().expect("lifecycle slot"); + facet.save_session(None).expect("seed journaled session"); + match facet.tree_body().expect("tree ok") { + TreeBodyProjection::Journal { rendered } => { + assert!(!rendered.is_empty()); + } + other => panic!("expected Journal projection, got {other:?}"), + } + } + } } diff --git a/crates/tui/src/commands/groups/session/branch.rs b/crates/tui/src/commands/groups/session/branch.rs index d791b87f0b..fe19e00fe2 100644 --- a/crates/tui/src/commands/groups/session/branch.rs +++ b/crates/tui/src/commands/groups/session/branch.rs @@ -1,85 +1,80 @@ use super::CommandResult; -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + +use codewhale_command_contract::facets::CommandSessionLifecycleContext; +use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{ + CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, +}; + +pub(in crate::commands) struct BranchCmd; + +// --------------------------------------------------------------------------- +// FEAT-023 Phase 4 (D3/D5/D6): portable contextual registration and handler. +// The handler owns parsing, branch order, exact messages, guidance appends, +// and action composition; all concrete host work stays behind the lifecycle +// facet. Missing lifecycle authority fails safely with the exact capability +// error (never a panic). +// --------------------------------------------------------------------------- + +pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { name: "branch", aliases: &[], usage: "/branch ", - description_id: MessageId::CmdBranchDescription, + description_key: "cmd_branch_description", }; -pub(in crate::commands) struct BranchCmd; -impl RegisterCommand for BranchCmd { - fn info() -> &'static CommandInfo { - &COMMAND_INFO + +impl ContractRegisterCommand for BranchCmd { + fn info() -> &'static ContractInfo { + &CONTRACT_INFO } - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - branch(app, arg) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: + codewhale_command_contract::handler::CommandCapabilities::SESSION_LIFECYCLE, + handler: branch_contextual, + } } } -fn branch(app: &mut App, arg: Option<&str>) -> CommandResult { - if app.session_transition_blocked() { + +pub(in crate::commands) fn branch_contextual( + contexts: CommandContexts<'_>, + arg: Option<&str>, +) -> CommandResult { + let mut parts = contexts.into_parts(); + let Some(lifecycle) = parts.lifecycle.as_deref_mut() else { return CommandResult::error( - "Cannot branch while runtime work is active. Wait for the turn to finish, or cancel it first.", + "Command capability unavailable: session_lifecycle".to_string(), + ); + }; + branch_portable(lifecycle, arg) +} + +pub(in crate::commands) fn branch_portable( + lifecycle: &mut dyn CommandSessionLifecycleContext, + arg: Option<&str>, +) -> CommandResult { + if lifecycle.transition_blocked() { + return CommandResult::error( + "Cannot branch while runtime work is active. Wait for the turn to finish, or cancel it first." + .to_string(), ); } let Some(entry_id) = arg.map(str::trim).filter(|s| !s.is_empty()) else { - if let Some(session_id) = app.current_session_id.as_deref() - && let Ok(manager) = crate::session_manager::SessionManager::default_location() - && let Ok(mut session) = manager.load_session(session_id) - { - session.ensure_journal(); - if let Some(journal) = session.journal.as_ref() - && let Some(leaf) = journal.leaf_id.as_deref() - { - return CommandResult::message(format!( - "Current leaf: {leaf}\nUse `/branch ` to move the leaf (history is never rewritten).\nUse `/tree` to list entry ids." - )); - } + if let Some(leaf) = lifecycle.branch_current_leaf_hint() { + return CommandResult::message(format!( + "Current leaf: {leaf}\nUse `/branch ` to move the leaf (history is never rewritten).\nUse `/tree` to list entry ids." + )); } return CommandResult::message( - "Usage: /branch \nMoves the active leaf to an existing entry. Future appends become children of that entry.\nHistory is never rewritten — branching only moves the leaf.\n\nUse `/tree` to see entry ids.", + "Usage: /branch \nMoves the active leaf to an existing entry. Future appends become children of that entry.\nHistory is never rewritten — branching only moves the leaf.\n\nUse `/tree` to see entry ids." + .to_string(), ); }; - let session_id = match app.current_session_id.clone() { - Some(id) => id, - None => { - return CommandResult::error( - "No active session to branch. Resume or create a session first.", - ); - } - }; - let manager = match crate::session_manager::SessionManager::default_location() { - Ok(m) => m, - Err(e) => return CommandResult::error(format!("could not open sessions directory: {e}")), - }; - let mut session = match manager.load_session(&session_id) { - Ok(s) => s, - Err(e) => return CommandResult::error(format!("could not load session {session_id}: {e}")), - }; - session.ensure_journal(); - let journal_len_before = session - .journal - .as_ref() - .map(|j| j.entries.len()) - .unwrap_or(0); - match session.journal_branch_to(entry_id) { - Ok(()) => { - if let Err(e) = manager.save_session(&session) { - return CommandResult::error(format!("branch saved but persist failed: {e}")); - } - app.api_messages = session.messages.clone(); - let leaf = session - .leaf_id - .clone() - .unwrap_or_else(|| "(none)".to_string()); - let msg = format!( - "Branched to entry {entry_id} (leaf now {leaf}); journal entries {journal_len_before} (history preserved, leaf moved only)" - ); - CommandResult::message(msg) - } - Err(e) => CommandResult::error(format!( - "branch failed: {e}. Use `/tree` to see valid entry ids." + match lifecycle.branch_to(entry_id) { + Ok(outcome) => CommandResult::message(format!( + "Branched to entry {entry_id} (leaf now {}); journal entries {} (history preserved, leaf moved only)", + outcome.leaf_display, outcome.journal_entries_before )), + Err(error) => CommandResult::error(error), } } diff --git a/crates/tui/src/commands/groups/session/compact.rs b/crates/tui/src/commands/groups/session/compact.rs index 8f0c1a112b..4ad08a7dde 100644 --- a/crates/tui/src/commands/groups/session/compact.rs +++ b/crates/tui/src/commands/groups/session/compact.rs @@ -1,26 +1,84 @@ //! `/compact` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; - use super::CommandResult; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub(in crate::commands) struct CompactCmd; + +// --------------------------------------------------------------------------- +// FEAT-023 Phase 4 (D3/D6): portable pure registration. `/compact` parses and +// normalizes its focus argument and emits the existing receipt + action with +// no host context bundle (the baseline `App` parameter is unused). +// --------------------------------------------------------------------------- + +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{ + CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, +}; + +use crate::tui::app::AppAction; + +pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { name: "compact", aliases: &["yasuo"], usage: "/compact [focus]", - description_id: MessageId::CmdCompactDescription, + description_key: "cmd_compact_description", }; -pub(in crate::commands) struct CompactCmd; +impl ContractRegisterCommand for CompactCmd { + fn info() -> &'static ContractInfo { + &CONTRACT_INFO + } -impl RegisterCommand for CompactCmd { - fn info() -> &'static CommandInfo { - &COMMAND_INFO + fn handler() -> CommandHandler { + CommandHandler::Pure(compact_pure) } +} + +/// Pure `/compact` — byte-identical to the baseline `session::compact`. +pub(in crate::commands) fn compact_pure(arg: Option<&str>) -> CommandResult { + let focus = arg + .map(str::trim) + .filter(|focus| !focus.is_empty()) + .map(str::to_string); + let receipt = match focus.as_deref() { + Some(focus) => format!("Context compaction triggered (focus: {focus})..."), + None => "Context compaction triggered...".to_string(), + }; + CommandResult::with_message_and_action(receipt, AppAction::CompactContext { focus }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::AppAction; + + #[test] + fn pure_compact_matches_baseline_receipts() { + let none = compact_pure(None); + assert_eq!( + none.message.as_deref(), + Some("Context compaction triggered...") + ); + assert!(matches!( + none.action, + Some(AppAction::CompactContext { focus: None }) + )); + assert!(!none.is_error); + + let blank = compact_pure(Some(" ")); + assert!(matches!( + blank.action, + Some(AppAction::CompactContext { focus: None }) + )); - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - super::session::compact(app, arg) + let focus = compact_pure(Some(" the auth refactor ")); + assert_eq!( + focus.message.as_deref(), + Some("Context compaction triggered (focus: the auth refactor)...") + ); + assert!(matches!( + focus.action, + Some(AppAction::CompactContext { focus: Some(ref f) }) if f == "the auth refactor" + )); } } diff --git a/crates/tui/src/commands/groups/session/fork.rs b/crates/tui/src/commands/groups/session/fork.rs index f2728e2687..90054f55a9 100644 --- a/crates/tui/src/commands/groups/session/fork.rs +++ b/crates/tui/src/commands/groups/session/fork.rs @@ -1,41 +1,102 @@ //! `/fork` command — interactive picker (#576) + direct fork. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; -use crate::tui::session_picker::SessionPickerView; - use super::CommandResult; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +use codewhale_command_contract::facets::CommandSessionLifecycleContext; +use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{ + CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, +}; + +pub(in crate::commands) struct ForkCmd; + +// --------------------------------------------------------------------------- +// FEAT-023 Phase 4 (D3/D5/D6): portable contextual registration and handler. +// The handler owns parsing, branch order, exact messages, guidance appends, +// and action composition; all concrete host work stays behind the lifecycle +// facet. Missing lifecycle authority fails safely with the exact capability +// error (never a panic). +// --------------------------------------------------------------------------- + +pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { name: "fork", aliases: &["f"], usage: "/fork [session_id|picker]", - description_id: MessageId::CmdForkDescription, + description_key: "cmd_fork_description", }; -pub(in crate::commands) struct ForkCmd; - -impl RegisterCommand for ForkCmd { - fn info() -> &'static CommandInfo { - &COMMAND_INFO +impl ContractRegisterCommand for ForkCmd { + fn info() -> &'static ContractInfo { + &CONTRACT_INFO } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: + codewhale_command_contract::handler::CommandCapabilities::SESSION_LIFECYCLE, + handler: fork_contextual, + } + } +} + +pub(in crate::commands) fn fork_contextual( + contexts: CommandContexts<'_>, + arg: Option<&str>, +) -> CommandResult { + let mut parts = contexts.into_parts(); + let Some(lifecycle) = parts.lifecycle.as_deref_mut() else { + return CommandResult::error( + "Command capability unavailable: session_lifecycle".to_string(), + ); + }; + fork_portable(lifecycle, arg) +} - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - let trimmed = arg.map(str::trim).filter(|s| !s.is_empty()); - if let Some(a) = trimmed { - if matches!( - a.to_ascii_lowercase().as_str(), - "picker" | "list" | "--picker" | "pick" - ) { - app.view_stack - .push(SessionPickerView::new(&app.workspace, app.ui_locale)); - return CommandResult::message( - "Fork picker: select a session and then run `/fork ` to fork it.", - ); - } - return super::session::fork_from_session(app, a); +pub(in crate::commands) fn fork_portable( + lifecycle: &mut dyn CommandSessionLifecycleContext, + arg: Option<&str>, +) -> CommandResult { + let trimmed = arg.map(str::trim).filter(|s| !s.is_empty()); + if let Some(a) = trimmed { + if matches!( + a.to_ascii_lowercase().as_str(), + "picker" | "list" | "--picker" | "pick" + ) { + lifecycle.open_picker(None); + return CommandResult::message( + "Fork picker: select a session and then run `/fork ` to fork it.".to_string(), + ); + } + if lifecycle.transition_blocked() { + return CommandResult::error( + "Cannot fork a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first." + .to_string(), + ); } - super::session::fork(app) + return match lifecycle.fork_from(a) { + Ok(receipt) => CommandResult::with_message_and_action( + format!( + "Forked session {} -> {} (spawn_depth {})", + receipt.parent_label, receipt.fork_label, receipt.spawn_depth + ), + super::sync_session_action(receipt.sync), + ), + Err(error) => CommandResult::error(error), + }; + } + if lifecycle.transition_blocked() { + return CommandResult::error( + "Cannot fork a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first." + .to_string(), + ); + } + match lifecycle.fork_active() { + Ok(receipt) => CommandResult::with_message_and_action( + format!( + "Forked session {} -> {}", + receipt.parent_label, receipt.fork_label + ), + super::sync_session_action(receipt.sync), + ), + Err(error) => CommandResult::error(error), } } diff --git a/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs b/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs new file mode 100644 index 0000000000..47921db00d --- /dev/null +++ b/crates/tui/src/commands/groups/session/lifecycle_portable_tests.rs @@ -0,0 +1,530 @@ +//! FEAT-023 Phase 4/6: portable lifecycle handler tests (Tasks 4.2/4.4/4.6). +//! +//! Deterministic composition tests: canned lifecycle outcomes drive each +//! portable handler and the exact baseline messages/actions are asserted +//! byte-for-byte. The public dispatch seam integration (real bundle) is +//! exercised in Phase 6 and the end-to-end parity matrix in Phase 7. + +use codewhale_command_contract::facets::{ + SessionArchiveReceipt, SessionBranchOutcome, SessionForkFromReceipt, SessionForkReceipt, + SessionNewReceipt, SessionSaveReceipt, TreeBodyProjection, +}; +use codewhale_command_contract::handler::CommandContexts; +use std::path::PathBuf; + +use crate::tui::app::AppAction; + +use super::lifecycle_test_support::{CannedLifecycle, sync_payload}; + +fn missing_lifecycle() -> CommandContexts<'static> { + CommandContexts::empty() +} + +// ---- /branch (Task 4.5) ---- + +#[test] +fn every_contextual_lifecycle_handler_fails_safely_without_its_facet() { + type Handler = fn(CommandContexts<'_>, Option<&str>) -> super::CommandResult; + let handlers: [(&str, Handler, Option<&str>); 7] = [ + ("branch", super::branch::branch_contextual, Some("entry-1")), + ("fork", super::fork::fork_contextual, Some("session-1")), + ("load", super::load::load_contextual, Some("session.json")), + ("new", super::new::new_contextual, None), + ("save", super::save::save_contextual, None), + ("sessions", super::sessions::sessions_contextual, None), + ("tree", super::tree::tree_contextual, None), + ]; + + for (name, handler, arg) in handlers { + let result = handler(missing_lifecycle(), arg); + assert!(result.is_error, "/{name}: {result:?}"); + assert_eq!( + result.message.as_deref(), + Some("Error: Command capability unavailable: session_lifecycle"), + "/{name}" + ); + assert!(result.action.is_none(), "/{name}"); + } +} + +#[test] +fn branch_composes_exact_baseline_messages() { + // Blocked transition first. + let mut canned = CannedLifecycle { + blocked: true, + ..CannedLifecycle::default() + }; + let result = super::branch::branch_portable(&mut canned, Some("entry-1")); + assert_eq!( + result.message.as_deref(), + Some( + "Error: Cannot branch while runtime work is active. Wait for the turn to finish, or cancel it first." + ) + ); + assert_eq!(canned.transition_checks.get(), 1); + assert!(canned.branch_entries.is_empty()); + + // No-arg with an active leaf hint. + let mut canned = CannedLifecycle { + leaf_hint: Some("entry-7".to_string()), + ..CannedLifecycle::default() + }; + let result = super::branch::branch_portable(&mut canned, None); + assert_eq!( + result.message.as_deref(), + Some( + "Current leaf: entry-7\nUse `/branch ` to move the leaf (history is never rewritten).\nUse `/tree` to list entry ids." + ) + ); + + // No-arg without a leaf -> usage fallback. + let mut canned = CannedLifecycle::default(); + let result = super::branch::branch_portable(&mut canned, None); + assert!( + result + .message + .as_deref() + .is_some_and(|m| m.starts_with("Usage: /branch ")), + "{result:?}" + ); + + // Success message uses deterministic receipt fields. + let mut canned = CannedLifecycle { + branch: Ok(SessionBranchOutcome { + leaf_display: "entry-3".to_string(), + journal_entries_before: 5, + }), + ..CannedLifecycle::default() + }; + let result = super::branch::branch_portable(&mut canned, Some("entry-3")); + assert_eq!( + result.message.as_deref(), + Some( + "Branched to entry entry-3 (leaf now entry-3); journal entries 5 (history preserved, leaf moved only)" + ) + ); + assert!(result.action.is_none()); + assert_eq!(canned.branch_entries, ["entry-3"]); + + // Host stage error passes through unchanged. + let mut canned = CannedLifecycle { + branch: Err("could not load session x: boom".to_string()), + ..CannedLifecycle::default() + }; + let result = super::branch::branch_portable(&mut canned, Some("x")); + assert!(result.is_error); + assert_eq!( + result.message.as_deref(), + Some("Error: could not load session x: boom") + ); +} + +// ---- /fork (Task 4.3) ---- + +#[test] +fn fork_composes_exact_baseline_messages_and_actions() { + // Picker aliases push the picker and return the baseline message. + let mut canned = CannedLifecycle::default(); + let result = super::fork::fork_portable(&mut canned, Some("picker")); + assert_eq!( + result.message.as_deref(), + Some("Fork picker: select a session and then run `/fork ` to fork it.") + ); + assert_eq!( + canned.picker_calls, + [None], + "bare picker must be opened without preselection" + ); + assert_eq!( + canned.transition_checks.get(), + 0, + "picker aliases bypass the transition gate" + ); + + // Blocked active fork. + let mut canned = CannedLifecycle { + blocked: true, + ..CannedLifecycle::default() + }; + let result = super::fork::fork_portable(&mut canned, None); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("runtime work is active"), + "{result:?}" + ); + assert_eq!(canned.transition_checks.get(), 1); + + // Active fork success -> message + SyncSession action from the receipt. + let mut canned = CannedLifecycle { + fork_active: Ok(SessionForkReceipt { + parent_label: "parent1".to_string(), + fork_label: "child2".to_string(), + sync: sync_payload("child2"), + }), + ..CannedLifecycle::default() + }; + let result = super::fork::fork_portable(&mut canned, None); + assert_eq!( + result.message.as_deref(), + Some("Forked session parent1 -> child2") + ); + assert!(matches!( + result.action, + Some(AppAction::SyncSession { session_id: Some(ref id), .. }) if id == "child2" + )); + assert_eq!(canned.transition_checks.get(), 1); + + // Explicit fork success appends spawn_depth. + let mut canned = CannedLifecycle { + fork_from: Ok(SessionForkFromReceipt { + parent_label: "aaaa".to_string(), + fork_label: "bbbb".to_string(), + spawn_depth: 2, + sync: sync_payload("bbbb"), + }), + ..CannedLifecycle::default() + }; + let result = super::fork::fork_portable(&mut canned, Some("aaaa")); + assert_eq!( + result.message.as_deref(), + Some("Forked session aaaa -> bbbb (spawn_depth 2)") + ); + assert_eq!(canned.fork_sources, ["aaaa"]); + assert_eq!(canned.transition_checks.get(), 1); +} + +// ---- /load (Task 4.3) ---- + +#[test] +fn load_composes_exact_baseline_outcomes() { + let mut canned = CannedLifecycle { + blocked: true, + ..CannedLifecycle::default() + }; + let result = super::load::load_portable(&mut canned, Some("x.json")); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("runtime work is active") + ); + assert_eq!(canned.transition_checks.get(), 1); + assert!(canned.load_paths.is_empty()); + + let mut canned = CannedLifecycle::default(); + let result = super::load::load_portable(&mut canned, None); + assert_eq!( + result.message.as_deref(), + Some("Error: Usage: /load ") + ); + assert_eq!(canned.transition_checks.get(), 1); + assert!(canned.load_paths.is_empty()); + + let mut canned = CannedLifecycle { + load: Ok(PathBuf::from("/tmp/loaded.json")), + ..CannedLifecycle::default() + }; + let result = super::load::load_portable(&mut canned, Some("/tmp/loaded.json")); + assert!(result.message.is_none(), "no premature receipt: {result:?}"); + assert!(matches!( + result.action, + Some(AppAction::LoadSession(ref p)) if p == &PathBuf::from("/tmp/loaded.json") + )); + assert_eq!(canned.load_paths, ["/tmp/loaded.json"]); + assert_eq!(canned.transition_checks.get(), 1); + + let mut canned = CannedLifecycle { + load: Err("Failed to read session file: nope".to_string()), + ..CannedLifecycle::default() + }; + let result = super::load::load_portable(&mut canned, Some("missing.json")); + assert_eq!( + result.message.as_deref(), + Some("Error: Failed to read session file: nope") + ); +} + +// ---- /new (Task 4.3) ---- + +#[test] +fn new_composes_exact_baseline_outcomes() { + // Unknown argument usage. + let mut canned = CannedLifecycle::default(); + let result = super::new::new_portable(&mut canned, Some("bogus")); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("Unknown argument: bogus"), + "{result:?}" + ); + assert_eq!( + canned.transition_checks.get(), + 0, + "argument validation precedes the transition gate" + ); + + // Blocked. + let mut canned = CannedLifecycle { + blocked: true, + ..CannedLifecycle::default() + }; + let result = super::new::new_portable(&mut canned, None); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("only discards draft or queued input") + ); + assert_eq!(canned.transition_checks.get(), 1); + assert!(canned.fresh_forces.is_empty()); + + // Success -> message + empty SyncSession action. + let mut canned = CannedLifecycle { + fresh: Ok(SessionNewReceipt { + truncated_id: "new-123".to_string(), + sync: sync_payload("new-123"), + }), + ..CannedLifecycle::default() + }; + let result = super::new::new_portable(&mut canned, Some("--force")); + assert_eq!( + result.message.as_deref(), + Some( + "Started new session new-123 (New Session). Previous sessions remain available via /resume." + ) + ); + assert!(matches!( + result.action, + Some(AppAction::SyncSession { session_id: Some(ref id), .. }) if id == "new-123" + )); + assert_eq!(canned.fresh_forces, [true]); + assert_eq!(canned.transition_checks.get(), 1); + + // Host blocker error passes through. + let mut canned = CannedLifecycle { + fresh: Err("Cannot start a new session while the composer has unsent text. Run `/new --force` to discard pending work and start a fresh session.".to_string()), + ..CannedLifecycle::default() + }; + let result = super::new::new_portable(&mut canned, None); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("/new --force") + ); +} + +// ---- /save (Task 4.3) ---- + +#[test] +fn save_composes_exact_baseline_receipt() { + let mut canned = CannedLifecycle { + save: Ok(SessionSaveReceipt { + display_path: "/tmp/abc.json".to_string(), + truncated_id: "abc123".to_string(), + }), + ..CannedLifecycle::default() + }; + let result = super::save::save_portable(&mut canned, Some("/tmp/abc.json")); + assert_eq!( + result.message.as_deref(), + Some("Session saved to /tmp/abc.json (ID: abc123)") + ); + assert!(result.action.is_none()); + assert_eq!(canned.save_paths, [Some("/tmp/abc.json".to_string())]); + + let mut canned = CannedLifecycle { + save: Err("Failed to save session: boom".to_string()), + ..CannedLifecycle::default() + }; + let result = super::save::save_portable(&mut canned, None); + assert_eq!( + result.message.as_deref(), + Some("Error: Failed to save session: boom") + ); +} + +// ---- /sessions (Task 4.5) ---- + +#[test] +fn sessions_composes_exact_baseline_outcomes() { + // Bare -> picker push, no message/action. + let mut canned = CannedLifecycle::default(); + let result = super::sessions::sessions_portable(&mut canned, None); + assert_eq!(result.message, None); + assert_eq!(result.action, None); + assert_eq!(canned.picker_calls, [None]); + + // show/list/picker aliases. + for alias in ["show", "list", "picker"] { + let mut canned = CannedLifecycle::default(); + let result = super::sessions::sessions_portable(&mut canned, Some(alias)); + assert_eq!(result.message, None, "{alias}"); + assert_eq!(canned.picker_calls, [None], "{alias}"); + } + + // open with preselection. + let mut canned = CannedLifecycle::default(); + let _result = super::sessions::sessions_portable(&mut canned, Some("open abc123")); + assert_eq!(canned.picker_calls, [Some("abc123".to_string())]); + + // open without id -> usage. + let result = super::sessions::sessions_portable(&mut canned, Some("open")); + assert_eq!( + result.message.as_deref(), + Some("Error: usage: /sessions open ") + ); + + // archive/unarchive messages. + let mut canned = CannedLifecycle { + archived: Ok(SessionArchiveReceipt { + truncated_id: "zzz".to_string(), + title: "My Session".to_string(), + }), + ..CannedLifecycle::default() + }; + let result = super::sessions::sessions_portable(&mut canned, Some("archive zzz")); + assert_eq!( + result.message.as_deref(), + Some("Archived session zzz (My Session)") + ); + assert_eq!(canned.archive_calls, [("zzz".to_string(), true)]); + let mut canned = CannedLifecycle { + archived: Ok(SessionArchiveReceipt { + truncated_id: "zzz".to_string(), + title: "My Session".to_string(), + }), + ..CannedLifecycle::default() + }; + let result = super::sessions::sessions_portable(&mut canned, Some("restore zzz")); + assert_eq!( + result.message.as_deref(), + Some("Restored session zzz (My Session)") + ); + assert_eq!(canned.archive_calls, [("zzz".to_string(), false)]); + + // prune parsing + messages. + let result = super::sessions::sessions_portable(&mut canned, Some("prune")); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("usage: /sessions prune ") + ); + let result = super::sessions::sessions_portable(&mut canned, Some("prune abc")); + assert_eq!( + result.message.as_deref(), + Some("Error: expected a positive integer number of days, got `abc`") + ); + let mut canned = CannedLifecycle { + prune: Ok(0), + ..CannedLifecycle::default() + }; + let result = super::sessions::sessions_portable(&mut canned, Some("prune 30")); + assert_eq!( + result.message.as_deref(), + Some("no sessions older than 30d to prune") + ); + assert_eq!(canned.prune_days, [30]); + let mut canned = CannedLifecycle { + prune: Ok(2), + ..CannedLifecycle::default() + }; + let result = super::sessions::sessions_portable(&mut canned, Some("prune 30")); + assert_eq!( + result.message.as_deref(), + Some("pruned 2 sessions older than 30d") + ); + assert_eq!(canned.prune_days, [30]); + + // Unknown subcommand. + let result = super::sessions::sessions_portable(&mut canned, Some("teleport")); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("unknown subcommand `teleport`") + ); +} + +// ---- /tree (Task 4.5) ---- + +#[test] +fn tree_composes_exact_baseline_messages() { + let mut canned = CannedLifecycle { + tree: Ok(TreeBodyProjection::Journal { + rendered: "journal body".to_string(), + }), + ..CannedLifecycle::default() + }; + let result = super::tree::tree_portable(&mut canned, None); + assert_eq!( + result.message.as_deref(), + Some( + "journal body\nUse `/branch ` to branch (moves leaf only, never rewrites history).\nUse `/fork [session_id]` to fork this session at any node.\n" + ) + ); + + let mut canned = CannedLifecycle { + tree: Ok(TreeBodyProjection::Linear { + rendered: "linear body".to_string(), + }), + ..CannedLifecycle::default() + }; + let result = super::tree::tree_portable(&mut canned, None); + assert_eq!( + result.message.as_deref(), + Some("linear body\nUse `/branch ` with entry id after journal is saved.\n") + ); + + let mut canned = CannedLifecycle { + tree: Ok(TreeBodyProjection::EmptySession), + ..CannedLifecycle::default() + }; + let result = super::tree::tree_portable(&mut canned, None); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("(empty session — no entries yet)") + ); + + let mut canned = CannedLifecycle { + tree: Ok(TreeBodyProjection::NoSession), + ..CannedLifecycle::default() + }; + let result = super::tree::tree_portable(&mut canned, None); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("No active session") + ); + + let mut canned = CannedLifecycle { + tree: Err("could not open sessions directory: boom".to_string()), + ..CannedLifecycle::default() + }; + let result = super::tree::tree_portable(&mut canned, None); + assert_eq!( + result.message.as_deref(), + Some("Error: could not open sessions directory: boom") + ); +} diff --git a/crates/tui/src/commands/groups/session/lifecycle_test_support.rs b/crates/tui/src/commands/groups/session/lifecycle_test_support.rs new file mode 100644 index 0000000000..90a36af2d3 --- /dev/null +++ b/crates/tui/src/commands/groups/session/lifecycle_test_support.rs @@ -0,0 +1,124 @@ +//! FEAT-023 Phase 4/6 test support: a deterministic canned implementation of +//! `CommandSessionLifecycleContext` so portable handlers are unit-tested for +//! exact message/action composition without host state. + +use std::cell::Cell; + +use codewhale_command_contract::facets::{ + CommandSessionLifecycleContext, SessionArchiveReceipt, SessionBranchOutcome, + SessionForkFromReceipt, SessionForkReceipt, SessionNewReceipt, SessionSaveReceipt, + SessionSyncPayload, TreeBodyProjection, +}; +use std::path::PathBuf; + +/// Every delegate returns the canned value set by the test and records its +/// arguments so handler-routing assertions cannot pass without the expected +/// facet call. Unconfigured result slots return a descriptive canned error. +pub(crate) struct CannedLifecycle { + pub blocked: bool, + pub transition_checks: Cell, + pub leaf_hint: Option, + pub branch: Result, + pub tree: Result, + pub save: Result, + pub fork_active: Result, + pub fork_from: Result, + pub fresh: Result, + pub load: Result, + pub archived: Result, + pub prune: Result, + pub branch_entries: Vec, + pub save_paths: Vec>, + pub fork_sources: Vec, + pub fresh_forces: Vec, + pub load_paths: Vec, + pub picker_calls: Vec>, + pub archive_calls: Vec<(String, bool)>, + pub prune_days: Vec, +} + +impl Default for CannedLifecycle { + fn default() -> Self { + Self { + blocked: false, + transition_checks: Cell::new(0), + leaf_hint: None, + branch: Err("canned: branch_to not configured".to_string()), + tree: Ok(TreeBodyProjection::NoSession), + save: Err("canned: save not configured".to_string()), + fork_active: Err("canned: fork_active not configured".to_string()), + fork_from: Err("canned: fork_from not configured".to_string()), + fresh: Err("canned: fresh_session not configured".to_string()), + load: Err("canned: load not configured".to_string()), + archived: Err("canned: set_archived not configured".to_string()), + prune: Err("canned: prune not configured".to_string()), + branch_entries: Vec::new(), + save_paths: Vec::new(), + fork_sources: Vec::new(), + fresh_forces: Vec::new(), + load_paths: Vec::new(), + picker_calls: Vec::new(), + archive_calls: Vec::new(), + prune_days: Vec::new(), + } + } +} + +pub(crate) fn sync_payload(session_id: &str) -> SessionSyncPayload { + SessionSyncPayload { + session_id: Some(session_id.to_string()), + messages: vec![], + system_prompt: None, + model: "test-model".to_string(), + workspace: PathBuf::from("/workspace"), + mode: codewhale_command_contract::types::CommandMode::Agent, + } +} + +impl CommandSessionLifecycleContext for CannedLifecycle { + fn transition_blocked(&self) -> bool { + self.transition_checks + .set(self.transition_checks.get().saturating_add(1)); + self.blocked + } + fn branch_current_leaf_hint(&self) -> Option { + self.leaf_hint.clone() + } + fn branch_to(&mut self, entry_id: &str) -> Result { + self.branch_entries.push(entry_id.to_string()); + self.branch.clone() + } + fn tree_body(&self) -> Result { + self.tree.clone() + } + fn save_session(&mut self, path: Option) -> Result { + self.save_paths.push(path); + self.save.clone() + } + fn fork_active(&mut self) -> Result { + self.fork_active.clone() + } + fn fork_from(&mut self, id: &str) -> Result { + self.fork_sources.push(id.to_string()); + self.fork_from.clone() + } + fn fresh_session(&mut self, force: bool) -> Result { + self.fresh_forces.push(force); + self.fresh.clone() + } + fn load_session(&mut self, path: &str) -> Result { + self.load_paths.push(path.to_string()); + self.load.clone() + } + fn open_picker(&mut self, preselected: Option) { + self.picker_calls.push(preselected); + } + fn set_archived(&mut self, id: &str, archived: bool) -> Result { + self.archive_calls.push((id.to_string(), archived)); + self.archived.clone() + } + fn prune_sessions(&mut self, days: u64) -> Result { + self.prune_days.push(days); + self.prune.clone() + } +} diff --git a/crates/tui/src/commands/groups/session/load.rs b/crates/tui/src/commands/groups/session/load.rs index 03a6cadbeb..c349abcca2 100644 --- a/crates/tui/src/commands/groups/session/load.rs +++ b/crates/tui/src/commands/groups/session/load.rs @@ -1,26 +1,71 @@ //! `/load` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; - use super::CommandResult; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +use codewhale_command_contract::facets::CommandSessionLifecycleContext; +use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{ + CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, +}; + +pub(in crate::commands) struct LoadCmd; + +// --------------------------------------------------------------------------- +// FEAT-023 Phase 4 (D3/D5/D6): portable contextual registration and handler. +// The handler owns parsing, branch order, exact messages, guidance appends, +// and action composition; all concrete host work stays behind the lifecycle +// facet. Missing lifecycle authority fails safely with the exact capability +// error (never a panic). +// --------------------------------------------------------------------------- + +pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { name: "load", aliases: &["jiazai"], usage: "/load [path]", - description_id: MessageId::CmdLoadDescription, + description_key: "cmd_load_description", }; -pub(in crate::commands) struct LoadCmd; - -impl RegisterCommand for LoadCmd { - fn info() -> &'static CommandInfo { - &COMMAND_INFO +impl ContractRegisterCommand for LoadCmd { + fn info() -> &'static ContractInfo { + &CONTRACT_INFO + } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: + codewhale_command_contract::handler::CommandCapabilities::SESSION_LIFECYCLE, + handler: load_contextual, + } } +} - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - super::session::load(app, arg) +pub(in crate::commands) fn load_contextual( + contexts: CommandContexts<'_>, + arg: Option<&str>, +) -> CommandResult { + let mut parts = contexts.into_parts(); + let Some(lifecycle) = parts.lifecycle.as_deref_mut() else { + return CommandResult::error( + "Command capability unavailable: session_lifecycle".to_string(), + ); + }; + load_portable(lifecycle, arg) +} + +pub(in crate::commands) fn load_portable( + lifecycle: &mut dyn CommandSessionLifecycleContext, + arg: Option<&str>, +) -> CommandResult { + if lifecycle.transition_blocked() { + return CommandResult::error( + "Cannot load a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first." + .to_string(), + ); + } + let Some(path) = arg.map(str::trim).filter(|p| !p.is_empty()) else { + return CommandResult::error("Usage: /load ".to_string()); + }; + match lifecycle.load_session(path) { + Ok(load_path) => CommandResult::action(crate::tui::app::AppAction::LoadSession(load_path)), + Err(error) => CommandResult::error(error), } } diff --git a/crates/tui/src/commands/groups/session/mod.rs b/crates/tui/src/commands/groups/session/mod.rs index 8f278a5022..fd36bb4066 100644 --- a/crates/tui/src/commands/groups/session/mod.rs +++ b/crates/tui/src/commands/groups/session/mod.rs @@ -1,8 +1,6 @@ //! Session command area: saving, forking, resuming, exporting, and the //! `/relay` session-handoff artifact. -#[cfg(all(test, feature = "long-running-tests"))] -mod acceptance; mod branch; mod compact; mod export; @@ -30,7 +28,9 @@ mod tree; mod session; use crate::commands::CommandResult; -use crate::commands::traits::{Command, CommandGroup, FunctionCommand, RegisterCommand}; +use crate::commands::traits::{ + Command, CommandGroup, ContextualCommand, FunctionCommand, RegisterCommand, +}; pub struct SessionCommands; @@ -45,46 +45,38 @@ impl CommandGroup for SessionCommands { title::TitleCmd::info(), title::TitleCmd::execute, )), - Box::new(FunctionCommand::new( - save::SaveCmd::info(), - save::SaveCmd::execute, - )), - Box::new(FunctionCommand::new( - fork::ForkCmd::info(), - fork::ForkCmd::execute, - )), - Box::new(FunctionCommand::new( - new::NewCmd::info(), - new::NewCmd::execute, - )), - Box::new(FunctionCommand::new( - sessions::SessionsCmd::info(), - sessions::SessionsCmd::execute, - )), - Box::new(FunctionCommand::new( - load::LoadCmd::info(), - load::LoadCmd::execute, - )), + Box::new( + ContextualCommand::from_contract::().expect("save registration") + ), + Box::new( + ContextualCommand::from_contract::().expect("fork registration") + ), + Box::new(ContextualCommand::from_contract::().expect("new registration")), + Box::new( + ContextualCommand::from_contract::() + .expect("sessions registration") + ), + Box::new( + ContextualCommand::from_contract::().expect("load registration") + ), Box::new(FunctionCommand::new( resume::ResumeCmd::info(), resume::ResumeCmd::execute, )), - Box::new(FunctionCommand::new( - tree::TreeCmd::info(), - tree::TreeCmd::execute, - )), - Box::new(FunctionCommand::new( - branch::BranchCmd::info(), - branch::BranchCmd::execute, - )), - Box::new(FunctionCommand::new( - compact::CompactCmd::info(), - compact::CompactCmd::execute, - )), - Box::new(FunctionCommand::new( - purge::PurgeCmd::info(), - purge::PurgeCmd::execute, - )), + Box::new( + ContextualCommand::from_contract::().expect("tree registration") + ), + Box::new( + ContextualCommand::from_contract::() + .expect("branch registration") + ), + Box::new( + ContextualCommand::from_contract::() + .expect("compact registration") + ), + Box::new( + ContextualCommand::from_contract::().expect("purge registration") + ), Box::new(FunctionCommand::new( relay::RelayCmd::info(), relay::RelayCmd::execute, @@ -108,3 +100,27 @@ impl CommandGroup for SessionCommands { ]) } } + +// --------------------------------------------------------------------------- +// FEAT-023 Phase 4 (D6): map a portable lifecycle sync payload into the +// temporary `SyncSession` action. FEAT-037 owns the eventual shared outcome +// types; until then the mapping lives here so every portable handler composes +// the same action from the same receipt. +// --------------------------------------------------------------------------- +pub(in crate::commands) fn sync_session_action( + sync: codewhale_command_contract::facets::SessionSyncPayload, +) -> crate::tui::app::AppAction { + crate::tui::app::AppAction::SyncSession { + session_id: sync.session_id, + messages: sync.messages, + system_prompt: sync.system_prompt, + model: sync.model, + workspace: sync.workspace, + mode: crate::commands::contract::from_command_mode(sync.mode), + } +} + +#[cfg(test)] +mod lifecycle_portable_tests; +#[cfg(test)] +mod lifecycle_test_support; diff --git a/crates/tui/src/commands/groups/session/new.rs b/crates/tui/src/commands/groups/session/new.rs index c6f56a90d7..7759b15fa3 100644 --- a/crates/tui/src/commands/groups/session/new.rs +++ b/crates/tui/src/commands/groups/session/new.rs @@ -1,26 +1,79 @@ -//! `/new` command. - -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; +//! `/new` command — start a fresh saved session from the current TUI state. use super::CommandResult; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +use codewhale_command_contract::facets::CommandSessionLifecycleContext; +use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{ + CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, +}; + +pub(in crate::commands) struct NewCmd; + +// --------------------------------------------------------------------------- +// FEAT-023 Phase 4 (D3/D5/D6): portable contextual registration and handler. +// --------------------------------------------------------------------------- + +pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { name: "new", aliases: &[], usage: "/new [--force]", - description_id: MessageId::CmdNewDescription, + description_key: "cmd_new_description", }; -pub(in crate::commands) struct NewCmd; - -impl RegisterCommand for NewCmd { - fn info() -> &'static CommandInfo { - &COMMAND_INFO +impl ContractRegisterCommand for NewCmd { + fn info() -> &'static ContractInfo { + &CONTRACT_INFO + } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: + codewhale_command_contract::handler::CommandCapabilities::SESSION_LIFECYCLE, + handler: new_contextual, + } } +} - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - super::session::new_session(app, arg) +pub(in crate::commands) fn new_contextual( + contexts: CommandContexts<'_>, + arg: Option<&str>, +) -> CommandResult { + let mut parts = contexts.into_parts(); + let Some(lifecycle) = parts.lifecycle.as_deref_mut() else { + return CommandResult::error( + "Command capability unavailable: session_lifecycle".to_string(), + ); + }; + new_portable(lifecycle, arg) +} + +pub(in crate::commands) fn new_portable( + lifecycle: &mut dyn CommandSessionLifecycleContext, + arg: Option<&str>, +) -> CommandResult { + let force = match arg.map(str::trim).filter(|s| !s.is_empty()) { + None => false, + Some("--force" | "force") => true, + Some(other) => { + return CommandResult::error(format!( + "Usage: /new [--force]\n\nUnknown argument: {other}" + )); + } + }; + if lifecycle.transition_blocked() { + return CommandResult::error( + "Cannot start a new session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work. `/new --force` only discards draft or queued input." + .to_string(), + ); + } + match lifecycle.fresh_session(force) { + Ok(receipt) => CommandResult::with_message_and_action( + format!( + "Started new session {} (New Session). Previous sessions remain available via /resume.", + receipt.truncated_id + ), + super::sync_session_action(receipt.sync), + ), + Err(error) => CommandResult::error(error), } } diff --git a/crates/tui/src/commands/groups/session/purge.rs b/crates/tui/src/commands/groups/session/purge.rs index e13fc42056..d7786786e9 100644 --- a/crates/tui/src/commands/groups/session/purge.rs +++ b/crates/tui/src/commands/groups/session/purge.rs @@ -1,26 +1,60 @@ //! `/purge` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; - use super::CommandResult; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub(in crate::commands) struct PurgeCmd; + +// --------------------------------------------------------------------------- +// FEAT-023 Phase 4 (D3/D6): portable pure registration. `/purge` emits the +// existing receipt + action with no host context bundle; any argument is +// ignored exactly like the baseline. +// --------------------------------------------------------------------------- + +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{ + CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, +}; + +use crate::tui::app::AppAction; + +pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { name: "purge", aliases: &["qingchu"], usage: "/purge", - description_id: MessageId::CmdPurgeDescription, + description_key: "cmd_purge_description", }; -pub(in crate::commands) struct PurgeCmd; +impl ContractRegisterCommand for PurgeCmd { + fn info() -> &'static ContractInfo { + &CONTRACT_INFO + } -impl RegisterCommand for PurgeCmd { - fn info() -> &'static CommandInfo { - &COMMAND_INFO + fn handler() -> CommandHandler { + CommandHandler::Pure(purge_pure) } +} + +/// Pure `/purge` — byte-identical to the baseline `session::purge`. +pub(in crate::commands) fn purge_pure(_arg: Option<&str>) -> CommandResult { + CommandResult::with_message_and_action( + "Agent context purge triggered...".to_string(), + AppAction::PurgeContext, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::app::AppAction; - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - super::session::purge(app) + #[test] + fn pure_purge_matches_baseline_receipt() { + let result = purge_pure(Some("ignored")); + assert_eq!( + result.message.as_deref(), + Some("Agent context purge triggered...") + ); + assert!(matches!(result.action, Some(AppAction::PurgeContext))); + assert!(!result.is_error); } } diff --git a/crates/tui/src/commands/groups/session/save.rs b/crates/tui/src/commands/groups/session/save.rs index fbf589f57e..e63ef849cd 100644 --- a/crates/tui/src/commands/groups/session/save.rs +++ b/crates/tui/src/commands/groups/session/save.rs @@ -1,26 +1,65 @@ -//! `/save` command. - -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; +//! `/save` command — persist the current session. use super::CommandResult; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +use codewhale_command_contract::facets::CommandSessionLifecycleContext; +use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{ + CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, +}; + +pub(in crate::commands) struct SaveCmd; + +// --------------------------------------------------------------------------- +// FEAT-023 Phase 4 (D3/D5/D6): portable contextual registration and handler. +// --------------------------------------------------------------------------- + +pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { name: "save", aliases: &[], usage: "/save [path]", - description_id: MessageId::CmdSaveDescription, + description_key: "cmd_save_description", }; -pub(in crate::commands) struct SaveCmd; - -impl RegisterCommand for SaveCmd { - fn info() -> &'static CommandInfo { - &COMMAND_INFO +impl ContractRegisterCommand for SaveCmd { + fn info() -> &'static ContractInfo { + &CONTRACT_INFO + } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: + codewhale_command_contract::handler::CommandCapabilities::SESSION_LIFECYCLE, + handler: save_contextual, + } } +} + +pub(in crate::commands) fn save_contextual( + contexts: CommandContexts<'_>, + arg: Option<&str>, +) -> CommandResult { + let mut parts = contexts.into_parts(); + let Some(lifecycle) = parts.lifecycle.as_deref_mut() else { + return CommandResult::error( + "Command capability unavailable: session_lifecycle".to_string(), + ); + }; + save_portable(lifecycle, arg) +} - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - super::session::save(app, arg) +pub(in crate::commands) fn save_portable( + lifecycle: &mut dyn CommandSessionLifecycleContext, + arg: Option<&str>, +) -> CommandResult { + let explicit = arg + .map(str::trim) + .filter(|p| !p.is_empty()) + .map(str::to_string); + match lifecycle.save_session(explicit) { + Ok(receipt) => CommandResult::message(format!( + "Session saved to {} (ID: {})", + receipt.display_path, receipt.truncated_id + )), + Err(error) => CommandResult::error(error), } } diff --git a/crates/tui/src/commands/groups/session/session.rs b/crates/tui/src/commands/groups/session/session.rs index e7350af888..46ac63bfe7 100644 --- a/crates/tui/src/commands/groups/session/session.rs +++ b/crates/tui/src/commands/groups/session/session.rs @@ -1,1395 +1,11 @@ -//! Session commands: save, load, compact, export - -use std::path::PathBuf; - -use crate::session_manager::{ - create_saved_session_with_id_and_mode, create_saved_session_with_mode, -}; -use crate::tui::app::{App, AppAction}; -use crate::tui::session_picker::SessionPickerView; - -use super::CommandResult; - -/// Save session to file. -/// -/// When an explicit path is given, the session is exported there -/// (user-visible explicit export). Without a path, v0.8.44 saves -/// into the managed session directory (`~/.codewhale/sessions` -/// or legacy `~/.deepseek/sessions`) so repo-local `session_*.json` -/// artifacts are no longer created by default. -pub fn save(app: &mut App, path: Option<&str>) -> CommandResult { - let explicit_save_path = path.map(PathBuf::from); - - let messages = app.api_messages.clone(); - let mut session = create_saved_session_with_mode( - &messages, - &app.model, - &app.workspace, - u64::from(app.session.total_tokens), - app.system_prompt.as_ref(), - Some(app.mode.label()), - ); - session - .metadata - .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence()); - app.sync_cost_to_metadata(&mut session.metadata); - session.context_references = app.session_context_references.clone(); - session.artifacts = app.session_artifacts.clone(); - session.work_state = match app.work_state_snapshot() { - Ok(state) => state, - Err(err) => return CommandResult::error(format!("Failed to snapshot Work state: {err}")), - }; - session.last_auto_route = app.auto_route_for_persistence(); - let save_path = explicit_save_path.unwrap_or_else(|| { - let dir = crate::session_manager::default_sessions_dir() - .unwrap_or_else(|_| app.workspace.clone()); - dir.join(format!("{}.json", session.metadata.id)) - }); - - let sessions_dir = save_path - .parent() - .filter(|p| !p.as_os_str().is_empty()) - .map_or_else(|| app.workspace.clone(), std::path::Path::to_path_buf); - - match std::fs::create_dir_all(&sessions_dir) { - Ok(()) => { - let json = match serde_json::to_string_pretty(&session) { - Ok(j) => j, - Err(e) => return CommandResult::error(format!("Failed to serialize session: {e}")), - }; - match crate::utils::write_atomic(&save_path, json.as_bytes()) { - Ok(()) => { - app.current_session_id = Some(session.metadata.id.clone()); - app.current_session_metadata = Some(session.metadata.clone()); - app.session_title = Some(session.metadata.title.clone()); - if let Err(err) = app.publish_pending_work_state() { - return CommandResult::error(format!( - "Session saved, but Work views were not published: {err}" - )); - } - CommandResult::message(format!( - "Session saved to {} (ID: {})", - save_path.display(), - crate::session_manager::truncate_id(&session.metadata.id) - )) - } - Err(e) => CommandResult::error(format!("Failed to save session: {e}")), - } - } - Err(e) => CommandResult::error(format!("Failed to create directory: {e}")), - } -} - -/// Fork a specific session by id/prefix into a new sibling session and switch to it. -/// This implements `/fork ` for picker-based forking (#576). -pub fn fork_from_session(app: &mut App, session_id_or_prefix: &str) -> CommandResult { - if app.session_transition_blocked() { - return CommandResult::error( - "Cannot fork a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first.", - ); - } - let manager = match crate::session_manager::SessionManager::default_location() { - Ok(m) => m, - Err(err) => { - return CommandResult::error(format!("could not open sessions directory: {err}")); - } - }; - let source = manager - .load_session(session_id_or_prefix) - .or_else(|_| manager.load_session_by_prefix(session_id_or_prefix)); - let mut source_session = match source { - Ok(s) => s, - Err(e) => { - return CommandResult::error(format!( - "could not load session '{}': {e}", - session_id_or_prefix - )); - } - }; - source_session.ensure_journal(); - let journal = source_session.journal.clone().unwrap_or_else(|| { - crate::session_tree::SessionJournal::from_messages( - source_session.messages.clone(), - source_session.metadata.spawn_depth, - ) - }); - let forked_journal = journal.fork_from(None).unwrap_or_else(|_| { - crate::session_tree::SessionJournal::with_spawn_depth( - source_session.metadata.spawn_depth.saturating_add(1), - ) - }); - let messages = forked_journal.to_messages(); - let mut forked = crate::session_manager::create_saved_session_with_id_and_mode( - uuid::Uuid::new_v4().to_string(), - &messages, - &source_session.metadata.model, - &app.workspace, - source_session.metadata.total_tokens, - source_session - .system_prompt - .as_ref() - .map(|s| crate::models::SystemPrompt::Text(s.clone())) - .as_ref(), - source_session.metadata.mode.as_deref(), - ); - forked.journal = Some(forked_journal); - forked.leaf_id = forked.journal.as_ref().and_then(|j| j.leaf_id.clone()); - forked.messages = messages; - forked.metadata.spawn_depth = forked.journal.as_ref().map(|j| j.spawn_depth).unwrap_or(0); - forked.metadata.parent_session_id = Some(source_session.metadata.id.clone()); - forked.metadata.forked_from_message_count = Some(source_session.metadata.message_count); - forked.metadata.set_model_provider_route( - source_session.metadata.model_provider.as_str(), - source_session.metadata.model_provider_id.as_deref(), - ); - forked.metadata.copy_cost_from(&source_session.metadata); - forked.context_references = source_session.context_references.clone(); - forked.artifacts = source_session.artifacts.clone(); - forked.work_state = source_session.work_state.clone(); - forked.last_auto_route = source_session.last_auto_route.clone(); - if let Err(err) = manager.save_session(&forked) { - return CommandResult::error(format!("Failed to save forked session: {err}")); - } - app.current_session_id = Some(forked.metadata.id.clone()); - app.current_session_metadata = Some(forked.metadata.clone()); - app.session_title = Some(forked.metadata.title.clone()); - // A fork starts as its own session: no inherited tab/window title. - app.window_title = None; - let parent_label = crate::session_manager::truncate_id(&source_session.metadata.id).to_string(); - let fork_label = crate::session_manager::truncate_id(&forked.metadata.id).to_string(); - CommandResult::with_message_and_action( - format!( - "Forked session {parent_label} -> {fork_label} (spawn_depth {})", - forked.metadata.spawn_depth - ), - AppAction::SyncSession { - session_id: Some(forked.metadata.id.clone()), - messages: forked.messages.clone(), - system_prompt: forked - .system_prompt - .as_ref() - .map(|s| crate::models::SystemPrompt::Text(s.clone())), - model: forked.metadata.model.clone(), - workspace: app.workspace.clone(), - mode: app.mode, - }, - ) -} - -/// Fork the active conversation into a new saved sibling session and switch to it. -pub fn fork(app: &mut App) -> CommandResult { - if app.session_transition_blocked() { - return CommandResult::error( - "Cannot fork a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first.", - ); - } - if app.api_messages.is_empty() { - return CommandResult::error("Nothing to fork. Send or load a message first."); - } - - let manager = match crate::session_manager::SessionManager::default_location() { - Ok(manager) => manager, - Err(err) => { - return CommandResult::error(format!("could not open sessions directory: {err}")); - } - }; - - let parent_id = app - .current_session_id - .clone() - .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); - let mut parent = create_saved_session_with_id_and_mode( - parent_id, - &app.api_messages, - &app.model, - &app.workspace, - u64::from(app.session.total_tokens), - app.system_prompt.as_ref(), - Some(app.mode.label()), - ); - parent - .metadata - .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence()); - if let Some(cached) = app - .current_session_metadata - .as_ref() - .filter(|metadata| metadata.id == parent.metadata.id) - { - parent.metadata.created_at = cached.created_at; - parent.metadata.title.clone_from(&cached.title); - parent - .metadata - .parent_session_id - .clone_from(&cached.parent_session_id); - parent.metadata.forked_from_message_count = cached.forked_from_message_count; - } - app.sync_cost_to_metadata(&mut parent.metadata); - parent.context_references = app.session_context_references.clone(); - parent.artifacts = app.session_artifacts.clone(); - let work_state = match app.work_state_snapshot() { - Ok(state) => state, - Err(err) => return CommandResult::error(format!("Failed to snapshot Work state: {err}")), - }; - parent.work_state = work_state.clone(); - parent.last_auto_route = app.auto_route_for_persistence(); - - if let Err(err) = manager.save_session(&parent) { - return CommandResult::error(format!("Failed to save parent session: {err}")); - } - - let mut forked = create_saved_session_with_mode( - &app.api_messages, - &app.model, - &app.workspace, - u64::from(app.session.total_tokens), - app.system_prompt.as_ref(), - Some(app.mode.label()), - ); - forked - .metadata - .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence()); - forked.metadata.copy_cost_from(&parent.metadata); - forked.metadata.spawn_depth = parent.metadata.spawn_depth.saturating_add(1); - // Ensure journal for both sessions: parent already has one from factory, bump forked's journal depth - if let Some(j) = forked.journal.as_mut() { - j.spawn_depth = forked.metadata.spawn_depth; - } - if let Some(j) = parent.journal.as_mut() { - j.spawn_depth = parent.metadata.spawn_depth; - } - forked.metadata.mark_forked_from(&parent.metadata); - forked.context_references = app.session_context_references.clone(); - forked.artifacts = app.session_artifacts.clone(); - forked.work_state = work_state; - forked.last_auto_route = app.auto_route_for_persistence(); - - if let Err(err) = manager.save_session(&forked) { - return CommandResult::error(format!("Failed to save forked session: {err}")); - } - if let Err(err) = app.publish_pending_work_state() { - return CommandResult::error(format!( - "Sessions saved, but Work views were not published: {err}" - )); - } - - app.current_session_id = Some(forked.metadata.id.clone()); - app.current_session_metadata = Some(forked.metadata.clone()); - app.session_title = Some(forked.metadata.title.clone()); - // A fork starts as its own session: no inherited tab/window title. - app.window_title = None; - let fork_id = forked.metadata.id.clone(); - let parent_label = crate::session_manager::truncate_id(&parent.metadata.id).to_string(); - let fork_label = crate::session_manager::truncate_id(&fork_id).to_string(); - - CommandResult::with_message_and_action( - format!("Forked session {parent_label} -> {fork_label}"), - AppAction::SyncSession { - session_id: Some(fork_id), - messages: app.api_messages.clone(), - system_prompt: app.system_prompt.clone(), - model: app.model.clone(), - workspace: app.workspace.clone(), - mode: app.mode, - }, - ) -} - -/// Start a fresh saved session from the current TUI state. -pub fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult { - let force = match arg.map(str::trim).filter(|s| !s.is_empty()) { - None => false, - Some("--force" | "force") => true, - Some(other) => { - return CommandResult::error(format!( - "Usage: /new [--force]\n\nUnknown argument: {other}" - )); - } - }; - - if app.session_transition_blocked() { - return CommandResult::error( - "Cannot start a new session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work. `/new --force` only discards draft or queued input.", - ); - } - - if !force { - let blockers = new_session_blockers(app); - if !blockers.is_empty() { - return CommandResult::error(format!( - "Cannot start a new session while {}. Run `/new --force` to discard pending work and start a fresh session.", - blockers.join(", ") - )); - } - } - - let new_id = uuid::Uuid::new_v4().to_string(); - if !super::super::core::reset_conversation_state(app) { - return CommandResult::error( - "Could not start a new session because Work state is busy; retry in a moment.", - ); - } - app.clear_input(); - app.session_artifacts.clear(); - app.session_context_references.clear(); - app.tool_evidence.clear(); - app.current_session_id = Some(new_id.clone()); - app.current_session_metadata = None; - app.session_title = Some(crate::session_manager::DEFAULT_SESSION_TITLE.to_string()); - // A new session has no tab/window title override yet; the `title` - // config default still applies. - app.window_title = None; - app.scroll_to_bottom(); - - CommandResult::with_message_and_action( - format!( - "Started new session {} (New Session). Previous sessions remain available via /resume.", - crate::session_manager::truncate_id(&new_id) - ), - AppAction::SyncSession { - session_id: Some(new_id), - messages: Vec::new(), - system_prompt: None, - model: app.model.clone(), - workspace: app.workspace.clone(), - mode: app.mode, - }, - ) -} - -fn new_session_blockers(app: &App) -> Vec<&'static str> { - let mut blockers = Vec::new(); - if !app.input.trim().is_empty() { - blockers.push("the composer has unsent text"); - } - if !app.queued_messages.is_empty() || app.queued_draft.is_some() { - blockers.push("queued messages are pending"); - } - blockers -} - -/// Load session from file -pub fn load(app: &mut App, path: Option<&str>) -> CommandResult { - if app.session_transition_blocked() { - return CommandResult::error( - "Cannot load a session while runtime work is active. Wait for the current turn, maintenance, and background tasks to finish, or cancel that specific work first.", - ); - } - let load_path = if let Some(p) = path { - if p.contains('/') || p.contains('\\') { - PathBuf::from(p) - } else { - app.workspace.join(p) - } - } else { - return CommandResult::error("Usage: /load "); - }; - - let content = match std::fs::read_to_string(&load_path) { - Ok(c) => c, - Err(e) => { - return CommandResult::error(format!("Failed to read session file: {e}")); - } - }; - - let _session: crate::session_manager::SavedSession = match serde_json::from_str(&content) { - Ok(s) => s, - Err(e) => { - return CommandResult::error(format!("Failed to parse session file: {e}")); - } - }; - - // The command layer only validates the file shape. The event loop reloads - // Config once and applies the session plus route atomically before it - // rebuilds or syncs the engine. - // Success is reported only after the event loop re-reads live Config and - // atomically applies the session route. Emitting it here would leave a - // false receipt in the current transcript if that final validation fails. - CommandResult::action(crate::tui::app::AppAction::LoadSession(load_path)) -} - -/// Trigger context compaction. An optional argument becomes the summary -/// focus (`/compact the auth refactor`), forwarded into the successor brief. -pub fn compact(_app: &mut App, arg: Option<&str>) -> CommandResult { - let focus = arg - .map(str::trim) - .filter(|focus| !focus.is_empty()) - .map(str::to_string); - let receipt = match focus.as_deref() { - Some(focus) => format!("Context compaction triggered (focus: {focus})..."), - None => "Context compaction triggered...".to_string(), - }; - CommandResult::with_message_and_action(receipt, AppAction::CompactContext { focus }) -} - -/// Trigger agent-driven context purging. -pub fn purge(_app: &mut App) -> CommandResult { - CommandResult::with_message_and_action( - "Agent context purge triggered...".to_string(), - AppAction::PurgeContext, - ) -} - -/// Open the session picker UI, or run a sub-action like -/// `prune ` for housekeeping (#406 phase-1.5). -pub fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult { - let trimmed = arg.unwrap_or("").trim(); - if trimmed.is_empty() { - app.view_stack - .push(SessionPickerView::new(&app.workspace, app.ui_locale)); - return CommandResult::ok(); - } - - let mut parts = trimmed.split_whitespace(); - let action = parts.next().unwrap_or("").to_ascii_lowercase(); - match action.as_str() { - "prune" => prune(app, parts.next()), - "show" | "list" | "picker" => { - app.view_stack - .push(SessionPickerView::new(&app.workspace, app.ui_locale)); - CommandResult::ok() - } - // `open` is what the sidebar Sessions rail dispatches (#2934): it - // opens the existing picker preselected on a session rather than - // resuming inline, so resume keeps its single implementation. - "open" => open_session(app, parts.next()), - "archive" => set_archived(app, parts.next(), true), - "unarchive" | "restore" => set_archived(app, parts.next(), false), - _ => CommandResult::error(format!( - "unknown subcommand `{action}`. usage: /sessions [show|open |archive |unarchive |prune ]" - )), - } -} - -/// Open the session picker with `session_id` preselected. -fn open_session(app: &mut App, session_id: Option<&str>) -> CommandResult { - let Some(session_id) = session_id.map(str::trim).filter(|id| !id.is_empty()) else { - return CommandResult::error("usage: /sessions open "); - }; - app.view_stack.push(SessionPickerView::new_selecting( - &app.workspace, - app.ui_locale, - session_id, - )); - CommandResult::ok() -} - -/// Archive or restore a saved session. -/// -/// Routes through [`crate::session_manager::SessionManager::set_session_archived`] -/// — the same writer the picker and `PATCH /v1/sessions/{id}` use — so all -/// three surfaces produce one durable lifecycle state. -fn set_archived(app: &mut App, session_id: Option<&str>, archived: bool) -> CommandResult { - let verb = if archived { "archive" } else { "unarchive" }; - let Some(session_id) = session_id.map(str::trim).filter(|id| !id.is_empty()) else { - return CommandResult::error(format!("usage: /sessions {verb} ")); - }; - let manager = match crate::session_manager::SessionManager::default_location() { - Ok(manager) => manager, - Err(err) => { - return CommandResult::error(format!("could not open sessions directory: {err}")); - } - }; - // `Owner`: this is the in-process interactive surface, and the block below - // updates the live cached metadata in the same step. - match manager.set_session_archived( - session_id, - archived, - crate::session_manager::SessionMutator::Owner, - ) { - Ok(metadata) => { - // Atomic with the write, from the app's point of view: nothing can - // run between the manager call and this update, so the next - // autosave already sees the new lifecycle state. - if let Some(cached) = app.current_session_metadata.as_mut() - && cached.id == metadata.id - { - cached.archived = metadata.archived; - } - CommandResult::message(format!( - "{} session {} ({})", - if archived { "Archived" } else { "Restored" }, - crate::session_manager::truncate_id(&metadata.id), - metadata.title - )) - } - Err(err) => CommandResult::error(format!("{verb} failed: {err}")), - } -} - -/// Prune persisted sessions older than `` from -/// `~/.deepseek/sessions/`. Wraps -/// [`crate::session_manager::SessionManager::prune_sessions_older_than`] -/// so users can run a safe cleanup without leaving the TUI. Skips -/// the checkpoint subdirectory (the helper guarantees that already). -fn prune(app: &mut App, days_arg: Option<&str>) -> CommandResult { - let days_str = match days_arg { - Some(s) => s, - None => { - return CommandResult::error( - "usage: /sessions prune (e.g. `/sessions prune 30` to drop sessions older than 30 days)", - ); - } - }; - let days: u64 = match days_str.parse() { - Ok(n) if n > 0 => n, - _ => { - return CommandResult::error(format!( - "expected a positive integer number of days, got `{days_str}`" - )); - } - }; - - let manager = match crate::session_manager::SessionManager::default_location() { - Ok(m) => m, - Err(err) => { - return CommandResult::error(format!("could not open sessions directory: {err}")); - } - }; - - let max_age = std::time::Duration::from_secs(days.saturating_mul(24 * 60 * 60)); - // Never prune the active session, even if its timestamp is stale (a - // just-resumed session isn't re-saved until its first post-resume write). - let keep = app.current_session_id.as_deref(); - match manager.prune_sessions_older_than_keeping(max_age, keep) { - Ok(0) => CommandResult::message(format!("no sessions older than {days}d to prune")), - Ok(n) => CommandResult::message(format!( - "pruned {n} session{} older than {days}d", - if n == 1 { "" } else { "s" } - )), - Err(err) => CommandResult::error(format!("prune failed: {err}")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::models::Role; - use crate::test_support::EnvVarGuard; - use crate::tui::app::{App, AppMode, ReasoningEffort, TuiOptions, TurnCacheRecord}; - use crate::tui::history::HistoryCell; - use std::time::Instant; - use tempfile::TempDir; - - fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { - let options = TuiOptions { - skills_dir: tmpdir.path().join("skills"), - memory_path: tmpdir.path().join("memory.md"), - notes_path: tmpdir.path().join("notes.txt"), - mcp_config_path: tmpdir.path().join("mcp.json"), - ..crate::test_support::test_tui_options(tmpdir.path()) - }; - App::new(options, &Config::default()) - } - - #[test] - fn test_save_creates_file_and_sets_session_id() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let save_path = tmpdir.path().join("test_session.json"); - - let result = save(&mut app, Some(save_path.to_str().unwrap())); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Session saved to")); - assert!(msg.contains("ID:")); - assert!(app.current_session_id.is_some()); - assert!(save_path.exists()); - } - - #[test] - fn save_preserves_artifact_registry() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let save_path = tmpdir.path().join("artifact_session.json"); - app.session_artifacts - .push(crate::artifacts::ArtifactRecord { - id: "art_call_big".to_string(), - kind: crate::artifacts::ArtifactKind::ToolOutput, - session_id: "artifact-session".to_string(), - tool_call_id: "call-big".to_string(), - tool_name: "exec_shell".to_string(), - created_at: chrono::Utc::now(), - byte_size: 512_000, - preview: "cargo test output".to_string(), - storage_path: tmpdir.path().join("call-big.txt"), - }); - - let result = save(&mut app, Some(save_path.to_str().unwrap())); - - assert!(!result.is_error); - let saved: crate::session_manager::SavedSession = - serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap(); - assert_eq!(saved.artifacts, app.session_artifacts); - } - - #[test] - fn save_preserves_latest_auto_route_receipt() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let save_path = tmpdir.path().join("auto_route_session.json"); - let receipt = crate::model_routing::AutoRouteReceipt { - tier: crate::model_routing::AutoRouteTier::Fast, - pair: crate::model_routing::AutoRoutePair { - strong: crate::config::ZAI_GLM_5_2_MODEL.to_string(), - fast: Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()), - }, - scope: crate::model_routing::AutoRouteScope::ResolvedProvider, - data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic, - reason: crate::model_routing::AutoRouteReason::LocalHeuristic( - crate::model_routing::AutoRouteHeuristicReason::ShortRequest, - ), - }; - app.set_model_selection("auto".to_string()); - app.last_effective_provider = Some(crate::config::ApiProvider::Zai); - app.last_effective_provider_identity = Some("zai".to_string()); - app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()); - app.last_auto_route_receipt = Some(receipt.clone()); - app.last_effective_reasoning_effort = - Some(crate::tui::app::EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable); - - let result = save(&mut app, Some(save_path.to_str().unwrap())); - - assert!(!result.is_error); - let saved: crate::session_manager::SavedSession = - serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap(); - let route = saved.last_auto_route.expect("latest Auto route"); - assert_eq!(route.provider, crate::config::ApiProvider::Zai); - assert_eq!(route.provider_identity, "zai"); - assert_eq!(route.model, crate::config::ZAI_GLM_5_TURBO_MODEL); - assert_eq!(route.receipt, receipt); - assert_eq!( - route.effective_reasoning_effort, - Some(crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable) - ); - } - - #[test] - fn fork_saves_parent_and_switches_to_child_session() { - let tmpdir = TempDir::new().unwrap(); - let _lock = crate::test_support::lock_test_env(); - let home = tmpdir.path().join("home"); - std::fs::create_dir_all(&home).unwrap(); - let home_guard = EnvVarGuard::set("HOME", &home); - let previous_home = home_guard.previous(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.set_provider_identity(crate::config::ApiProvider::Custom, "lm-studio"); - app.current_session_id = Some("parent-session".to_string()); - let mut cached_parent = create_saved_session_with_id_and_mode( - "parent-session".to_string(), - &[], - &app.model, - &app.workspace, - 0, - None, - Some(app.mode.label()), - ) - .metadata; - cached_parent.title = "Custom Parent".to_string(); - cached_parent.created_at = "2026-01-02T03:04:05Z" - .parse() - .expect("fixed parent timestamp"); - app.current_session_metadata = Some(cached_parent.clone()); - app.session_title = Some(cached_parent.title.clone()); - app.api_messages.push(crate::models::Message { - role: Role::User, - content: vec![crate::models::ContentBlock::Text { - text: "try another path".to_string(), - cache_control: None, - }], - }); - { - let mut todos = app.todos.try_lock().expect("todos lock"); - todos.add( - "preserve fork Work".to_string(), - crate::tools::todo::TodoStatus::InProgress, - ); - } - { - let mut plan = app.plan_state.try_lock().expect("plan lock"); - plan.update(crate::tools::plan::UpdatePlanArgs { - objective: Some("Fork without Work drift".to_string()), - ..crate::tools::plan::UpdatePlanArgs::default() - }); - } - app.cycle_effort(); - let expected_work = app - .work_state_snapshot() - .expect("Work snapshot") - .expect("graph-backed Work state"); - assert!( - expected_work.graph.is_some(), - "fork fixture must use a graph" - ); - - let result = fork(&mut app); - - assert!(!result.is_error, "{:?}", result.message); - let new_id = app.current_session_id.clone().expect("fork session id"); - assert_ne!(new_id, "parent-session"); - assert!(result.message.as_deref().unwrap_or("").contains("Forked")); - assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); - - let manager = crate::session_manager::SessionManager::default_location().unwrap(); - let parent = manager - .load_session("parent-session") - .expect("parent saved"); - let child = manager.load_session(&new_id).expect("child saved"); - assert_eq!(parent.messages.len(), 1); - assert_eq!(parent.metadata.model_provider, "custom"); - assert_eq!( - parent.metadata.model_provider_id.as_deref(), - Some("lm-studio") - ); - assert_eq!(parent.metadata.title, cached_parent.title); - assert_eq!(parent.metadata.created_at, cached_parent.created_at); - assert_eq!( - child.metadata.parent_session_id.as_deref(), - Some("parent-session") - ); - assert_eq!(child.metadata.forked_from_message_count, Some(1)); - assert_eq!(child.metadata.model_provider, "custom"); - assert_eq!( - child.metadata.model_provider_id.as_deref(), - Some("lm-studio") - ); - assert_eq!(parent.work_state.as_ref(), Some(&expected_work)); - assert_eq!(child.work_state.as_ref(), Some(&expected_work)); - let cached_child = app - .current_session_metadata - .as_ref() - .expect("child metadata cached"); - assert_eq!(cached_child.id, child.metadata.id); - assert_eq!(cached_child.title, child.metadata.title); - assert_eq!(cached_child.created_at, child.metadata.created_at); - assert_eq!( - cached_child.parent_session_id, - child.metadata.parent_session_id - ); - assert_eq!( - app.session_title.as_deref(), - Some(child.metadata.title.as_str()) - ); - drop(home_guard); - assert_eq!(std::env::var_os("HOME"), previous_home); - } - - #[test] - fn fork_rejects_active_runtime_without_switching_sessions() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("parent-session".to_string()); - app.api_messages.push(crate::models::Message { - role: Role::User, - content: vec![crate::models::ContentBlock::Text { - text: "still running".to_string(), - cache_control: None, - }], - }); - app.is_loading = true; - - let result = fork(&mut app); - - assert!(result.is_error); - assert!(result.action.is_none()); - assert_eq!(app.current_session_id.as_deref(), Some("parent-session")); - assert_eq!(app.api_messages.len(), 1); - } - - #[test] - fn new_session_from_resumed_state_creates_distinct_empty_session() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("old-session".to_string()); - app.session_title = Some("Old Session".to_string()); - app.api_messages.push(crate::models::Message { - role: Role::User, - content: vec![crate::models::ContentBlock::Text { - text: "continue this thread".to_string(), - cache_control: None, - }], - }); - app.add_message(HistoryCell::System { - content: "old transcript".to_string(), - }); - app.system_prompt = Some(crate::models::SystemPrompt::Text("old prompt".to_string())); - app.session.total_tokens = 123; - app.session.session_cost = 1.25; - - let result = new_session(&mut app, None); - - assert!(!result.is_error, "{:?}", result.message); - let new_id = app.current_session_id.clone().expect("new session id"); - assert_ne!(new_id, "old-session"); - assert_eq!(app.session_title.as_deref(), Some("New Session")); - assert!(app.api_messages.is_empty()); - assert!(app.history.is_empty()); - assert!(app.system_prompt.is_none()); - assert_eq!(app.session.total_tokens, 0); - assert_eq!(app.session.session_cost, 0.0); - assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains("/resume") - ); - match result.action { - Some(AppAction::SyncSession { - session_id, - messages, - system_prompt, - .. - }) => { - assert_eq!(session_id.as_deref(), Some(new_id.as_str())); - assert!(messages.is_empty()); - assert!(system_prompt.is_none()); - } - other => panic!("expected SyncSession action, got {other:?}"), - } - } - - #[test] - fn new_session_blocks_unsent_input_without_force() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("old-session".to_string()); - app.input = "draft text".to_string(); - - let result = new_session(&mut app, None); - - assert!(result.is_error); - assert_eq!(app.current_session_id.as_deref(), Some("old-session")); - assert_eq!(app.input, "draft text"); - assert!(result.action.is_none()); - assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains("/new --force") - ); - } - - #[test] - fn new_session_force_discards_unsent_input() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("old-session".to_string()); - app.input = "draft text".to_string(); - - let result = new_session(&mut app, Some("--force")); - - assert!(!result.is_error, "{:?}", result.message); - assert_ne!(app.current_session_id.as_deref(), Some("old-session")); - assert!(app.input.is_empty()); - assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); - } - - #[test] - fn new_session_blocks_in_flight_turn_without_force() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("old-session".to_string()); - app.is_loading = true; - - let result = new_session(&mut app, None); - - assert!(result.is_error); - assert_eq!(app.current_session_id.as_deref(), Some("old-session")); - assert!(result.action.is_none()); - } - - #[test] - fn new_session_force_cannot_detach_an_in_flight_turn() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("old-session".to_string()); - app.api_messages.push(crate::models::Message { - role: Role::User, - content: vec![], - }); - app.is_loading = true; - app.runtime_turn_status = Some("in_progress".to_string()); - - let result = new_session(&mut app, Some("--force")); - - assert!(result.is_error); - assert!(result.action.is_none()); - assert_eq!(app.current_session_id.as_deref(), Some("old-session")); - assert_eq!(app.api_messages.len(), 1); - assert!( - result - .message - .as_deref() - .is_some_and(|message| message.contains("only discards draft or queued input")) - ); - } - - #[test] - fn load_rejects_an_active_runtime_before_reading_or_mutating() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.current_session_id = Some("old-session".to_string()); - app.api_messages.push(crate::models::Message { - role: Role::User, - content: vec![], - }); - app.task_panel.push(crate::tui::app::TaskPanelEntry { - id: "queued-late-producer".to_string(), - status: "queued".to_string(), - prompt_summary: "queued".to_string(), - duration_ms: None, - kind: crate::tui::app::TaskPanelEntryKind::Background, - stale: false, - elapsed_since_output_ms: None, - owner_agent_id: None, - owner_agent_name: None, - current_tool: None, - role: None, - files_touched: 0, - }); - - let result = load(&mut app, Some("does-not-exist.json")); - - assert!(result.is_error); - assert!(result.action.is_none()); - assert_eq!(app.current_session_id.as_deref(), Some("old-session")); - assert_eq!(app.api_messages.len(), 1); - assert!( - result - .message - .as_deref() - .is_some_and(|message| message.contains("runtime work is active")) - ); - } - - #[test] - fn test_save_with_default_path_uses_managed_sessions_dir() { - let tmpdir = TempDir::new().unwrap(); - let _lock = crate::test_support::lock_test_env(); - // Set CODEWHALE_HOME so the managed sessions directory lands inside the - // temp dir rather than the real user home. Pre-create the directory so - // resolve_state_dir picks it up instead of falling back to legacy. - let home = tmpdir.path().join("home"); - let sessions_dir = home.join("sessions"); - std::fs::create_dir_all(&sessions_dir).unwrap(); - let codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home); - let previous_codewhale_home = codewhale_home.previous(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = save(&mut app, None); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - // Give it a moment to ensure file is written - std::thread::sleep(std::time::Duration::from_millis(10)); - let entries: Vec<_> = if sessions_dir.exists() { - std::fs::read_dir(&sessions_dir) - .unwrap() - .filter_map(|e| e.ok()) - .filter(|e| e.file_name().to_string_lossy().ends_with(".json")) - .collect() - } else { - Vec::new() - }; - drop(codewhale_home); - // Session should be saved to the managed dir, not the workspace root. - assert!( - !entries.is_empty(), - "expected session file in {sessions_dir:?}, got none; msg: {msg}" - ); - let session_id = app - .current_session_id - .as_deref() - .expect("current session id"); - assert!(sessions_dir.join(format!("{session_id}.json")).exists()); - assert_eq!(std::env::var_os("CODEWHALE_HOME"), previous_codewhale_home); - } - - #[test] - fn test_save_serialization_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - // This should work normally since SavedSession is serializable - // Testing error path would require mocking, which is complex - let save_path = tmpdir.path().join("test.json"); - let result = save(&mut app, Some(save_path.to_str().unwrap())); - assert!(result.message.is_some()); - } - - #[test] - fn test_load_without_path_returns_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = load(&mut app, None); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Usage: /load")); - } - - #[test] - fn test_load_nonexistent_file_returns_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = load(&mut app, Some("nonexistent.json")); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Failed to read")); - } - - #[test] - fn test_load_invalid_json_returns_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let bad_file = tmpdir.path().join("bad.json"); - std::fs::write(&bad_file, "not valid json").unwrap(); - let result = load(&mut app, Some(bad_file.to_str().unwrap())); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Failed to parse")); - } - - #[test] - fn test_load_valid_session_defers_state_restore_to_event_loop() { - let tmpdir = TempDir::new().unwrap(); - let mut app1 = create_test_app_with_tmpdir(&tmpdir); - // Set up some state to save - app1.api_messages.push(crate::models::Message { - role: Role::User, - content: vec![crate::models::ContentBlock::Text { - text: "Hello".to_string(), - cache_control: None, - }], - }); - app1.session.total_tokens = 500; - app1.set_mode(AppMode::Plan); - let save_path = tmpdir.path().join("test.json"); - save(&mut app1, Some(save_path.to_str().unwrap())); - - // Create new app and load - let mut app2 = create_test_app_with_tmpdir(&tmpdir); - app2.system_prompt = Some(crate::models::SystemPrompt::Text( - "stale prompt from prior session".to_string(), - )); - app2.session_context_references - .push(crate::session_manager::SessionContextReference { - message_index: 0, - reference: crate::tui::file_mention::ContextReference { - kind: crate::tui::file_mention::ContextReferenceKind::File, - source: crate::tui::file_mention::ContextReferenceSource::AtMention, - badge: "file".to_string(), - label: "stale.rs".to_string(), - target: tmpdir.path().join("stale.rs").display().to_string(), - included: true, - expanded: true, - detail: None, - }, - }); - let result = load(&mut app2, Some(save_path.to_str().unwrap())); - assert_eq!(result.message, None); - assert!(app2.api_messages.is_empty()); - assert_eq!(app2.session.total_tokens, 0); - assert!(app2.current_session_id.is_none()); - assert!(app2.system_prompt.is_some()); - assert_eq!(app2.session_context_references.len(), 1); - assert!(matches!( - result.action, - Some(AppAction::LoadSession(path)) if path == save_path - )); - } - - #[test] - fn explicit_save_persists_work_state_and_load_defers_application() { - let tmpdir = TempDir::new().unwrap(); - let mut saved_app = create_test_app_with_tmpdir(&tmpdir); - { - let mut todos = saved_app.todos.try_lock().expect("todos lock"); - todos.add( - "persist me".to_string(), - crate::tools::todo::TodoStatus::InProgress, - ); - } - { - let mut plan = saved_app.plan_state.try_lock().expect("plan lock"); - plan.update(crate::tools::plan::UpdatePlanArgs { - objective: Some("Resume exactly".to_string()), - ..crate::tools::plan::UpdatePlanArgs::default() - }); - } - let expected = saved_app.work_state_snapshot().expect("snapshot"); - let save_path = tmpdir.path().join("work_state.json"); - let saved = save(&mut saved_app, Some(save_path.to_str().unwrap())); - assert!(!saved.is_error, "{:?}", saved.message); - - let mut loaded_app = create_test_app_with_tmpdir(&tmpdir); - let loaded = load(&mut loaded_app, Some(save_path.to_str().unwrap())); - assert!(!loaded.is_error, "{:?}", loaded.message); - assert_eq!(loaded_app.work_state_snapshot().expect("snapshot"), None); - assert!(matches!( - loaded.action, - Some(AppAction::LoadSession(path)) if path == save_path - )); - let saved_session: crate::session_manager::SavedSession = - serde_json::from_str(&std::fs::read_to_string(&save_path).expect("saved session file")) - .expect("saved session JSON"); - assert_eq!(saved_session.work_state, expected); - } - - #[test] - fn new_session_is_all_or_nothing_when_work_state_is_busy() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.api_messages.push(crate::models::Message { - role: Role::User, - content: vec![], - }); - app.current_session_id = Some("current-session".to_string()); - let todos = app.todos.clone(); - let _held = todos.try_lock().expect("hold todos lock"); - - let result = new_session(&mut app, Some("--force")); - - assert!(result.is_error); - assert_eq!(app.api_messages.len(), 1); - assert_eq!(app.current_session_id.as_deref(), Some("current-session")); - assert!(result.action.is_none()); - } - - #[test] - fn load_auto_model_session_defers_model_restore_to_event_loop() { - let tmpdir = TempDir::new().unwrap(); - let mut saved_app = create_test_app_with_tmpdir(&tmpdir); - saved_app.set_model_selection("auto".to_string()); - saved_app.last_effective_model = Some("deepseek-v4-flash".to_string()); - saved_app.last_effective_reasoning_effort = Some( - crate::tui::app::EffectiveReasoningEffort::Tier(ReasoningEffort::Low), - ); - let save_path = tmpdir.path().join("auto_model.json"); - save(&mut saved_app, Some(save_path.to_str().unwrap())); - - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.set_model_selection("deepseek-v4-flash".to_string()); - app.reasoning_effort = ReasoningEffort::High; - let result = load(&mut app, Some(save_path.to_str().unwrap())); - - assert!(!result.is_error); - assert!(!app.auto_model); - assert_eq!(app.model, "deepseek-v4-flash"); - assert_eq!(app.reasoning_effort, ReasoningEffort::High); - assert!(matches!( - result.action, - Some(AppAction::LoadSession(path)) if path == save_path - )); - } - - #[test] - fn load_defers_artifact_registry_restore_to_event_loop() { - let tmpdir = TempDir::new().unwrap(); - let mut saved_app = create_test_app_with_tmpdir(&tmpdir); - saved_app - .session_artifacts - .push(crate::artifacts::ArtifactRecord { - id: "art_call_big".to_string(), - kind: crate::artifacts::ArtifactKind::ToolOutput, - session_id: "artifact-session".to_string(), - tool_call_id: "call-big".to_string(), - tool_name: "exec_shell".to_string(), - created_at: chrono::Utc::now(), - byte_size: 128, - preview: "checking crate".to_string(), - storage_path: tmpdir.path().join("call-big.txt"), - }); - let save_path = tmpdir.path().join("artifact_load.json"); - save(&mut saved_app, Some(save_path.to_str().unwrap())); - - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.session_artifacts - .push(crate::artifacts::ArtifactRecord { - id: "art_stale".to_string(), - kind: crate::artifacts::ArtifactKind::ToolOutput, - session_id: "stale-session".to_string(), - tool_call_id: "stale".to_string(), - tool_name: "exec_shell".to_string(), - created_at: chrono::Utc::now(), - byte_size: 1, - preview: "stale".to_string(), - storage_path: tmpdir.path().join("stale.txt"), - }); - - let result = load(&mut app, Some(save_path.to_str().unwrap())); - - assert!(!result.is_error); - assert_eq!(app.session_artifacts.len(), 1); - assert_eq!(app.session_artifacts[0].id, "art_stale"); - assert!(matches!( - result.action, - Some(AppAction::LoadSession(path)) if path == save_path - )); - } - - #[test] - fn load_defers_telemetry_reset_to_event_loop() { - let tmpdir = TempDir::new().unwrap(); - let mut saved_app = create_test_app_with_tmpdir(&tmpdir); - saved_app.api_messages.push(crate::models::Message { - role: Role::User, - content: vec![crate::models::ContentBlock::Text { - text: "checkpoint".to_string(), - cache_control: None, - }], - }); - saved_app.session.total_tokens = 500; - let save_path = tmpdir.path().join("checkpoint.json"); - save(&mut saved_app, Some(save_path.to_str().unwrap())); - - let mut app = create_test_app_with_tmpdir(&tmpdir); - app.session.session_cost = 1.25; - app.session.session_cost_cny = 9.13; - app.session.subagent_cost = 0.75; - app.session.subagent_cost_cny = 5.48; - app.session - .subagent_usage_sources - .insert(crate::cost_status::usage_source_fingerprint( - "response-test", - )); - app.session.displayed_cost_high_water = 2.0; - app.session.displayed_cost_high_water_cny = 14.61; - app.session.last_prompt_tokens = Some(120); - app.session.last_completion_tokens = Some(35); - app.session.last_prompt_cache_hit_tokens = Some(80); - app.session.last_prompt_cache_miss_tokens = Some(40); - app.session.last_reasoning_replay_tokens = Some(12); - app.push_turn_cache_record(TurnCacheRecord { - provider: None, - provider_identity: None, - model: None, - auto_model: false, - input_tokens: 120, - output_tokens: 35, - cache_hit_tokens: Some(80), - cache_miss_tokens: Some(40), - reasoning_replay_tokens: Some(12), - cache_write_tokens: None, - reasoning_tokens: None, - cost_audit: None, - recorded_at: Instant::now(), - }); - - let result = load(&mut app, Some(save_path.to_str().unwrap())); - - assert_eq!(result.message, None); - assert_eq!(app.session.total_tokens, 0); - assert_eq!(app.session.session_cost, 1.25); - assert_eq!(app.session.session_cost_cny, 9.13); - assert_eq!(app.session.subagent_cost, 0.75); - assert_eq!(app.session.subagent_cost_cny, 5.48); - assert_eq!(app.session.turn_cache_history.len(), 1); - assert!(matches!( - result.action, - Some(AppAction::LoadSession(path)) if path == save_path - )); - } - - #[test] - fn test_compact_toggles_state() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - - let result = compact(&mut app, None); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("compaction") || msg.contains("Compact")); - assert!(matches!( - result.action, - Some(AppAction::CompactContext { focus: None }) - )); - } - - #[test] - fn compact_command_forwards_a_trimmed_focus_argument() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - - let result = compact(&mut app, Some(" the auth refactor ")); - assert!(matches!( - result.action, - Some(AppAction::CompactContext { focus: Some(ref focus) }) if focus == "the auth refactor" - )); - assert!( - result - .message - .as_deref() - .is_some_and(|msg| msg.contains("focus: the auth refactor")), - "{result:?}" - ); - - // Whitespace-only arguments behave like no focus at all. - let blank = compact(&mut app, Some(" ")); - assert!(matches!( - blank.action, - Some(AppAction::CompactContext { focus: None }) - )); - } - - #[test] - fn test_sessions_pushes_picker_view() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let initial_kind = app.view_stack.top_kind(); - - let result = sessions(&mut app, None); - assert_eq!(result.message, None); - assert!(result.action.is_none()); - // View should have changed (session picker should be on top) - assert_ne!(app.view_stack.top_kind(), initial_kind); - } - - #[test] - fn test_sessions_show_subcommand_pushes_picker_view() { - // `/sessions show` and `/sessions list` are explicit aliases - // for the no-arg picker form. Verify they don't fall through - // to the prune branch. - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let initial_kind = app.view_stack.top_kind(); - let result = sessions(&mut app, Some("show")); - assert_eq!(result.message, None); - assert_ne!(app.view_stack.top_kind(), initial_kind); - } - - #[test] - fn test_sessions_prune_requires_days_argument() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = sessions(&mut app, Some("prune")); - assert!(result.is_error); - assert!( - result.message.as_deref().unwrap_or("").contains("usage"), - "expected usage hint: {:?}", - result.message - ); - } - - #[test] - fn test_sessions_prune_rejects_non_positive_days() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - for bad in ["0", "-3", "abc", "3.14"] { - let result = sessions(&mut app, Some(&format!("prune {bad}"))); - assert!(result.is_error, "expected error for `{bad}`"); - } - } - - #[test] - fn test_sessions_unknown_subcommand_errors() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = sessions(&mut app, Some("teleport")); - assert!(result.is_error); - assert!( - result - .message - .as_deref() - .unwrap_or("") - .contains("unknown subcommand"), - "expected unknown-subcommand error: {:?}", - result.message - ); - } -} +//! Shared session lifecycle implementation (FEAT-023). +//! +//! The nine lifecycle commands' concrete host work moved into the +//! `SessionLifecycleAdapter` in `crate::commands::contract` (FEAT-023 Phase 3) +//! and the portable handlers own all parsing/message/action composition +//! (Phase 4). Dispatch switched to the contract registrations in Phase 6, so +//! no lifecycle body remains here. Shared host helpers used by the still +//! legacy session leaves (FEAT-024/025/026 ownership) live beside those leaves; +//! the migration-topology `session::lifecycle` scope keeps this file as the +//! tenth predeclared source file until the root `session` entry is removed by +//! FEAT-026. diff --git a/crates/tui/src/commands/groups/session/sessions.rs b/crates/tui/src/commands/groups/session/sessions.rs index 312c2e9d9f..bd685b2aba 100644 --- a/crates/tui/src/commands/groups/session/sessions.rs +++ b/crates/tui/src/commands/groups/session/sessions.rs @@ -1,26 +1,121 @@ -//! `/sessions` command. - -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::localization::MessageId; -use crate::tui::app::App; +//! `/sessions` command — picker UI or housekeeping sub-actions. use super::CommandResult; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +use codewhale_command_contract::facets::CommandSessionLifecycleContext; +use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{ + CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, +}; + +pub(in crate::commands) struct SessionsCmd; + +// --------------------------------------------------------------------------- +// FEAT-023 Phase 4 (D3/D5/D6): portable contextual registration and handler. +// --------------------------------------------------------------------------- + +pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { name: "sessions", aliases: &[], usage: "/sessions [show|open |archive |unarchive |prune ]", - description_id: MessageId::CmdSessionsDescription, + description_key: "cmd_sessions_description", }; -pub(in crate::commands) struct SessionsCmd; +impl ContractRegisterCommand for SessionsCmd { + fn info() -> &'static ContractInfo { + &CONTRACT_INFO + } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: + codewhale_command_contract::handler::CommandCapabilities::SESSION_LIFECYCLE, + handler: sessions_contextual, + } + } +} + +pub(in crate::commands) fn sessions_contextual( + contexts: CommandContexts<'_>, + arg: Option<&str>, +) -> CommandResult { + let mut parts = contexts.into_parts(); + let Some(lifecycle) = parts.lifecycle.as_deref_mut() else { + return CommandResult::error( + "Command capability unavailable: session_lifecycle".to_string(), + ); + }; + sessions_portable(lifecycle, arg) +} -impl RegisterCommand for SessionsCmd { - fn info() -> &'static CommandInfo { - &COMMAND_INFO +pub(in crate::commands) fn sessions_portable( + lifecycle: &mut dyn CommandSessionLifecycleContext, + arg: Option<&str>, +) -> CommandResult { + let trimmed = arg.unwrap_or("").trim(); + if trimmed.is_empty() { + lifecycle.open_picker(None); + return CommandResult::ok(); } - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - super::session::sessions(app, arg) + let mut parts = trimmed.split_whitespace(); + let action = parts.next().unwrap_or("").to_ascii_lowercase(); + match action.as_str() { + "prune" => { + let days_str = match parts.next() { + Some(s) => s, + None => { + return CommandResult::error( + "usage: /sessions prune (e.g. `/sessions prune 30` to drop sessions older than 30 days)" + .to_string(), + ); + } + }; + let days: u64 = match days_str.parse() { + Ok(n) if n > 0 => n, + _ => { + return CommandResult::error(format!( + "expected a positive integer number of days, got `{days_str}`" + )); + } + }; + match lifecycle.prune_sessions(days) { + Ok(0) => CommandResult::message(format!("no sessions older than {days}d to prune")), + Ok(n) => CommandResult::message(format!( + "pruned {n} session{} older than {days}d", + if n == 1 { "" } else { "s" } + )), + Err(error) => CommandResult::error(error), + } + } + "show" | "list" | "picker" => { + lifecycle.open_picker(None); + CommandResult::ok() + } + "open" => { + let Some(session_id) = parts.next().map(str::trim).filter(|id| !id.is_empty()) else { + return CommandResult::error("usage: /sessions open ".to_string()); + }; + lifecycle.open_picker(Some(session_id.to_string())); + CommandResult::ok() + } + "archive" | "unarchive" | "restore" => { + let archived = action == "archive"; + let verb = if archived { "archive" } else { "unarchive" }; + let Some(session_id) = parts.next().map(str::trim).filter(|id| !id.is_empty()) else { + return CommandResult::error(format!("usage: /sessions {verb} ")); + }; + match lifecycle.set_archived(session_id, archived) { + Ok(receipt) => CommandResult::message(format!( + "{} session {} ({})", + if archived { "Archived" } else { "Restored" }, + receipt.truncated_id, + receipt.title + )), + Err(error) => CommandResult::error(error), + } + } + _ => CommandResult::error(format!( + "unknown subcommand `{action}`. usage: /sessions [show|open |archive |unarchive |prune ]" + )), } } diff --git a/crates/tui/src/commands/groups/session/tree.rs b/crates/tui/src/commands/groups/session/tree.rs index 9c25aa8bca..d22299165d 100644 --- a/crates/tui/src/commands/groups/session/tree.rs +++ b/crates/tui/src/commands/groups/session/tree.rs @@ -1,67 +1,76 @@ +//! `/tree` command — render the session entry journal or linear transcript. + use super::CommandResult; -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::localization::MessageId; -use crate::session_tree::render_tree; -use crate::tui::app::App; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + +use codewhale_command_contract::facets::{CommandSessionLifecycleContext, TreeBodyProjection}; +use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{ + CommandInfo as ContractInfo, RegisterCommand as ContractRegisterCommand, +}; + +pub(in crate::commands) struct TreeCmd; + +// --------------------------------------------------------------------------- +// FEAT-023 Phase 4 (D3/D5/D6): portable contextual registration and handler. +// --------------------------------------------------------------------------- + +pub(in crate::commands) const CONTRACT_INFO: ContractInfo = ContractInfo { name: "tree", aliases: &[], usage: "/tree [interactive]", - description_id: MessageId::CmdTreeDescription, + description_key: "cmd_tree_description", }; -pub(in crate::commands) struct TreeCmd; -impl RegisterCommand for TreeCmd { - fn info() -> &'static CommandInfo { - &COMMAND_INFO + +impl ContractRegisterCommand for TreeCmd { + fn info() -> &'static ContractInfo { + &CONTRACT_INFO } - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - tree(app, arg) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: + codewhale_command_contract::handler::CommandCapabilities::SESSION_LIFECYCLE, + handler: tree_contextual, + } } } -fn tree(app: &mut App, _arg: Option<&str>) -> CommandResult { - let manager = match crate::session_manager::SessionManager::default_location() { - Ok(m) => m, - Err(e) => return CommandResult::error(format!("could not open sessions directory: {e}")), + +pub(in crate::commands) fn tree_contextual( + contexts: CommandContexts<'_>, + arg: Option<&str>, +) -> CommandResult { + let mut parts = contexts.into_parts(); + let Some(lifecycle) = parts.lifecycle.as_deref_mut() else { + return CommandResult::error( + "Command capability unavailable: session_lifecycle".to_string(), + ); }; - if let Some(session_id) = app.current_session_id.clone() { - if let Ok(mut session) = manager.load_session(&session_id) { - session.ensure_journal(); - if let Some(journal) = session.journal.as_ref() { - let rendered = render_tree(journal); - let mut out = rendered; - out.push_str("\nUse `/branch ` to branch (moves leaf only, never rewrites history).\n"); - out.push_str("Use `/fork [session_id]` to fork this session at any node.\n"); - return CommandResult::message(out); - } - } - if app.api_messages.is_empty() { - return CommandResult::message( - "(empty session — no entries yet)\nSend a message first, then `/tree` will show the entry journal.", - ); + tree_portable(lifecycle, arg) +} + +pub(in crate::commands) fn tree_portable( + lifecycle: &mut dyn CommandSessionLifecycleContext, + _arg: Option<&str>, +) -> CommandResult { + match lifecycle.tree_body() { + Ok(TreeBodyProjection::Journal { rendered }) => { + let mut out = rendered; + out.push_str("\nUse `/branch ` to branch (moves leaf only, never rewrites history).\n"); + out.push_str("Use `/fork [session_id]` to fork this session at any node.\n"); + CommandResult::message(out) } - let mut out = String::from("Active branch (linear — journal will be created on save):\n"); - for (i, msg) in app.api_messages.iter().enumerate() { - let snippet: String = msg - .content - .iter() - .filter_map(|b| match b { - crate::models::ContentBlock::Text { text, .. } => Some(text.as_str()), - _ => None, - }) - .collect::>() - .join(" "); - let short: String = snippet.chars().take(60).collect(); - let marker = if i + 1 == app.api_messages.len() { - "*" - } else { - "●" - }; - out.push_str(&format!(" {marker} [{i}] {}: {short}\n", msg.role)); + Ok(TreeBodyProjection::Linear { rendered }) => { + let mut out = rendered; + out.push_str("\nUse `/branch ` with entry id after journal is saved.\n"); + CommandResult::message(out) } - out.push_str("\nUse `/branch ` with entry id after journal is saved.\n"); - return CommandResult::message(out); + Ok(TreeBodyProjection::EmptySession) => CommandResult::message( + "(empty session — no entries yet)\nSend a message first, then `/tree` will show the entry journal." + .to_string(), + ), + Ok(TreeBodyProjection::NoSession) => CommandResult::message( + "No active session. Use `/resume` to pick a session, then `/tree` to see its journal." + .to_string(), + ), + Err(error) => CommandResult::error(error), } - CommandResult::message( - "No active session. Use `/resume` to pick a session, then `/tree` to see its journal.", - ) } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 10698d7f25..2dc52d368b 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -21,6 +21,13 @@ mod epic_dispatch_acceptance; #[path = "epic_discovery_acceptance.rs"] mod epic_discovery_acceptance; +// TUI-hosted session acceptance and persistence regressions deliberately stay +// outside `groups/session`, which FEAT-043 moves to `codewhale-commands`. +#[cfg(all(test, feature = "long-running-tests"))] +mod session_acceptance; +#[cfg(test)] +mod session_lifecycle_regression_tests; + use std::sync::OnceLock; pub use traits::CommandInfo; @@ -2002,6 +2009,16 @@ mod tests { "skill", "review", "restore", + // FEAT-023 session lifecycle slice. + "branch", + "compact", + "fork", + "load", + "new", + "purge", + "save", + "sessions", + "tree", ]; for info in command_infos() { if info.name == "feat015ctx" || MIGRATED_GROUPS.contains(&info.name) { @@ -2634,4 +2651,110 @@ mod tests { ); } } + + // ----------------------------------------------------------------------- + // FEAT-023 Phase 6 (Task 6.2): the nine lifecycle registrations dispatch + // through the public seam with exact capability declarations. + // ----------------------------------------------------------------------- + + #[test] + fn feat023_lifecycle_entries_register_through_portable_bridge() { + use codewhale_command_contract::handler::{CommandCapabilities, CommandHandler}; + + for name in ["branch", "fork", "load", "new", "save", "sessions", "tree"] { + assert!( + registry().has_contextual_handler(name), + "/{name} must register through the portable bridge" + ); + let handler = registry() + .get(name) + .expect("entry") + .contextual_handler() + .expect("contextual handler"); + let CommandHandler::Contextual { capabilities, .. } = handler else { + panic!("/{name} must be contextual"); + }; + assert_eq!( + capabilities, + CommandCapabilities::SESSION_LIFECYCLE, + "/{name} declares lifecycle authority only" + ); + } + // Pure handlers register through the bridge with no host bundle. + for name in ["compact", "purge"] { + assert!( + registry().has_contextual_handler(name), + "/{name} must register through the portable bridge" + ); + let handler = registry() + .get(name) + .expect("entry") + .contextual_handler() + .expect("pure handler"); + assert!( + matches!(handler, CommandHandler::Pure(_)), + "/{name} must be pure (no host context bundle)" + ); + } + // Out-of-scope session commands remain legacy for FEAT-024/025/026. + for name in ["export", "relay", "structcopy"] { + assert!( + !registry().has_contextual_handler(name), + "/{name} must stay on the legacy dispatch until its owning FEAT" + ); + } + } + + #[test] + fn feat023_lifecycle_commands_dispatch_through_public_seam() { + let mut app = create_test_app(); + app.workspace = PathBuf::from("."); + + // Pure handlers need no App machinery. + let compact = execute("/compact the auth refactor", &mut app); + assert_eq!( + compact.message.as_deref(), + Some("Context compaction triggered (focus: the auth refactor)...") + ); + assert!(matches!( + compact.action, + Some(AppAction::CompactContext { focus: Some(ref f) }) if f == "the auth refactor" + )); + let purge = execute("/purge", &mut app); + assert_eq!( + purge.message.as_deref(), + Some("Agent context purge triggered...") + ); + assert!(matches!(purge.action, Some(AppAction::PurgeContext))); + + // Contextual handler reaches the adapter through the seam; /tree on a + // bare app reports no active session. + let tree = execute("/tree", &mut app); + assert!( + tree.message + .as_deref() + .unwrap_or_default() + .contains("No active session"), + "{tree:?}" + ); + + // Subcommand routing and usage errors stay byte-exact. + let bad = execute("/sessions teleport", &mut app); + assert!( + bad.message + .as_deref() + .unwrap_or_default() + .contains("unknown subcommand `teleport`"), + "{bad:?}" + ); + let branch_usage = execute("/branch", &mut app); + assert!( + branch_usage + .message + .as_deref() + .unwrap_or_default() + .starts_with("Usage: /branch "), + "{branch_usage:?}" + ); + } } diff --git a/crates/tui/src/commands/groups/session/acceptance.rs b/crates/tui/src/commands/session_acceptance.rs similarity index 100% rename from crates/tui/src/commands/groups/session/acceptance.rs rename to crates/tui/src/commands/session_acceptance.rs diff --git a/crates/tui/src/commands/session_lifecycle_regression_tests.rs b/crates/tui/src/commands/session_lifecycle_regression_tests.rs new file mode 100644 index 0000000000..7f90c18bbd --- /dev/null +++ b/crates/tui/src/commands/session_lifecycle_regression_tests.rs @@ -0,0 +1,871 @@ +//! Regression coverage retained from the pre-FEAT-023 lifecycle implementation. +//! +//! These tests dispatch through the public command seam so moving host logic +//! behind `CommandSessionLifecycleContext` cannot silently reduce the existing +//! persistence, reset, and deferred-load guarantees. + +use std::time::Instant; + +use tempfile::TempDir; + +use crate::commands::CommandResult; +use crate::config::Config; +use crate::models::Role; +use crate::session_manager::create_saved_session_with_id_and_mode; +use crate::test_support::EnvVarGuard; +use crate::tui::app::{App, AppAction, AppMode, ReasoningEffort, TuiOptions, TurnCacheRecord}; +use crate::tui::history::HistoryCell; + +fn dispatch_lifecycle(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + let command = match arg { + Some(arg) => format!("/{name} {arg}"), + None => format!("/{name}"), + }; + crate::commands::execute(&command, app) +} + +fn save(app: &mut App, path: Option<&str>) -> CommandResult { + dispatch_lifecycle(app, "save", path) +} + +fn fork(app: &mut App) -> CommandResult { + dispatch_lifecycle(app, "fork", None) +} + +fn new_session(app: &mut App, arg: Option<&str>) -> CommandResult { + dispatch_lifecycle(app, "new", arg) +} + +fn load(app: &mut App, path: Option<&str>) -> CommandResult { + dispatch_lifecycle(app, "load", path) +} + +fn compact(app: &mut App, arg: Option<&str>) -> CommandResult { + dispatch_lifecycle(app, "compact", arg) +} + +fn sessions(app: &mut App, arg: Option<&str>) -> CommandResult { + dispatch_lifecycle(app, "sessions", arg) +} + +fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { + let options = TuiOptions { + skills_dir: tmpdir.path().join("skills"), + memory_path: tmpdir.path().join("memory.md"), + notes_path: tmpdir.path().join("notes.txt"), + mcp_config_path: tmpdir.path().join("mcp.json"), + ..crate::test_support::test_tui_options(tmpdir.path()) + }; + App::new(options, &Config::default()) +} + +#[test] +fn test_save_creates_file_and_sets_session_id() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let save_path = tmpdir.path().join("test_session.json"); + + let result = save(&mut app, Some(save_path.to_str().unwrap())); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("Session saved to")); + assert!(msg.contains("ID:")); + assert!(app.current_session_id.is_some()); + assert!(save_path.exists()); +} + +#[test] +fn save_preserves_artifact_registry() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let save_path = tmpdir.path().join("artifact_session.json"); + app.session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_call_big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "artifact-session".to_string(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 512_000, + preview: "cargo test output".to_string(), + storage_path: tmpdir.path().join("call-big.txt"), + }); + + let result = save(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error); + let saved: crate::session_manager::SavedSession = + serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap(); + assert_eq!(saved.artifacts, app.session_artifacts); +} + +#[test] +fn save_preserves_latest_auto_route_receipt() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let save_path = tmpdir.path().join("auto_route_session.json"); + let receipt = crate::model_routing::AutoRouteReceipt { + tier: crate::model_routing::AutoRouteTier::Fast, + pair: crate::model_routing::AutoRoutePair { + strong: crate::config::ZAI_GLM_5_2_MODEL.to_string(), + fast: Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()), + }, + scope: crate::model_routing::AutoRouteScope::ResolvedProvider, + data_path: crate::model_routing::AutoRouteDataPath::LocalHeuristic, + reason: crate::model_routing::AutoRouteReason::LocalHeuristic( + crate::model_routing::AutoRouteHeuristicReason::ShortRequest, + ), + }; + app.set_model_selection("auto".to_string()); + app.last_effective_provider = Some(crate::config::ApiProvider::Zai); + app.last_effective_provider_identity = Some("zai".to_string()); + app.last_effective_model = Some(crate::config::ZAI_GLM_5_TURBO_MODEL.to_string()); + app.last_auto_route_receipt = Some(receipt.clone()); + app.last_effective_reasoning_effort = + Some(crate::tui::app::EffectiveReasoningEffort::ThinkingEnabledGranularityUnavailable); + + let result = save(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error); + let saved: crate::session_manager::SavedSession = + serde_json::from_str(&std::fs::read_to_string(save_path).unwrap()).unwrap(); + let route = saved.last_auto_route.expect("latest Auto route"); + assert_eq!(route.provider, crate::config::ApiProvider::Zai); + assert_eq!(route.provider_identity, "zai"); + assert_eq!(route.model, crate::config::ZAI_GLM_5_TURBO_MODEL); + assert_eq!(route.receipt, receipt); + assert_eq!( + route.effective_reasoning_effort, + Some(crate::work_graph::ReasoningEffortTier::ThinkingEnabledGranularityUnavailable) + ); +} + +#[test] +fn fork_saves_parent_and_switches_to_child_session() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + let home = tmpdir.path().join("home"); + std::fs::create_dir_all(&home).unwrap(); + let home_guard = EnvVarGuard::set("HOME", &home); + let previous_home = home_guard.previous(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.set_provider_identity(crate::config::ApiProvider::Custom, "lm-studio"); + app.current_session_id = Some("parent-session".to_string()); + let mut cached_parent = create_saved_session_with_id_and_mode( + "parent-session".to_string(), + &[], + &app.model, + &app.workspace, + 0, + None, + Some(app.mode.label()), + ) + .metadata; + cached_parent.title = "Custom Parent".to_string(); + cached_parent.created_at = "2026-01-02T03:04:05Z" + .parse() + .expect("fixed parent timestamp"); + app.current_session_metadata = Some(cached_parent.clone()); + app.session_title = Some(cached_parent.title.clone()); + app.api_messages.push(crate::models::Message { + role: Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "try another path".to_string(), + cache_control: None, + }], + }); + { + let mut todos = app.todos.try_lock().expect("todos lock"); + todos.add( + "preserve fork Work".to_string(), + crate::tools::todo::TodoStatus::InProgress, + ); + } + { + let mut plan = app.plan_state.try_lock().expect("plan lock"); + plan.update(crate::tools::plan::UpdatePlanArgs { + objective: Some("Fork without Work drift".to_string()), + ..crate::tools::plan::UpdatePlanArgs::default() + }); + } + app.cycle_effort(); + let expected_work = app + .work_state_snapshot() + .expect("Work snapshot") + .expect("graph-backed Work state"); + assert!( + expected_work.graph.is_some(), + "fork fixture must use a graph" + ); + + let result = fork(&mut app); + + assert!(!result.is_error, "{:?}", result.message); + let new_id = app.current_session_id.clone().expect("fork session id"); + assert_ne!(new_id, "parent-session"); + assert!(result.message.as_deref().unwrap_or("").contains("Forked")); + assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); + + let manager = crate::session_manager::SessionManager::default_location().unwrap(); + let parent = manager + .load_session("parent-session") + .expect("parent saved"); + let child = manager.load_session(&new_id).expect("child saved"); + assert_eq!(parent.messages.len(), 1); + assert_eq!(parent.metadata.model_provider, "custom"); + assert_eq!( + parent.metadata.model_provider_id.as_deref(), + Some("lm-studio") + ); + assert_eq!(parent.metadata.title, cached_parent.title); + assert_eq!(parent.metadata.created_at, cached_parent.created_at); + assert_eq!( + child.metadata.parent_session_id.as_deref(), + Some("parent-session") + ); + assert_eq!(child.metadata.forked_from_message_count, Some(1)); + assert_eq!(child.metadata.model_provider, "custom"); + assert_eq!( + child.metadata.model_provider_id.as_deref(), + Some("lm-studio") + ); + assert_eq!(parent.work_state.as_ref(), Some(&expected_work)); + assert_eq!(child.work_state.as_ref(), Some(&expected_work)); + let cached_child = app + .current_session_metadata + .as_ref() + .expect("child metadata cached"); + assert_eq!(cached_child.id, child.metadata.id); + assert_eq!(cached_child.title, child.metadata.title); + assert_eq!(cached_child.created_at, child.metadata.created_at); + assert_eq!( + cached_child.parent_session_id, + child.metadata.parent_session_id + ); + assert_eq!( + app.session_title.as_deref(), + Some(child.metadata.title.as_str()) + ); + drop(home_guard); + assert_eq!(std::env::var_os("HOME"), previous_home); +} + +#[test] +fn fork_rejects_active_runtime_without_switching_sessions() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("parent-session".to_string()); + app.api_messages.push(crate::models::Message { + role: Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "still running".to_string(), + cache_control: None, + }], + }); + app.is_loading = true; + + let result = fork(&mut app); + + assert!(result.is_error); + assert!(result.action.is_none()); + assert_eq!(app.current_session_id.as_deref(), Some("parent-session")); + assert_eq!(app.api_messages.len(), 1); +} + +#[test] +fn new_session_from_resumed_state_creates_distinct_empty_session() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.session_title = Some("Old Session".to_string()); + app.api_messages.push(crate::models::Message { + role: Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "continue this thread".to_string(), + cache_control: None, + }], + }); + app.add_message(HistoryCell::System { + content: "old transcript".to_string(), + }); + app.system_prompt = Some(crate::models::SystemPrompt::Text("old prompt".to_string())); + app.session.total_tokens = 123; + app.session.session_cost = 1.25; + + let result = new_session(&mut app, None); + + assert!(!result.is_error, "{:?}", result.message); + let new_id = app.current_session_id.clone().expect("new session id"); + assert_ne!(new_id, "old-session"); + assert_eq!(app.session_title.as_deref(), Some("New Session")); + assert!(app.api_messages.is_empty()); + assert!(app.history.is_empty()); + assert!(app.system_prompt.is_none()); + assert_eq!(app.session.total_tokens, 0); + assert_eq!(app.session.session_cost, 0.0); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("/resume") + ); + match result.action { + Some(AppAction::SyncSession { + session_id, + messages, + system_prompt, + .. + }) => { + assert_eq!(session_id.as_deref(), Some(new_id.as_str())); + assert!(messages.is_empty()); + assert!(system_prompt.is_none()); + } + other => panic!("expected SyncSession action, got {other:?}"), + } +} + +#[test] +fn new_session_blocks_unsent_input_without_force() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.input = "draft text".to_string(); + + let result = new_session(&mut app, None); + + assert!(result.is_error); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + assert_eq!(app.input, "draft text"); + assert!(result.action.is_none()); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("/new --force") + ); +} + +#[test] +fn new_session_force_discards_unsent_input() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.input = "draft text".to_string(); + + let result = new_session(&mut app, Some("--force")); + + assert!(!result.is_error, "{:?}", result.message); + assert_ne!(app.current_session_id.as_deref(), Some("old-session")); + assert!(app.input.is_empty()); + assert!(matches!(result.action, Some(AppAction::SyncSession { .. }))); +} + +#[test] +fn new_session_blocks_in_flight_turn_without_force() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.is_loading = true; + + let result = new_session(&mut app, None); + + assert!(result.is_error); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + assert!(result.action.is_none()); +} + +#[test] +fn new_session_force_cannot_detach_an_in_flight_turn() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.api_messages.push(crate::models::Message { + role: Role::User, + content: vec![], + }); + app.is_loading = true; + app.runtime_turn_status = Some("in_progress".to_string()); + + let result = new_session(&mut app, Some("--force")); + + assert!(result.is_error); + assert!(result.action.is_none()); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + assert_eq!(app.api_messages.len(), 1); + assert!( + result + .message + .as_deref() + .is_some_and(|message| message.contains("only discards draft or queued input")) + ); +} + +#[test] +fn load_rejects_an_active_runtime_before_reading_or_mutating() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.current_session_id = Some("old-session".to_string()); + app.api_messages.push(crate::models::Message { + role: Role::User, + content: vec![], + }); + app.task_panel.push(crate::tui::app::TaskPanelEntry { + id: "queued-late-producer".to_string(), + status: "queued".to_string(), + prompt_summary: "queued".to_string(), + duration_ms: None, + kind: crate::tui::app::TaskPanelEntryKind::Background, + stale: false, + elapsed_since_output_ms: None, + owner_agent_id: None, + owner_agent_name: None, + current_tool: None, + role: None, + files_touched: 0, + }); + + let result = load(&mut app, Some("does-not-exist.json")); + + assert!(result.is_error); + assert!(result.action.is_none()); + assert_eq!(app.current_session_id.as_deref(), Some("old-session")); + assert_eq!(app.api_messages.len(), 1); + assert!( + result + .message + .as_deref() + .is_some_and(|message| message.contains("runtime work is active")) + ); +} + +#[test] +fn test_save_with_default_path_uses_managed_sessions_dir() { + let tmpdir = TempDir::new().unwrap(); + let _lock = crate::test_support::lock_test_env(); + // Set CODEWHALE_HOME so the managed sessions directory lands inside the + // temp dir rather than the real user home. Pre-create the directory so + // resolve_state_dir picks it up instead of falling back to legacy. + let home = tmpdir.path().join("home"); + let sessions_dir = home.join("sessions"); + std::fs::create_dir_all(&sessions_dir).unwrap(); + let codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &home); + let previous_codewhale_home = codewhale_home.previous(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = save(&mut app, None); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + // Give it a moment to ensure file is written + std::thread::sleep(std::time::Duration::from_millis(10)); + let entries: Vec<_> = if sessions_dir.exists() { + std::fs::read_dir(&sessions_dir) + .unwrap() + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".json")) + .collect() + } else { + Vec::new() + }; + drop(codewhale_home); + // Session should be saved to the managed dir, not the workspace root. + assert!( + !entries.is_empty(), + "expected session file in {sessions_dir:?}, got none; msg: {msg}" + ); + let session_id = app + .current_session_id + .as_deref() + .expect("current session id"); + assert!(sessions_dir.join(format!("{session_id}.json")).exists()); + assert_eq!(std::env::var_os("CODEWHALE_HOME"), previous_codewhale_home); +} + +#[test] +fn test_save_serialization_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + // This should work normally since SavedSession is serializable + // Testing error path would require mocking, which is complex + let save_path = tmpdir.path().join("test.json"); + let result = save(&mut app, Some(save_path.to_str().unwrap())); + assert!(result.message.is_some()); +} + +#[test] +fn test_load_without_path_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = load(&mut app, None); + assert!(result.message.is_some()); + assert!(result.message.unwrap().contains("Usage: /load")); +} + +#[test] +fn test_load_nonexistent_file_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = load(&mut app, Some("nonexistent.json")); + assert!(result.message.is_some()); + assert!(result.message.unwrap().contains("Failed to read")); +} + +#[test] +fn test_load_invalid_json_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let bad_file = tmpdir.path().join("bad.json"); + std::fs::write(&bad_file, "not valid json").unwrap(); + let result = load(&mut app, Some(bad_file.to_str().unwrap())); + assert!(result.message.is_some()); + assert!(result.message.unwrap().contains("Failed to parse")); +} + +#[test] +fn test_load_valid_session_defers_state_restore_to_event_loop() { + let tmpdir = TempDir::new().unwrap(); + let mut app1 = create_test_app_with_tmpdir(&tmpdir); + // Set up some state to save + app1.api_messages.push(crate::models::Message { + role: Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "Hello".to_string(), + cache_control: None, + }], + }); + app1.session.total_tokens = 500; + app1.set_mode(AppMode::Plan); + let save_path = tmpdir.path().join("test.json"); + save(&mut app1, Some(save_path.to_str().unwrap())); + + // Create new app and load + let mut app2 = create_test_app_with_tmpdir(&tmpdir); + app2.system_prompt = Some(crate::models::SystemPrompt::Text( + "stale prompt from prior session".to_string(), + )); + app2.session_context_references + .push(crate::session_manager::SessionContextReference { + message_index: 0, + reference: crate::tui::file_mention::ContextReference { + kind: crate::tui::file_mention::ContextReferenceKind::File, + source: crate::tui::file_mention::ContextReferenceSource::AtMention, + badge: "file".to_string(), + label: "stale.rs".to_string(), + target: tmpdir.path().join("stale.rs").display().to_string(), + included: true, + expanded: true, + detail: None, + }, + }); + let result = load(&mut app2, Some(save_path.to_str().unwrap())); + assert_eq!(result.message, None); + assert!(app2.api_messages.is_empty()); + assert_eq!(app2.session.total_tokens, 0); + assert!(app2.current_session_id.is_none()); + assert!(app2.system_prompt.is_some()); + assert_eq!(app2.session_context_references.len(), 1); + assert!(matches!( + result.action, + Some(AppAction::LoadSession(path)) if path == save_path + )); +} + +#[test] +fn explicit_save_persists_work_state_and_load_defers_application() { + let tmpdir = TempDir::new().unwrap(); + let mut saved_app = create_test_app_with_tmpdir(&tmpdir); + { + let mut todos = saved_app.todos.try_lock().expect("todos lock"); + todos.add( + "persist me".to_string(), + crate::tools::todo::TodoStatus::InProgress, + ); + } + { + let mut plan = saved_app.plan_state.try_lock().expect("plan lock"); + plan.update(crate::tools::plan::UpdatePlanArgs { + objective: Some("Resume exactly".to_string()), + ..crate::tools::plan::UpdatePlanArgs::default() + }); + } + let expected = saved_app.work_state_snapshot().expect("snapshot"); + let save_path = tmpdir.path().join("work_state.json"); + let saved = save(&mut saved_app, Some(save_path.to_str().unwrap())); + assert!(!saved.is_error, "{:?}", saved.message); + + let mut loaded_app = create_test_app_with_tmpdir(&tmpdir); + let loaded = load(&mut loaded_app, Some(save_path.to_str().unwrap())); + assert!(!loaded.is_error, "{:?}", loaded.message); + assert_eq!(loaded_app.work_state_snapshot().expect("snapshot"), None); + assert!(matches!( + loaded.action, + Some(AppAction::LoadSession(path)) if path == save_path + )); + let saved_session: crate::session_manager::SavedSession = + serde_json::from_str(&std::fs::read_to_string(&save_path).expect("saved session file")) + .expect("saved session JSON"); + assert_eq!(saved_session.work_state, expected); +} + +#[test] +fn new_session_is_all_or_nothing_when_work_state_is_busy() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.api_messages.push(crate::models::Message { + role: Role::User, + content: vec![], + }); + app.current_session_id = Some("current-session".to_string()); + let todos = app.todos.clone(); + let _held = todos.try_lock().expect("hold todos lock"); + + let result = new_session(&mut app, Some("--force")); + + assert!(result.is_error); + assert_eq!(app.api_messages.len(), 1); + assert_eq!(app.current_session_id.as_deref(), Some("current-session")); + assert!(result.action.is_none()); +} + +#[test] +fn load_auto_model_session_defers_model_restore_to_event_loop() { + let tmpdir = TempDir::new().unwrap(); + let mut saved_app = create_test_app_with_tmpdir(&tmpdir); + saved_app.set_model_selection("auto".to_string()); + saved_app.last_effective_model = Some("deepseek-v4-flash".to_string()); + saved_app.last_effective_reasoning_effort = Some( + crate::tui::app::EffectiveReasoningEffort::Tier(ReasoningEffort::Low), + ); + let save_path = tmpdir.path().join("auto_model.json"); + save(&mut saved_app, Some(save_path.to_str().unwrap())); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.set_model_selection("deepseek-v4-flash".to_string()); + app.reasoning_effort = ReasoningEffort::High; + let result = load(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error); + assert!(!app.auto_model); + assert_eq!(app.model, "deepseek-v4-flash"); + assert_eq!(app.reasoning_effort, ReasoningEffort::High); + assert!(matches!( + result.action, + Some(AppAction::LoadSession(path)) if path == save_path + )); +} + +#[test] +fn load_defers_artifact_registry_restore_to_event_loop() { + let tmpdir = TempDir::new().unwrap(); + let mut saved_app = create_test_app_with_tmpdir(&tmpdir); + saved_app + .session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_call_big".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "artifact-session".to_string(), + tool_call_id: "call-big".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 128, + preview: "checking crate".to_string(), + storage_path: tmpdir.path().join("call-big.txt"), + }); + let save_path = tmpdir.path().join("artifact_load.json"); + save(&mut saved_app, Some(save_path.to_str().unwrap())); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.session_artifacts + .push(crate::artifacts::ArtifactRecord { + id: "art_stale".to_string(), + kind: crate::artifacts::ArtifactKind::ToolOutput, + session_id: "stale-session".to_string(), + tool_call_id: "stale".to_string(), + tool_name: "exec_shell".to_string(), + created_at: chrono::Utc::now(), + byte_size: 1, + preview: "stale".to_string(), + storage_path: tmpdir.path().join("stale.txt"), + }); + + let result = load(&mut app, Some(save_path.to_str().unwrap())); + + assert!(!result.is_error); + assert_eq!(app.session_artifacts.len(), 1); + assert_eq!(app.session_artifacts[0].id, "art_stale"); + assert!(matches!( + result.action, + Some(AppAction::LoadSession(path)) if path == save_path + )); +} + +#[test] +fn load_defers_telemetry_reset_to_event_loop() { + let tmpdir = TempDir::new().unwrap(); + let mut saved_app = create_test_app_with_tmpdir(&tmpdir); + saved_app.api_messages.push(crate::models::Message { + role: Role::User, + content: vec![crate::models::ContentBlock::Text { + text: "checkpoint".to_string(), + cache_control: None, + }], + }); + saved_app.session.total_tokens = 500; + let save_path = tmpdir.path().join("checkpoint.json"); + save(&mut saved_app, Some(save_path.to_str().unwrap())); + + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.session.session_cost = 1.25; + app.session.session_cost_cny = 9.13; + app.session.subagent_cost = 0.75; + app.session.subagent_cost_cny = 5.48; + app.session + .subagent_usage_sources + .insert(crate::cost_status::usage_source_fingerprint( + "response-test", + )); + app.session.displayed_cost_high_water = 2.0; + app.session.displayed_cost_high_water_cny = 14.61; + app.session.last_prompt_tokens = Some(120); + app.session.last_completion_tokens = Some(35); + app.session.last_prompt_cache_hit_tokens = Some(80); + app.session.last_prompt_cache_miss_tokens = Some(40); + app.session.last_reasoning_replay_tokens = Some(12); + app.push_turn_cache_record(TurnCacheRecord { + provider: None, + provider_identity: None, + model: None, + auto_model: false, + input_tokens: 120, + output_tokens: 35, + cache_hit_tokens: Some(80), + cache_miss_tokens: Some(40), + reasoning_replay_tokens: Some(12), + cache_write_tokens: None, + reasoning_tokens: None, + cost_audit: None, + recorded_at: Instant::now(), + }); + + let result = load(&mut app, Some(save_path.to_str().unwrap())); + + assert_eq!(result.message, None); + assert_eq!(app.session.total_tokens, 0); + assert_eq!(app.session.session_cost, 1.25); + assert_eq!(app.session.session_cost_cny, 9.13); + assert_eq!(app.session.subagent_cost, 0.75); + assert_eq!(app.session.subagent_cost_cny, 5.48); + assert_eq!(app.session.turn_cache_history.len(), 1); + assert!(matches!( + result.action, + Some(AppAction::LoadSession(path)) if path == save_path + )); +} + +#[test] +fn test_compact_toggles_state() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = compact(&mut app, None); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("compaction") || msg.contains("Compact")); + assert!(matches!( + result.action, + Some(AppAction::CompactContext { focus: None }) + )); +} + +#[test] +fn compact_command_forwards_a_trimmed_focus_argument() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + + let result = compact(&mut app, Some(" the auth refactor ")); + assert!(matches!( + result.action, + Some(AppAction::CompactContext { focus: Some(ref focus) }) if focus == "the auth refactor" + )); + assert!( + result + .message + .as_deref() + .is_some_and(|msg| msg.contains("focus: the auth refactor")), + "{result:?}" + ); + + // Whitespace-only arguments behave like no focus at all. + let blank = compact(&mut app, Some(" ")); + assert!(matches!( + blank.action, + Some(AppAction::CompactContext { focus: None }) + )); +} + +#[test] +fn test_sessions_pushes_picker_view() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let initial_kind = app.view_stack.top_kind(); + + let result = sessions(&mut app, None); + assert_eq!(result.message, None); + assert!(result.action.is_none()); + // View should have changed (session picker should be on top) + assert_ne!(app.view_stack.top_kind(), initial_kind); +} + +#[test] +fn test_sessions_show_subcommand_pushes_picker_view() { + // `/sessions show` and `/sessions list` are explicit aliases + // for the no-arg picker form. Verify they don't fall through + // to the prune branch. + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let initial_kind = app.view_stack.top_kind(); + let result = sessions(&mut app, Some("show")); + assert_eq!(result.message, None); + assert_ne!(app.view_stack.top_kind(), initial_kind); +} + +#[test] +fn test_sessions_prune_requires_days_argument() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = sessions(&mut app, Some("prune")); + assert!(result.is_error); + assert!( + result.message.as_deref().unwrap_or("").contains("usage"), + "expected usage hint: {:?}", + result.message + ); +} + +#[test] +fn test_sessions_prune_rejects_non_positive_days() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + for bad in ["0", "-3", "abc", "3.14"] { + let result = sessions(&mut app, Some(&format!("prune {bad}"))); + assert!(result.is_error, "expected error for `{bad}`"); + } +} + +#[test] +fn test_sessions_unknown_subcommand_errors() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = sessions(&mut app, Some("teleport")); + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or("") + .contains("unknown subcommand"), + "expected unknown-subcommand error: {:?}", + result.message + ); +}