diff --git a/crates/core/src/conversation/native_replay.rs b/crates/core/src/conversation/native_replay.rs index 79b30ae91..a89ecc89c 100644 --- a/crates/core/src/conversation/native_replay.rs +++ b/crates/core/src/conversation/native_replay.rs @@ -148,7 +148,11 @@ pub fn session_record_from_native( // Canonical Session has no path string; use a non-empty sentinel // so SQLite + row parsers keep subagents off the default list // (`""` was previously collapsed to NULL on read). - Some(role.clone().unwrap_or_else(|| "subagent".to_string())), + Some( + extras + .and_then(|extras| extras.agent_path.clone()) + .unwrap_or_else(|| role.clone().unwrap_or_else(|| "subagent".to_string())), + ), None, None, ), @@ -190,7 +194,7 @@ pub fn session_record_from_native( .map(|extras| extras.source.clone()) .unwrap_or_default(), // Nickname is not modeled on canonical `SessionParent`. - agent_nickname: None, + agent_nickname: extras.and_then(|extras| extras.agent_nickname.clone()), agent_role, agent_path, model_provider: session.model.provider.clone(), diff --git a/crates/core/src/conversation/rollout.rs b/crates/core/src/conversation/rollout.rs index 8615ddd04..03625e2f7 100644 --- a/crates/core/src/conversation/rollout.rs +++ b/crates/core/src/conversation/rollout.rs @@ -43,6 +43,12 @@ pub struct SessionPersistenceExtras { /// Session-level permission preset override, when set. #[serde(default, skip_serializing_if = "Option::is_none")] pub permission_preset: Option, + /// Display path for a spawned agent (for example `root/reviewer`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_path: Option, + /// Display nickname for a spawned agent. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_nickname: Option, /// Relative path to the kernel dill snapshot under the session dir (RLM). #[serde(default, skip_serializing_if = "Option::is_none")] pub kernel_snapshot_path: Option, diff --git a/crates/core/src/conversation/rollout_write.rs b/crates/core/src/conversation/rollout_write.rs index 17d9f2b19..cff043c3f 100644 --- a/crates/core/src/conversation/rollout_write.rs +++ b/crates/core/src/conversation/rollout_write.rs @@ -168,6 +168,8 @@ pub fn session_persistence_extras_from_record(record: &SessionRecord) -> Session source: record.source.clone(), collaboration_mode: record.collaboration_mode, permission_preset: record.permission_preset, + agent_path: record.agent_path.clone(), + agent_nickname: record.agent_nickname.clone(), kernel_snapshot_path: None, } } diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index 35e7e3120..84cfbda9d 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -90,6 +90,8 @@ pub(crate) fn invent_session_persistence_extras() -> SessionPersistenceExtras { source: "cli".into(), collaboration_mode: None, permission_preset: None, + agent_path: None, + agent_nickname: None, kernel_snapshot_path: None, } } diff --git a/crates/server/src/replay_hydrate.rs b/crates/server/src/replay_hydrate.rs index 4ad6339aa..ec71f2744 100644 --- a/crates/server/src/replay_hydrate.rs +++ b/crates/server/src/replay_hydrate.rs @@ -41,14 +41,29 @@ impl ReplayedSession { source: String::new(), collaboration_mode: None, permission_preset: None, + agent_path: None, + agent_nickname: None, kernel_snapshot_path: None, }); + let is_agent = matches!( + &native.parent, + Some(devo_protocol::native::session::SessionParent::Agent { .. }) + ); + let agent_path = is_agent.then(|| extras.agent_path.clone()).flatten(); + let agent_nickname = is_agent + .then(|| { + extras + .agent_nickname + .clone() + .or_else(|| native.title.clone()) + }) + .flatten(); Self { native, extras, updated_at, - agent_path: None, - agent_nickname: None, + agent_path, + agent_nickname, } } @@ -64,6 +79,109 @@ impl ReplayedSession { } } +#[cfg(test)] +mod tests { + use chrono::DateTime; + use devo_protocol::native::model::{ModelBinding, PermissionProfile}; + use devo_protocol::native::session::SessionParent; + use devo_protocol::native::session::{ + Session, SessionActivity, SessionSettings, SessionStatus, + }; + use devo_protocol::native::usage::{SessionUsage, UsageTotals}; + + use super::ReplayedSession; + + fn native_agent_session() -> Session { + let now = DateTime::::UNIX_EPOCH; + Session { + id: devo_protocol::native::ids::SessionId::new(), + version: 1, + cwd: std::path::PathBuf::new(), + additional_directories: Vec::new(), + parent: Some(SessionParent::Agent { + session_id: devo_protocol::native::ids::SessionId::new(), + role: Some("default".into()), + }), + fork_from_id: None, + at_turn_id: None, + ephemeral: false, + created_at: now, + status: SessionStatus::Idle, + flags: Vec::new(), + archived: false, + activity: SessionActivity::Idle, + active_turn_id: None, + queued_count: 0, + title: Some("reviewer".into()), + title_state: Default::default(), + model: ModelBinding { + provider: "test".into(), + model: "test".into(), + variant: None, + reasoning_effort: None, + }, + settings: SessionSettings { + permission_profile: PermissionProfile::AutoReview, + reasoning_effort: None, + mode: None, + sandbox_profile: None, + effective_context_window: None, + auto_refine_enabled: None, + auto_refine_turn_interval: None, + python_cell_first_wait_ms: None, + }, + git_info: None, + preview: String::new(), + last_activity_at: now, + transcript_size_bytes: None, + message_count: None, + summary: None, + task_state: None, + usage: SessionUsage { + total: UsageTotals::default(), + by_purpose: Vec::new(), + legacy: None, + updated_at: now, + }, + } + } + + #[test] + fn native_agent_identity_survives_replay() { + let native = native_agent_session(); + let extras = devo_core::SessionPersistenceExtras { + session_context: None, + cli_version: String::new(), + source: String::new(), + collaboration_mode: None, + permission_preset: None, + agent_path: Some("root/planner/reviewer".into()), + agent_nickname: Some("reviewer".into()), + kernel_snapshot_path: None, + }; + + let replayed = + ReplayedSession::from_native(native, Some(extras), DateTime::::UNIX_EPOCH); + + assert_eq!( + replayed.agent_path.as_deref(), + Some("root/planner/reviewer") + ); + assert_eq!(replayed.agent_nickname.as_deref(), Some("reviewer")); + } + + #[test] + fn legacy_native_agent_uses_title_as_nickname() { + let native = native_agent_session(); + + let replayed = + ReplayedSession::from_native(native, None, DateTime::::UNIX_EPOCH); + + assert_eq!(replayed.agent_path, None); + assert_eq!(replayed.agent_nickname.as_deref(), Some("reviewer")); + } +} + /// Turn snapshot accumulated during Native-first resume (and kept live for /// fork/rollback cuts). #[derive(Debug, Clone, PartialEq)] diff --git a/crates/server/src/runtime/agents.rs b/crates/server/src/runtime/agents.rs index e18a10114..cfec2d8a0 100644 --- a/crates/server/src/runtime/agents.rs +++ b/crates/server/src/runtime/agents.rs @@ -171,6 +171,8 @@ impl ServerRuntime { .map_err(|error| ToolCallError::InternalError(error.to_string()))?; invented.extras.collaboration_mode = Some(parent_summary.collaboration_mode); invented.extras.permission_preset = parent_summary.permission_preset(); + invented.extras.agent_path = Some(agent_path.clone()); + invented.extras.agent_nickname = Some(nickname.clone()); self.rollout_store .append_session_meta_at( &invented.rollout_path, @@ -476,8 +478,12 @@ impl ServerRuntime { .clone() .or_else(|| summary.native.title.clone()) .unwrap_or_else(|| child_session_id.to_string()); - let parent_path = self.session_agent_path(parent_session_id).await; - let agent_path = AgentPath::new(parent_path).join(&nickname).0; + let agent_path = if let Some(agent_path) = &summary.agent_path { + agent_path.clone() + } else { + let parent_path = self.session_agent_path(parent_session_id).await; + AgentPath::new(parent_path).join(&nickname).0 + }; let status = runtime_session .latest_turn .as_ref() diff --git a/crates/server/src/runtime/agents/lifecycle.rs b/crates/server/src/runtime/agents/lifecycle.rs index 11b81032f..c91acda25 100644 --- a/crates/server/src/runtime/agents/lifecycle.rs +++ b/crates/server/src/runtime/agents/lifecycle.rs @@ -185,10 +185,13 @@ fn subagent_hook_extra( mod tests { use super::*; + use anyhow::Context; use anyhow::Result; use devo_core::tools::AgentToolCoordinator; use devo_protocol::AgentInfo; use devo_protocol::AgentListParams; + use devo_protocol::AgentToolPolicy; + use devo_protocol::SpawnAgentParams; use devo_protocol::SuccessResponse; use devo_protocol::WaitAgentParams; use pretty_assertions::assert_eq; @@ -286,6 +289,111 @@ mod tests { Ok(()) } + #[tokio::test] + async fn nested_agent_path_survives_cold_child_hydration() -> Result<()> { + let data_root = TempDir::new()?; + let runtime = build_runtime(data_root.path())?; + let (notifications_tx, _notifications_rx) = crate::test_outbound_channel(32); + let connection_id = runtime + .register_connection(crate::ClientTransportKind::Stdio, notifications_tx) + .await; + runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": 1, + "clientCapabilities": {}, + "_meta": { "devo": { "protocol": "native" } }, + "clientInfo": { "name": "test", "title": "test", "version": "1.0.0" } + } + }), + ) + .await + .context("initialize response")?; + let created = runtime + .handle_incoming( + connection_id, + serde_json::json!({ + "id": 2, + "method": "session/new", + "params": { + "cwd": data_root.path(), + "idempotencyKey": "nested-agent-path-parent" + } + }), + ) + .await + .context("session/new response")?; + let parent_session_id = serde_json::from_value::< + SuccessResponse, + >(created)? + .result + .session + .id; + + let child = Arc::clone(&runtime) + .spawn_agent(SpawnAgentParams { + session_id: parent_session_id, + message: "spawn a nested worker".into(), + fork_turns: Some("none".into()), + max_turns: None, + tool_policy: AgentToolPolicy::Inherit, + ephemeral: false, + nickname: Some("planner".into()), + }) + .await?; + let grandchild = Arc::clone(&runtime) + .spawn_agent(SpawnAgentParams { + session_id: child.child_session_id, + message: "verify a nested path".into(), + fork_turns: Some("none".into()), + max_turns: None, + tool_policy: AgentToolPolicy::Inherit, + ephemeral: false, + nickname: Some("reviewer".into()), + }) + .await?; + assert_eq!(child.agent_path, "root/planner"); + assert_eq!(grandchild.agent_path, "root/planner/reviewer"); + + runtime + .agent_registries + .lock() + .await + .remove(&parent_session_id); + runtime + .agent_registries + .lock() + .await + .remove(&child.child_session_id); + let mut sessions = runtime.sessions.lock().await; + sessions.remove(&child.child_session_id); + sessions.remove(&grandchild.child_session_id); + drop(sessions); + + let restored = runtime + .get_or_load_parent_session(grandchild.child_session_id) + .await?; + assert_eq!( + restored + .summary() + .await + .expect("restored summary") + .agent_path, + Some("root/planner/reviewer".into()) + ); + let (_, restored_path) = runtime + .child_parent_and_path(grandchild.child_session_id) + .await + .expect("restored grandchild linkage"); + assert_eq!(restored_path, "root/planner/reviewer"); + + Ok(()) + } + #[tokio::test] async fn rlm_list_subagents_reports_children_in_kernel_payload_shape() -> Result<()> { let data_root = TempDir::new()?; diff --git a/crates/stress-tools/src/generate.rs b/crates/stress-tools/src/generate.rs index 52c0af7b1..7cd5f2542 100644 --- a/crates/stress-tools/src/generate.rs +++ b/crates/stress-tools/src/generate.rs @@ -357,6 +357,8 @@ fn write_session_transcript( source: "stress".into(), collaboration_mode: None, permission_preset: None, + agent_path: None, + agent_nickname: None, kernel_snapshot_path: None, }; write_line(writer, &session_line(session, Some(extras), base))?;