diff --git a/crates/loopal-acp/src/translate/mod.rs b/crates/loopal-acp/src/translate/mod.rs index ef497eda..f553eb8e 100644 --- a/crates/loopal-acp/src/translate/mod.rs +++ b/crates/loopal-acp/src/translate/mod.rs @@ -113,7 +113,8 @@ pub fn translate_event(payload: &AgentEventPayload, session_id: &str) -> Option< | AgentEventPayload::RetryCleared | AgentEventPayload::SubAgentSpawned { .. } | AgentEventPayload::AutoModeDecision { .. } - | AgentEventPayload::TurnCompleted { .. } => None, + | AgentEventPayload::TurnCompleted { .. } + | AgentEventPayload::SessionResumed { .. } => None, } } diff --git a/crates/loopal-protocol/src/control.rs b/crates/loopal-protocol/src/control.rs index eb5be2b6..5374f664 100644 --- a/crates/loopal-protocol/src/control.rs +++ b/crates/loopal-protocol/src/control.rs @@ -25,4 +25,6 @@ pub enum ControlCommand { Rewind { turn_index: usize }, /// Switch thinking config at runtime. JSON string of ThinkingConfig. ThinkingSwitch(String), + /// Resume (hot-swap) to a different persisted session by ID. + ResumeSession(String), } diff --git a/crates/loopal-protocol/src/event_payload.rs b/crates/loopal-protocol/src/event_payload.rs index 17d0b48e..b44a1cbd 100644 --- a/crates/loopal-protocol/src/event_payload.rs +++ b/crates/loopal-protocol/src/event_payload.rs @@ -162,6 +162,12 @@ pub enum AgentEventPayload { duration_ms: u64, }, + /// Session context was replaced by resuming a persisted session. + SessionResumed { + session_id: String, + message_count: usize, + }, + /// Aggregated metrics emitted at the end of each turn. TurnCompleted { turn_id: u32, diff --git a/crates/loopal-protocol/tests/suite/control_test.rs b/crates/loopal-protocol/tests/suite/control_test.rs index 50715c7a..c88f8c90 100644 --- a/crates/loopal-protocol/tests/suite/control_test.rs +++ b/crates/loopal-protocol/tests/suite/control_test.rs @@ -55,3 +55,25 @@ fn test_control_command_thinking_switch() { panic!("expected ThinkingSwitch"); } } + +#[test] +fn test_control_command_resume_session() { + let cmd = ControlCommand::ResumeSession("abc-123".to_string()); + if let ControlCommand::ResumeSession(sid) = cmd { + assert_eq!(sid, "abc-123"); + } else { + panic!("expected ResumeSession"); + } +} + +#[test] +fn test_control_command_resume_session_serde_roundtrip() { + let cmd = ControlCommand::ResumeSession("session-xyz".to_string()); + let json = serde_json::to_string(&cmd).unwrap(); + let deserialized: ControlCommand = serde_json::from_str(&json).unwrap(); + if let ControlCommand::ResumeSession(sid) = deserialized { + assert_eq!(sid, "session-xyz"); + } else { + panic!("expected ResumeSession after roundtrip"); + } +} diff --git a/crates/loopal-protocol/tests/suite/event_edge_test.rs b/crates/loopal-protocol/tests/suite/event_edge_test.rs index b4dbdc57..18601ed1 100644 --- a/crates/loopal-protocol/tests/suite/event_edge_test.rs +++ b/crates/loopal-protocol/tests/suite/event_edge_test.rs @@ -85,3 +85,23 @@ fn test_event_rewound_serde_roundtrip() { panic!("expected AgentEventPayload::Rewound"); } } + +#[test] +fn test_event_session_resumed_serde_roundtrip() { + let event = AgentEvent::root(AgentEventPayload::SessionResumed { + session_id: "abc-123".into(), + message_count: 42, + }); + let json = serde_json::to_string(&event).unwrap(); + let de: AgentEvent = serde_json::from_str(&json).unwrap(); + if let AgentEventPayload::SessionResumed { + session_id, + message_count, + } = de.payload + { + assert_eq!(session_id, "abc-123"); + assert_eq!(message_count, 42); + } else { + panic!("expected AgentEventPayload::SessionResumed"); + } +} diff --git a/crates/loopal-runtime/src/agent_loop/input_control.rs b/crates/loopal-runtime/src/agent_loop/input_control.rs index 35dca74b..2808239d 100644 --- a/crates/loopal-runtime/src/agent_loop/input_control.rs +++ b/crates/loopal-runtime/src/agent_loop/input_control.rs @@ -67,6 +67,9 @@ impl AgentLoopRunner { Err(e) => error!(error = %e, "invalid thinking config"), } } + ControlCommand::ResumeSession(session_id) => { + self.handle_resume_session(&session_id).await?; + } } Ok(()) } diff --git a/crates/loopal-runtime/src/agent_loop/mod.rs b/crates/loopal-runtime/src/agent_loop/mod.rs index 15f8885f..74fb053c 100644 --- a/crates/loopal-runtime/src/agent_loop/mod.rs +++ b/crates/loopal-runtime/src/agent_loop/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod message_build; pub(crate) mod model_config; mod permission; mod question_parse; +mod resume_session; pub mod rewind; mod run; mod runner; diff --git a/crates/loopal-runtime/src/agent_loop/resume_session.rs b/crates/loopal-runtime/src/agent_loop/resume_session.rs new file mode 100644 index 00000000..8f9e898c --- /dev/null +++ b/crates/loopal-runtime/src/agent_loop/resume_session.rs @@ -0,0 +1,56 @@ +//! Session resume — hot-swap agent context to a different persisted session. + +use loopal_context::ContextStore; +use loopal_error::Result; +use loopal_protocol::AgentEventPayload; +use tracing::info; + +use super::runner::AgentLoopRunner; + +impl AgentLoopRunner { + /// Replace the agent's session context with a different persisted session. + /// + /// Loads the target session's metadata and messages from storage, + /// replaces the in-memory context store, and resets per-session counters. + pub(super) async fn handle_resume_session(&mut self, session_id: &str) -> Result<()> { + info!(session_id, "resuming session"); + let (session, messages) = self + .params + .deps + .session_manager + .resume_session(session_id)?; + + // Replace session identity + conversation context + self.params.session = session; + self.params.store = + ContextStore::from_messages(messages, self.params.store.budget().clone()); + + // Reset per-session counters + self.turn_count = 0; + self.tokens.reset(); + + // Update tool context so subsequent tool calls persist to the new session + self.tool_ctx.session_id.clone_from(&self.params.session.id); + + // Notify frontend + let message_count = self.params.store.len(); + self.emit(AgentEventPayload::SessionResumed { + session_id: self.params.session.id.clone(), + message_count, + }) + .await?; + + // Reset token display + self.emit(AgentEventPayload::TokenUsage { + input_tokens: 0, + output_tokens: 0, + context_window: self.params.store.budget().context_window, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + thinking_tokens: 0, + }) + .await?; + + Ok(()) + } +} diff --git a/crates/loopal-runtime/src/session.rs b/crates/loopal-runtime/src/session.rs index 7b792590..8fe8e21e 100644 --- a/crates/loopal-runtime/src/session.rs +++ b/crates/loopal-runtime/src/session.rs @@ -109,6 +109,18 @@ impl SessionManager { Ok(()) } + /// Find the most recently updated session for a given working directory. + pub fn latest_session_for_cwd(&self, cwd: &Path) -> Result> { + let session = self.session_store.latest_session_for_cwd(cwd)?; + Ok(session) + } + + /// List sessions for a given working directory, sorted by `updated_at` (newest first). + pub fn list_sessions_for_cwd(&self, cwd: &Path) -> Result> { + let sessions = self.session_store.list_sessions_for_cwd(cwd)?; + Ok(sessions) + } + /// List all sessions. pub fn list_sessions(&self) -> Result> { let sessions = self.session_store.list_sessions()?; diff --git a/crates/loopal-runtime/tests/suite/session_manager_test.rs b/crates/loopal-runtime/tests/suite/session_manager_test.rs index 4e0bd618..a4320be2 100644 --- a/crates/loopal-runtime/tests/suite/session_manager_test.rs +++ b/crates/loopal-runtime/tests/suite/session_manager_test.rs @@ -124,3 +124,71 @@ fn add_sub_agent_and_load_messages() { assert_eq!(sub_msgs[0].text_content(), "do analysis"); assert_eq!(sub_msgs[1].text_content(), "done"); } + +// --------------------------------------------------------------------------- +// cwd-based session queries +// --------------------------------------------------------------------------- + +#[test] +fn latest_session_for_cwd_finds_most_recent() { + let tmp = TempDir::new().unwrap(); + let mgr = SessionManager::with_base_dir(tmp.path().to_path_buf()); + + let _s1 = mgr + .create_session(std::path::Path::new("/proj"), "m1") + .unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + let s2 = mgr + .create_session(std::path::Path::new("/proj"), "m2") + .unwrap(); + mgr.create_session(std::path::Path::new("/other"), "m3") + .unwrap(); + + let latest = mgr + .latest_session_for_cwd(std::path::Path::new("/proj")) + .unwrap() + .expect("should find a session"); + assert_eq!(latest.id, s2.id); +} + +#[test] +fn latest_session_for_cwd_returns_none_for_unknown_dir() { + let tmp = TempDir::new().unwrap(); + let mgr = SessionManager::with_base_dir(tmp.path().to_path_buf()); + + mgr.create_session(std::path::Path::new("/a"), "m1") + .unwrap(); + + let result = mgr + .latest_session_for_cwd(std::path::Path::new("/unknown")) + .unwrap(); + assert!(result.is_none()); +} + +#[test] +fn list_sessions_for_cwd_filters_correctly() { + let tmp = TempDir::new().unwrap(); + let mgr = SessionManager::with_base_dir(tmp.path().to_path_buf()); + + mgr.create_session(std::path::Path::new("/alpha"), "m1") + .unwrap(); + mgr.create_session(std::path::Path::new("/alpha"), "m2") + .unwrap(); + mgr.create_session(std::path::Path::new("/beta"), "m3") + .unwrap(); + + let alpha = mgr + .list_sessions_for_cwd(std::path::Path::new("/alpha")) + .unwrap(); + assert_eq!(alpha.len(), 2); + + let beta = mgr + .list_sessions_for_cwd(std::path::Path::new("/beta")) + .unwrap(); + assert_eq!(beta.len(), 1); + + let empty = mgr + .list_sessions_for_cwd(std::path::Path::new("/gamma")) + .unwrap(); + assert!(empty.is_empty()); +} diff --git a/crates/loopal-session/src/agent_handler.rs b/crates/loopal-session/src/agent_handler.rs index 76925e1c..1bbbc780 100644 --- a/crates/loopal-session/src/agent_handler.rs +++ b/crates/loopal-session/src/agent_handler.rs @@ -193,7 +193,8 @@ pub(crate) fn apply_agent_event( AgentEventPayload::SubAgentSpawned { .. } | AgentEventPayload::MessageRouted { .. } | AgentEventPayload::TurnDiffSummary { .. } - | AgentEventPayload::TurnCompleted { .. } => {} + | AgentEventPayload::TurnCompleted { .. } + | AgentEventPayload::SessionResumed { .. } => {} AgentEventPayload::AutoModeDecision { tool_name, decision, diff --git a/crates/loopal-session/src/controller_control.rs b/crates/loopal-session/src/controller_control.rs index 2ad394f9..cbb95fd2 100644 --- a/crates/loopal-session/src/controller_control.rs +++ b/crates/loopal-session/src/controller_control.rs @@ -87,4 +87,34 @@ impl SessionController { .send_control_to_agent(&target, ControlCommand::Rewind { turn_index }) .await; } + + /// Resume a persisted session by ID. + /// + /// Clears local display state and sends `ResumeSession` to the agent. + /// The agent loads the session context; the TUI reloads display history + /// when it receives the `SessionResumed` event. + pub async fn resume_session(&self, session_id: &str) { + let target = { + let mut s = self.lock(); + let conv = s.active_conversation_mut(); + conv.messages.clear(); + conv.streaming_text.clear(); + conv.turn_count = 0; + conv.input_tokens = 0; + conv.output_tokens = 0; + conv.cache_creation_tokens = 0; + conv.cache_read_tokens = 0; + conv.retry_banner = None; + conv.reset_timer(); + s.inbox.clear(); + s.root_session_id = Some(session_id.to_string()); + s.active_view.clone() + }; + self.backend + .send_control_to_agent( + &target, + ControlCommand::ResumeSession(session_id.to_string()), + ) + .await; + } } diff --git a/crates/loopal-session/src/event_handler.rs b/crates/loopal-session/src/event_handler.rs index b4e14aa9..9d55abea 100644 --- a/crates/loopal-session/src/event_handler.rs +++ b/crates/loopal-session/src/event_handler.rs @@ -54,6 +54,11 @@ pub fn apply_event(state: &mut SessionState, event: AgentEvent) -> Option, + model: Option<&str>, + projected: Vec, + ) { + let display_msgs: Vec = + projected.into_iter().map(into_session_message).collect(); + let mut state = self.lock(); + let agent = state.agents.entry(name.to_string()).or_default(); + agent.parent = parent.map(|s| s.to_string()); + agent.session_id = Some(session_id.to_string()); + if let Some(m) = model { + agent.observable.model = m.to_string(); + } + agent.conversation.messages = display_msgs; + agent.conversation.agent_idle = true; + agent.observable.status = AgentStatus::Finished; + if let Some(parent_name) = parent + && let Some(parent_agent) = state.agents.get_mut(parent_name) + { + let child_name = name.to_string(); + if !parent_agent.children.contains(&child_name) { + parent_agent.children.push(child_name); + } + } + } } /// Convert a ProjectedMessage (pure data) into a SessionMessage (with default state). diff --git a/crates/loopal-session/tests/suite.rs b/crates/loopal-session/tests/suite.rs index 8dc43071..df8312b2 100644 --- a/crates/loopal-session/tests/suite.rs +++ b/crates/loopal-session/tests/suite.rs @@ -21,6 +21,10 @@ mod inbox_test; mod message_log_test; #[path = "suite/projection_convert_test.rs"] mod projection_convert_test; +#[path = "suite/resume_display_test.rs"] +mod resume_display_test; +#[path = "suite/resume_test.rs"] +mod resume_test; #[path = "suite/retry_banner_test.rs"] mod retry_banner_test; #[path = "suite/rewind_test.rs"] diff --git a/crates/loopal-session/tests/suite/resume_display_test.rs b/crates/loopal-session/tests/suite/resume_display_test.rs new file mode 100644 index 00000000..d1d160bb --- /dev/null +++ b/crates/loopal-session/tests/suite/resume_display_test.rs @@ -0,0 +1,127 @@ +//! Tests for load_sub_agent_history — populates agent entry with display data. + +use std::sync::Arc; + +use loopal_protocol::UserQuestionResponse; +use loopal_protocol::{AgentStatus, ControlCommand, ProjectedMessage, ProjectedToolCall}; +use loopal_session::SessionController; +use tokio::sync::mpsc; + +fn make_controller() -> SessionController { + let (control_tx, _) = mpsc::channel::(16); + let (perm_tx, _) = mpsc::channel::(16); + let (question_tx, _) = mpsc::channel::(16); + SessionController::new( + "test-model".to_string(), + "act".to_string(), + control_tx, + perm_tx, + question_tx, + Default::default(), + Arc::new(tokio::sync::watch::channel(0u64).0), + ) +} + +#[test] +fn test_load_sub_agent_history_creates_agent_entry() { + let ctrl = make_controller(); + + let projected = vec![ProjectedMessage { + role: "assistant".into(), + content: "sub-agent response".into(), + tool_calls: vec![], + image_count: 0, + }]; + ctrl.load_sub_agent_history("worker", "sub-sid", Some("main"), Some("gpt-4"), projected); + + let state = ctrl.lock(); + assert!(state.agents.contains_key("worker")); + let agent = &state.agents["worker"]; + assert_eq!(agent.parent.as_deref(), Some("main")); + assert_eq!(agent.session_id.as_deref(), Some("sub-sid")); + assert_eq!(agent.observable.model, "gpt-4"); + assert_eq!(agent.observable.status, AgentStatus::Finished); + assert!(agent.conversation.agent_idle); + assert_eq!(agent.conversation.messages.len(), 1); + assert_eq!(agent.conversation.messages[0].content, "sub-agent response"); +} + +#[test] +fn test_load_sub_agent_history_registers_parent_child() { + let ctrl = make_controller(); + + ctrl.load_sub_agent_history("child-agent", "sid-1", Some("main"), None, vec![]); + + let state = ctrl.lock(); + let main = &state.agents["main"]; + assert!( + main.children.contains(&"child-agent".to_string()), + "main should list child-agent as child" + ); +} + +#[test] +fn test_load_sub_agent_history_no_parent_no_crash() { + let ctrl = make_controller(); + + ctrl.load_sub_agent_history("orphan", "sid-2", None, None, vec![]); + + let state = ctrl.lock(); + assert!(state.agents.contains_key("orphan")); + assert!(state.agents["orphan"].parent.is_none()); +} + +#[test] +fn test_load_sub_agent_history_no_duplicate_children() { + let ctrl = make_controller(); + + ctrl.load_sub_agent_history("child", "sid-1", Some("main"), None, vec![]); + ctrl.load_sub_agent_history("child", "sid-1", Some("main"), None, vec![]); + + let state = ctrl.lock(); + let children = &state.agents["main"].children; + assert_eq!( + children.iter().filter(|c| *c == "child").count(), + 1, + "should not duplicate child entry" + ); +} + +#[test] +fn test_load_sub_agent_history_with_tool_calls() { + let ctrl = make_controller(); + + let projected = vec![ProjectedMessage { + role: "assistant".into(), + content: String::new(), + tool_calls: vec![ProjectedToolCall { + id: "tc1".into(), + name: "Read".into(), + input: Some(serde_json::json!({"path": "/tmp"})), + summary: "Read /tmp".into(), + result: Some("contents".into()), + is_error: false, + metadata: None, + }], + image_count: 0, + }]; + ctrl.load_sub_agent_history("worker", "sid-3", Some("main"), None, projected); + + let state = ctrl.lock(); + let msgs = &state.agents["worker"].conversation.messages; + assert_eq!(msgs.len(), 1); + assert_eq!(msgs[0].tool_calls.len(), 1); + assert_eq!(msgs[0].tool_calls[0].name, "Read"); +} + +#[test] +fn test_load_sub_agent_history_model_optional() { + let ctrl = make_controller(); + + ctrl.load_sub_agent_history("agent", "sid-4", Some("main"), None, vec![]); + + let state = ctrl.lock(); + // Default model should be empty string (from AgentViewState::default()) + let agent = &state.agents["agent"]; + assert!(agent.observable.model.is_empty()); +} diff --git a/crates/loopal-session/tests/suite/resume_test.rs b/crates/loopal-session/tests/suite/resume_test.rs new file mode 100644 index 00000000..c7588e9f --- /dev/null +++ b/crates/loopal-session/tests/suite/resume_test.rs @@ -0,0 +1,122 @@ +//! Tests for session resume: event handling and controller resume_session method. + +use std::sync::Arc; + +use loopal_protocol::UserQuestionResponse; +use loopal_protocol::{AgentEvent, AgentEventPayload, ControlCommand}; +use loopal_session::SessionController; +use loopal_session::event_handler::apply_event; +use loopal_session::state::SessionState; +use tokio::sync::mpsc; + +fn make_state() -> SessionState { + SessionState::new("test-model".to_string(), "act".to_string()) +} + +fn make_controller() -> (SessionController, mpsc::Receiver) { + let (control_tx, control_rx) = mpsc::channel::(16); + let (perm_tx, _) = mpsc::channel::(16); + let (question_tx, _) = mpsc::channel::(16); + let ctrl = SessionController::new( + "test-model".to_string(), + "act".to_string(), + control_tx, + perm_tx, + question_tx, + Default::default(), + Arc::new(tokio::sync::watch::channel(0u64).0), + ); + (ctrl, control_rx) +} + +// --------------------------------------------------------------------------- +// apply_event: SessionResumed updates root_session_id +// --------------------------------------------------------------------------- + +#[test] +fn test_session_resumed_event_updates_root_session_id() { + let mut state = make_state(); + state.root_session_id = Some("old-session".to_string()); + + apply_event( + &mut state, + AgentEvent::root(AgentEventPayload::SessionResumed { + session_id: "new-session-xyz".into(), + message_count: 10, + }), + ); + + assert_eq!(state.root_session_id.as_deref(), Some("new-session-xyz"),); +} + +#[test] +fn test_session_resumed_event_sets_root_session_id_from_none() { + let mut state = make_state(); + assert!(state.root_session_id.is_none()); + + apply_event( + &mut state, + AgentEvent::root(AgentEventPayload::SessionResumed { + session_id: "first-session".into(), + message_count: 0, + }), + ); + + assert_eq!(state.root_session_id.as_deref(), Some("first-session"),); +} + +// --------------------------------------------------------------------------- +// SessionController::resume_session — clears state + sends command +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn test_controller_resume_session_clears_display() { + let (ctrl, _rx) = make_controller(); + + // Populate some display state + ctrl.handle_event(AgentEvent::root(AgentEventPayload::Stream { + text: "hello world".into(), + })); + { + let state = ctrl.lock(); + assert!(!state.agents["main"].conversation.streaming_text.is_empty()); + } + + ctrl.resume_session("target-session").await; + + let state = ctrl.lock(); + let conv = &state.agents["main"].conversation; + assert!(conv.messages.is_empty(), "messages should be cleared"); + assert!( + conv.streaming_text.is_empty(), + "streaming should be cleared" + ); + assert_eq!(conv.turn_count, 0); + assert_eq!(conv.input_tokens, 0); + assert_eq!(conv.output_tokens, 0); +} + +#[tokio::test] +async fn test_controller_resume_session_updates_root_id() { + let (ctrl, _rx) = make_controller(); + ctrl.set_root_session_id("old-id"); + + ctrl.resume_session("new-id-abc").await; + + let state = ctrl.lock(); + assert_eq!(state.root_session_id.as_deref(), Some("new-id-abc")); +} + +#[tokio::test] +async fn test_controller_resume_session_sends_control_command() { + let (ctrl, mut rx) = make_controller(); + + ctrl.resume_session("target-session").await; + + let cmd = rx.try_recv().expect("should receive a control command"); + if let ControlCommand::ResumeSession(sid) = cmd { + assert_eq!(sid, "target-session"); + } else { + panic!("expected ControlCommand::ResumeSession, got {cmd:?}"); + } +} diff --git a/crates/loopal-storage/src/sessions.rs b/crates/loopal-storage/src/sessions.rs index 9b7e0b13..d14b8402 100644 --- a/crates/loopal-storage/src/sessions.rs +++ b/crates/loopal-storage/src/sessions.rs @@ -74,7 +74,7 @@ impl SessionStore { id: Uuid::new_v4().to_string(), title: String::new(), model: model.to_string(), - cwd: cwd.to_string_lossy().to_string(), + cwd: normalize_cwd(cwd), created_at: now, updated_at: now, mode: "default".to_string(), @@ -138,6 +138,26 @@ impl SessionStore { Ok(()) } + /// Find the most recently updated session for a given working directory. + pub fn latest_session_for_cwd(&self, cwd: &Path) -> Result, StorageError> { + let cwd_str = normalize_cwd(cwd); + let mut sessions = self.list_sessions()?; + sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(sessions.into_iter().find(|s| s.cwd == cwd_str)) + } + + /// List sessions filtered by working directory, sorted by `updated_at` (newest first). + pub fn list_sessions_for_cwd(&self, cwd: &Path) -> Result, StorageError> { + let cwd_str = normalize_cwd(cwd); + let mut sessions: Vec = self + .list_sessions()? + .into_iter() + .filter(|s| s.cwd == cwd_str) + .collect(); + sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at)); + Ok(sessions) + } + /// List all sessions, sorted by creation time (newest first). pub fn list_sessions(&self) -> Result, StorageError> { let sessions_dir = self.sessions_dir(); @@ -163,3 +183,12 @@ impl SessionStore { Ok(sessions) } } + +/// Canonicalize a path for consistent session cwd comparison. +/// Falls back to the original path if canonicalization fails (e.g. path doesn't exist yet). +fn normalize_cwd(cwd: &Path) -> String { + std::fs::canonicalize(cwd) + .unwrap_or_else(|_| cwd.to_path_buf()) + .to_string_lossy() + .to_string() +} diff --git a/crates/loopal-storage/tests/suite.rs b/crates/loopal-storage/tests/suite.rs index 708280a3..a2d009b3 100644 --- a/crates/loopal-storage/tests/suite.rs +++ b/crates/loopal-storage/tests/suite.rs @@ -5,6 +5,8 @@ mod entry_test; mod messages_test; #[path = "suite/replay_test.rs"] mod replay_test; +#[path = "suite/sessions_cwd_test.rs"] +mod sessions_cwd_test; #[path = "suite/sessions_test.rs"] mod sessions_test; #[path = "suite/sessions_update_test.rs"] diff --git a/crates/loopal-storage/tests/suite/sessions_cwd_test.rs b/crates/loopal-storage/tests/suite/sessions_cwd_test.rs new file mode 100644 index 00000000..d3dd8a67 --- /dev/null +++ b/crates/loopal-storage/tests/suite/sessions_cwd_test.rs @@ -0,0 +1,143 @@ +//! Tests for session cwd-based query: latest_session_for_cwd, list_sessions_for_cwd, normalize_cwd. + +use std::path::Path; + +use loopal_storage::SessionStore; +use tempfile::TempDir; + +// --------------------------------------------------------------------------- +// latest_session_for_cwd +// --------------------------------------------------------------------------- + +#[test] +fn test_latest_for_cwd_returns_most_recently_updated() { + let tmp = TempDir::new().unwrap(); + let store = SessionStore::with_base_dir(tmp.path().to_path_buf()); + + let s1 = store.create_session(Path::new("/project"), "m1").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + let s2 = store.create_session(Path::new("/project"), "m2").unwrap(); + + let latest = store + .latest_session_for_cwd(Path::new("/project")) + .unwrap() + .expect("should find a session"); + // s2 was created (and thus updated) more recently + assert_eq!(latest.id, s2.id); + + // Update s1's updated_at to be newer + let mut s1_loaded = store.load_session(&s1.id).unwrap(); + s1_loaded.updated_at = chrono::Utc::now(); + store.update_session(&s1_loaded).unwrap(); + + let latest = store + .latest_session_for_cwd(Path::new("/project")) + .unwrap() + .expect("should find a session"); + assert_eq!(latest.id, s1.id, "s1 now has a newer updated_at"); +} + +#[test] +fn test_latest_for_cwd_returns_none_when_no_match() { + let tmp = TempDir::new().unwrap(); + let store = SessionStore::with_base_dir(tmp.path().to_path_buf()); + + store.create_session(Path::new("/other"), "m1").unwrap(); + + let result = store.latest_session_for_cwd(Path::new("/project")).unwrap(); + assert!(result.is_none()); +} + +#[test] +fn test_latest_for_cwd_returns_none_when_empty() { + let tmp = TempDir::new().unwrap(); + let store = SessionStore::with_base_dir(tmp.path().to_path_buf()); + + let result = store.latest_session_for_cwd(Path::new("/project")).unwrap(); + assert!(result.is_none()); +} + +// --------------------------------------------------------------------------- +// list_sessions_for_cwd +// --------------------------------------------------------------------------- + +#[test] +fn test_list_for_cwd_filters_by_directory() { + let tmp = TempDir::new().unwrap(); + let store = SessionStore::with_base_dir(tmp.path().to_path_buf()); + + store.create_session(Path::new("/alpha"), "m1").unwrap(); + store.create_session(Path::new("/alpha"), "m2").unwrap(); + store.create_session(Path::new("/beta"), "m3").unwrap(); + + let alpha = store.list_sessions_for_cwd(Path::new("/alpha")).unwrap(); + assert_eq!(alpha.len(), 2); + + let beta = store.list_sessions_for_cwd(Path::new("/beta")).unwrap(); + assert_eq!(beta.len(), 1); + assert_eq!(beta[0].model, "m3"); +} + +#[test] +fn test_list_for_cwd_sorted_by_updated_at() { + let tmp = TempDir::new().unwrap(); + let store = SessionStore::with_base_dir(tmp.path().to_path_buf()); + + let s1 = store.create_session(Path::new("/proj"), "m1").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(10)); + let s2 = store.create_session(Path::new("/proj"), "m2").unwrap(); + + let sessions = store.list_sessions_for_cwd(Path::new("/proj")).unwrap(); + assert_eq!(sessions.len(), 2); + // Newest updated_at first + assert_eq!(sessions[0].id, s2.id); + assert_eq!(sessions[1].id, s1.id); +} + +#[test] +fn test_list_for_cwd_empty_when_no_match() { + let tmp = TempDir::new().unwrap(); + let store = SessionStore::with_base_dir(tmp.path().to_path_buf()); + + store.create_session(Path::new("/other"), "m1").unwrap(); + + let result = store.list_sessions_for_cwd(Path::new("/proj")).unwrap(); + assert!(result.is_empty()); +} + +// --------------------------------------------------------------------------- +// normalize_cwd (tested via create + query round-trip) +// --------------------------------------------------------------------------- + +#[test] +fn test_cwd_normalization_via_roundtrip() { + let tmp = TempDir::new().unwrap(); + let store = SessionStore::with_base_dir(tmp.path().to_path_buf()); + + // Create session with the canonical tmpdir path + let canonical = std::fs::canonicalize(tmp.path()).unwrap(); + store.create_session(&canonical, "m1").unwrap(); + + // Query with the original (possibly non-canonical) path — should still match + let result = store.latest_session_for_cwd(tmp.path()).unwrap(); + assert!( + result.is_some(), + "canonical and non-canonical paths should match" + ); +} + +#[test] +fn test_cwd_normalization_nonexistent_path_fallback() { + let tmp = TempDir::new().unwrap(); + let store = SessionStore::with_base_dir(tmp.path().to_path_buf()); + + // Path that doesn't exist — canonicalize falls back to raw path + let fake = Path::new("/nonexistent/path/abc"); + store.create_session(fake, "m1").unwrap(); + + let result = store.latest_session_for_cwd(fake).unwrap(); + assert!( + result.is_some(), + "non-existent path should match by raw string" + ); +} diff --git a/crates/loopal-tui/src/command/builtin.rs b/crates/loopal-tui/src/command/builtin.rs index 9a9e6598..258d28d3 100644 --- a/crates/loopal-tui/src/command/builtin.rs +++ b/crates/loopal-tui/src/command/builtin.rs @@ -81,23 +81,6 @@ impl CommandHandler for StatusCmd { } } -pub struct SessionsCmd; - -#[async_trait] -impl CommandHandler for SessionsCmd { - fn name(&self) -> &str { - "/sessions" - } - fn description(&self) -> &str { - "List session history" - } - async fn execute(&self, app: &mut App, _arg: Option<&str>) -> CommandEffect { - app.session - .push_system_message("Session listing is not yet available in TUI.".to_string()); - CommandEffect::Done - } -} - pub struct PlanCmd; #[async_trait] @@ -152,7 +135,7 @@ pub fn register_all(registry: &mut CommandRegistry) { registry.register(Arc::new(super::model_cmd::ModelCmd)); registry.register(Arc::new(super::rewind_cmd::RewindCmd)); registry.register(Arc::new(StatusCmd)); - registry.register(Arc::new(SessionsCmd)); + registry.register(Arc::new(super::resume_cmd::ResumeCmd)); registry.register(Arc::new(super::init_cmd::InitCmd)); registry.register(Arc::new(super::help_cmd::HelpCmd)); registry.register(Arc::new(ExitCmd)); diff --git a/crates/loopal-tui/src/command/mod.rs b/crates/loopal-tui/src/command/mod.rs index e3e511db..dc0ff603 100644 --- a/crates/loopal-tui/src/command/mod.rs +++ b/crates/loopal-tui/src/command/mod.rs @@ -6,6 +6,7 @@ mod help_cmd; pub(crate) mod init_cmd; mod model_cmd; pub mod registry; +mod resume_cmd; mod rewind_cmd; mod skill; mod topology_cmd; @@ -26,6 +27,8 @@ pub enum CommandEffect { ModeSwitch(AgentMode), /// Exit the application. Quit, + /// Resume a persisted session by ID (hot-swap agent context). + ResumeSession(String), } /// Slash command handler trait. diff --git a/crates/loopal-tui/src/command/resume_cmd.rs b/crates/loopal-tui/src/command/resume_cmd.rs new file mode 100644 index 00000000..e8d06ef8 --- /dev/null +++ b/crates/loopal-tui/src/command/resume_cmd.rs @@ -0,0 +1,91 @@ +//! `/resume` — resume a previous session or list resumable sessions. +//! +//! With argument: hot-swap agent context to the specified session (prefix match). +//! Without argument: list recent sessions for the current project directory. + +use std::path::Path; + +use async_trait::async_trait; + +use super::{CommandEffect, CommandHandler}; +use crate::app::App; + +pub struct ResumeCmd; + +#[async_trait] +impl CommandHandler for ResumeCmd { + fn name(&self) -> &str { + "/resume" + } + fn description(&self) -> &str { + "Resume a previous session" + } + fn has_arg(&self) -> bool { + true + } + async fn execute(&self, app: &mut App, arg: Option<&str>) -> CommandEffect { + match arg { + Some(partial_id) => match resolve_session_id(&app.cwd, partial_id) { + Ok(full_id) => CommandEffect::ResumeSession(full_id), + Err(msg) => { + app.session.push_system_message(msg); + CommandEffect::Done + } + }, + None => { + let text = format_project_sessions(&app.cwd) + .unwrap_or_else(|| "No previous sessions found for this project.".into()); + app.session.push_system_message(text); + CommandEffect::Done + } + } + } +} + +// ── Query ────────────────────────────────────────────────────────── + +/// Resolve a partial ID (prefix) to the full session ID. +fn resolve_session_id(cwd: &Path, partial: &str) -> Result { + let sm = loopal_runtime::SessionManager::new() + .map_err(|e| format!("Failed to access sessions: {e}"))?; + let sessions = sm + .list_sessions_for_cwd(cwd) + .map_err(|e| format!("Failed to list sessions: {e}"))?; + let matches: Vec<_> = sessions + .iter() + .filter(|s| s.id.starts_with(partial)) + .collect(); + match matches.len() { + 0 => Err(format!("No session matching '{partial}'")), + 1 => Ok(matches[0].id.clone()), + n => Err(format!("Ambiguous: {n} sessions match '{partial}'")), + } +} + +// ── Formatting ───────────────────────────────────────────────────── + +fn format_project_sessions(cwd: &Path) -> Option { + let sm = loopal_runtime::SessionManager::new().ok()?; + let sessions = sm.list_sessions_for_cwd(cwd).ok()?; + if sessions.is_empty() { + return None; + } + let mut lines = Vec::with_capacity(sessions.len().min(5) + 3); + lines.push("Recent sessions for this project:".into()); + lines.push(String::new()); + + for s in sessions.iter().take(5) { + let short_id = &s.id[..8]; + let updated = s.updated_at.format("%Y-%m-%d %H:%M"); + let title = if s.title.is_empty() { + String::new() + } else { + format!(" — {}", s.title) + }; + lines.push(format!(" {short_id} {updated} {}{title}", s.model)); + } + + lines.push(String::new()); + lines.push("To resume: /resume ".into()); + Some(lines.join("\n")) +} diff --git a/crates/loopal-tui/src/key_dispatch_ops.rs b/crates/loopal-tui/src/key_dispatch_ops.rs index 77351e2e..0a02dc59 100644 --- a/crates/loopal-tui/src/key_dispatch_ops.rs +++ b/crates/loopal-tui/src/key_dispatch_ops.rs @@ -44,6 +44,10 @@ pub(crate) async fn handle_effect(app: &mut App, effect: CommandEffect) -> bool app.exiting = true; true } + CommandEffect::ResumeSession(session_id) => { + app.session.resume_session(&session_id).await; + false + } } } diff --git a/crates/loopal-tui/src/tui_loop.rs b/crates/loopal-tui/src/tui_loop.rs index aca435b6..8c8fc23c 100644 --- a/crates/loopal-tui/src/tui_loop.rs +++ b/crates/loopal-tui/src/tui_loop.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use ratatui::prelude::*; -use loopal_protocol::AgentEvent; +use loopal_protocol::{AgentEvent, AgentEventPayload}; use loopal_session::SessionController; use loopal_tool_background::BackgroundTaskStore; use tokio::sync::mpsc; @@ -70,6 +70,13 @@ where } } AppEvent::Agent(agent_event) => { + // Load display history before handle_event processes the event, + // so the conversation view is populated before any state reset. + if let AgentEventPayload::SessionResumed { ref session_id, .. } = + agent_event.payload + { + load_resumed_display(app, session_id); + } if let Some(content) = app.session.handle_event(agent_event) { app.session.route_message(content).await; } @@ -90,3 +97,32 @@ where Ok(()) } + +/// Load display history from storage after the agent confirms a session resume. +fn load_resumed_display(app: &mut App, session_id: &str) { + let Ok(sm) = loopal_runtime::SessionManager::new() else { + return; + }; + let Ok((session, messages)) = sm.resume_session(session_id) else { + return; + }; + let projected = loopal_protocol::project_messages(&messages); + app.session.load_display_history(projected); + + // Restore sub-agent conversation views + for sub in &session.sub_agents { + let Ok(sub_msgs) = sm.load_messages(&sub.session_id) else { + continue; + }; + if sub_msgs.is_empty() { + continue; + } + app.session.load_sub_agent_history( + &sub.name, + &sub.session_id, + sub.parent.as_deref(), + sub.model.as_deref(), + loopal_protocol::project_messages(&sub_msgs), + ); + } +} diff --git a/crates/loopal-tui/tests/suite/command_test.rs b/crates/loopal-tui/tests/suite/command_test.rs index f74afda5..28be2519 100644 --- a/crates/loopal-tui/tests/suite/command_test.rs +++ b/crates/loopal-tui/tests/suite/command_test.rs @@ -12,17 +12,8 @@ fn test_registry_new_has_all_builtins() { let entries = registry.entries(); let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect(); for expected in &[ - "/plan", - "/act", - "/clear", - "/compact", - "/model", - "/rewind", - "/status", - "/sessions", - "/init", - "/help", - "/exit", + "/plan", "/act", "/clear", "/compact", "/model", "/rewind", "/status", "/resume", "/init", + "/help", "/exit", ] { assert!(names.contains(expected), "missing builtin: {expected}"); } @@ -33,6 +24,16 @@ fn test_registry_find_returns_handler() { let registry = CommandRegistry::new(); assert!(registry.find("/clear").is_some()); assert!(registry.find("/model").is_some()); + assert!(registry.find("/resume").is_some()); +} + +#[test] +fn test_registry_sessions_command_removed() { + let registry = CommandRegistry::new(); + assert!( + registry.find("/sessions").is_none(), + "/sessions was replaced by /resume" + ); } #[test] diff --git a/src/bootstrap/acp.rs b/src/bootstrap/acp.rs index 49e9bbca..53d98cc5 100644 --- a/src/bootstrap/acp.rs +++ b/src/bootstrap/acp.rs @@ -15,7 +15,7 @@ pub async fn run( ) -> anyhow::Result<()> { info!("starting in ACP mode (Hub-backed)"); - let ctx = super::hub_bootstrap::bootstrap_hub_and_agent(cli, cwd, config).await?; + let ctx = super::hub_bootstrap::bootstrap_hub_and_agent(cli, cwd, config, None).await?; // Start event broadcast let _event_loop = loopal_agent_hub::start_event_loop(ctx.hub.clone(), ctx.event_rx); diff --git a/src/bootstrap/hub_bootstrap.rs b/src/bootstrap/hub_bootstrap.rs index 8df4a4b9..a4681d2a 100644 --- a/src/bootstrap/hub_bootstrap.rs +++ b/src/bootstrap/hub_bootstrap.rs @@ -27,6 +27,7 @@ pub async fn bootstrap_hub_and_agent( cli: &Cli, cwd: &std::path::Path, config: &loopal_config::ResolvedConfig, + resume: Option<&str>, ) -> anyhow::Result { let (event_tx, event_rx) = mpsc::channel(256); let hub = Arc::new(Mutex::new(Hub::new(event_tx))); @@ -65,7 +66,7 @@ pub async fn bootstrap_hub_and_agent( prompt.as_deref(), cli.permission.as_deref(), cli.no_sandbox, - cli.resume.as_deref(), + resume, lifecycle_str, None, // root agent has no agent_type ) diff --git a/src/bootstrap/mod.rs b/src/bootstrap/mod.rs index 2e1a8d47..1408a040 100644 --- a/src/bootstrap/mod.rs +++ b/src/bootstrap/mod.rs @@ -60,7 +60,15 @@ pub async fn run() -> anyhow::Result<()> { .map(|wt| wt.info.path.clone()) .unwrap_or_else(|| cwd.clone()); - let result = multiprocess::run(&cli, &effective_cwd, &config).await; + // Resolve resume intent to a concrete session ID. + let resume_session_id = match cli.resume_intent() { + None => None, + Some(crate::cli::ResumeIntent::Specific(id)) => Some(id), + Some(crate::cli::ResumeIntent::Latest) => resolve_resume_for_cwd(&effective_cwd), + }; + + let result = + multiprocess::run(&cli, &effective_cwd, &config, resume_session_id.as_deref()).await; // Clean up worktree: remove if no changes, keep otherwise. // Note: If the process is killed by SIGKILL or panics, this cleanup won't run. @@ -108,3 +116,29 @@ fn abbreviate_home(path: &std::path::Path) -> String { } path.display().to_string() } + +/// Resolve `--resume` (no session ID) to the latest session for the given cwd. +/// Returns `Some(session_id)` if found, `None` if no matching session exists. +fn resolve_resume_for_cwd(cwd: &std::path::Path) -> Option { + let sm = match loopal_runtime::SessionManager::new() { + Ok(sm) => sm, + Err(e) => { + tracing::warn!("failed to create session manager for resume: {e}"); + return None; + } + }; + match sm.latest_session_for_cwd(cwd) { + Ok(Some(session)) => { + tracing::info!(session_id = %session.id, "auto-resuming latest session for cwd"); + Some(session.id) + } + Ok(None) => { + tracing::info!("no previous session found for cwd, starting fresh"); + None + } + Err(e) => { + tracing::warn!("failed to query sessions for resume: {e}"); + None + } + } +} diff --git a/src/bootstrap/multiprocess.rs b/src/bootstrap/multiprocess.rs index ad78dba7..9e57ac3a 100644 --- a/src/bootstrap/multiprocess.rs +++ b/src/bootstrap/multiprocess.rs @@ -14,11 +14,12 @@ pub async fn run( cli: &Cli, cwd: &std::path::Path, config: &loopal_config::ResolvedConfig, + resume: Option<&str>, ) -> anyhow::Result<()> { info!("starting in Hub mode"); // 1-3. Create Hub + spawn root agent - let ctx = super::hub_bootstrap::bootstrap_hub_and_agent(cli, cwd, config).await?; + let ctx = super::hub_bootstrap::bootstrap_hub_and_agent(cli, cwd, config, resume).await?; let root_session_id = ctx.root_session_id.clone(); // 4. Start event broadcast @@ -56,7 +57,7 @@ pub async fn run( // 9. Load display history or show welcome let session_manager = loopal_runtime::SessionManager::new()?; - if let Some(ref sid) = cli.resume { + if let Some(sid) = resume { if let Ok((session, messages)) = session_manager.resume_session(sid) { session_ctrl.load_display_history(project_messages(&messages)); super::sub_agent_resume::load_sub_agent_histories( diff --git a/src/bootstrap/server_mode.rs b/src/bootstrap/server_mode.rs index 25d46c86..1f05526f 100644 --- a/src/bootstrap/server_mode.rs +++ b/src/bootstrap/server_mode.rs @@ -21,7 +21,7 @@ pub async fn run( ) -> anyhow::Result<()> { info!("starting in server mode (ephemeral={})", cli.ephemeral); - let ctx = super::hub_bootstrap::bootstrap_hub_and_agent(cli, cwd, config).await?; + let ctx = super::hub_bootstrap::bootstrap_hub_and_agent(cli, cwd, config, None).await?; let _event_loop = loopal_agent_hub::start_event_loop(ctx.hub.clone(), ctx.event_rx); let ui_session = UiSession::connect(ctx.hub.clone(), "server").await; info!("server client connected to Hub"); diff --git a/src/bootstrap/sub_agent_resume.rs b/src/bootstrap/sub_agent_resume.rs index 7ad0364d..9a08e6b3 100644 --- a/src/bootstrap/sub_agent_resume.rs +++ b/src/bootstrap/sub_agent_resume.rs @@ -28,29 +28,13 @@ pub fn load_sub_agent_histories( if messages.is_empty() { continue; } - let display_msgs = project_messages(&messages) - .into_iter() - .map(loopal_session::into_session_message) - .collect(); - let mut state = session_ctrl.lock(); - let agent = state.agents.entry(sub_ref.name.clone()).or_default(); - agent.parent = sub_ref.parent.clone(); - agent.session_id = Some(sub_ref.session_id.clone()); - if let Some(ref m) = sub_ref.model { - agent.observable.model = m.clone(); - } - agent.conversation.messages = display_msgs; - agent.conversation.agent_idle = true; - agent.observable.status = loopal_protocol::AgentStatus::Finished; - // Register as child of parent - if let Some(ref parent_name) = sub_ref.parent - && let Some(parent_agent) = state.agents.get_mut(parent_name) - { - let child_name = sub_ref.name.clone(); - if !parent_agent.children.contains(&child_name) { - parent_agent.children.push(child_name); - } - } + session_ctrl.load_sub_agent_history( + &sub_ref.name, + &sub_ref.session_id, + sub_ref.parent.as_deref(), + sub_ref.model.as_deref(), + project_messages(&messages), + ); } } diff --git a/src/cli.rs b/src/cli.rs index 22bc74a1..e8cc141a 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,5 +1,13 @@ use clap::Parser; +/// Parsed resume intent — hides the clap-level empty-string sentinel. +pub enum ResumeIntent { + /// `--resume` (no ID): auto-find latest session for current directory. + Latest, + /// `--resume `: resume a specific session. + Specific(String), +} + #[derive(Parser)] #[command(name = "loopal", about = "AI coding agent", version = "0.1.0")] pub struct Cli { @@ -7,9 +15,9 @@ pub struct Cli { #[arg(short, long)] pub model: Option, - /// Resume a previous session - #[arg(short, long)] - pub resume: Option, + /// Resume a previous session (by ID, or latest for current directory if no ID given) + #[arg(short, long, num_args = 0..=1, default_missing_value = "")] + resume: Option, /// Permission mode #[arg(short = 'P', long)] @@ -65,6 +73,16 @@ pub struct Cli { } impl Cli { + /// Parse the raw `--resume` flag into a typed intent. + /// Encapsulates the clap-level `default_missing_value = ""` convention. + pub fn resume_intent(&self) -> Option { + match self.resume.as_deref() { + None => None, + Some("") => Some(ResumeIntent::Latest), + Some(id) => Some(ResumeIntent::Specific(id.to_string())), + } + } + /// Apply CLI flags to settings, overriding config-file values. pub fn apply_overrides(&self, settings: &mut loopal_config::Settings) { if let Some(model) = &self.model { @@ -82,3 +100,52 @@ impl Cli { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn cli_with_resume(resume: Option) -> Cli { + Cli { + model: None, + resume, + permission: None, + plan: false, + no_sandbox: false, + acp: false, + server: false, + ephemeral: false, + serve: false, + worktree: false, + test_provider: None, + meta_hub: None, + join_hub: None, + hub_name: None, + prompt: vec![], + } + } + + #[test] + fn test_resume_intent_none_when_no_flag() { + let cli = cli_with_resume(None); + assert!(cli.resume_intent().is_none()); + } + + #[test] + fn test_resume_intent_latest_when_empty_string() { + let cli = cli_with_resume(Some(String::new())); + let intent = cli.resume_intent().expect("should be Some"); + assert!(matches!(intent, ResumeIntent::Latest)); + } + + #[test] + fn test_resume_intent_specific_when_id_given() { + let cli = cli_with_resume(Some("abc-123".into())); + let intent = cli.resume_intent().expect("should be Some"); + if let ResumeIntent::Specific(id) = intent { + assert_eq!(id, "abc-123"); + } else { + panic!("expected ResumeIntent::Specific"); + } + } +}