Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/loopal-acp/src/translate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand Down
2 changes: 2 additions & 0 deletions crates/loopal-protocol/src/control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
6 changes: 6 additions & 0 deletions crates/loopal-protocol/src/event_payload.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions crates/loopal-protocol/tests/suite/control_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
20 changes: 20 additions & 0 deletions crates/loopal-protocol/tests/suite/event_edge_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
3 changes: 3 additions & 0 deletions crates/loopal-runtime/src/agent_loop/input_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
1 change: 1 addition & 0 deletions crates/loopal-runtime/src/agent_loop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 56 additions & 0 deletions crates/loopal-runtime/src/agent_loop/resume_session.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
}
12 changes: 12 additions & 0 deletions crates/loopal-runtime/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<Session>> {
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<Vec<Session>> {
let sessions = self.session_store.list_sessions_for_cwd(cwd)?;
Ok(sessions)
}

/// List all sessions.
pub fn list_sessions(&self) -> Result<Vec<Session>> {
let sessions = self.session_store.list_sessions()?;
Expand Down
68 changes: 68 additions & 0 deletions crates/loopal-runtime/tests/suite/session_manager_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
3 changes: 2 additions & 1 deletion crates/loopal-session/src/agent_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions crates/loopal-session/src/controller_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
5 changes: 5 additions & 0 deletions crates/loopal-session/src/event_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ pub fn apply_event(state: &mut SessionState, event: AgentEvent) -> Option<UserCo
}
}

// Track new root session ID on resume
if let AgentEventPayload::SessionResumed { ref session_id, .. } = event.payload {
state.root_session_id = Some(session_id.clone());
}

// Unified: route to agent conversation by name (root = "main")
let name = event.agent_name.unwrap_or_else(|| ROOT_AGENT.into());
crate::agent_handler::apply_agent_event(state, &name, event.payload)
Expand Down
36 changes: 35 additions & 1 deletion crates/loopal-session/src/session_display.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Session display state operations: messages, welcome, history, inbox.

use loopal_protocol::{ProjectedMessage, UserContent};
use loopal_protocol::{AgentStatus, ProjectedMessage, UserContent};

use crate::controller::SessionController;
use crate::conversation_display::push_system_msg;
Expand Down Expand Up @@ -45,6 +45,40 @@ impl SessionController {
.conversation;
conv.messages = session_msgs;
}

/// Load a sub-agent's display history from pre-projected messages.
///
/// Creates the agent entry if it doesn't exist, sets parent/child
/// relationships, and marks the agent as finished (historical data).
pub fn load_sub_agent_history(
&self,
name: &str,
session_id: &str,
parent: Option<&str>,
model: Option<&str>,
projected: Vec<ProjectedMessage>,
) {
let display_msgs: Vec<SessionMessage> =
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).
Expand Down
4 changes: 4 additions & 0 deletions crates/loopal-session/tests/suite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading
Loading