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
8 changes: 6 additions & 2 deletions crates/core/src/conversation/native_replay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down Expand Up @@ -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(),
Expand Down
6 changes: 6 additions & 0 deletions crates/core/src/conversation/rollout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::PermissionPreset>,
/// Display path for a spawned agent (for example `root/reviewer`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_path: Option<String>,
/// Display nickname for a spawned agent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_nickname: Option<String>,
/// 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<String>,
Expand Down
2 changes: 2 additions & 0 deletions crates/core/src/conversation/rollout_write.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/server/src/persistence.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
122 changes: 120 additions & 2 deletions crates/server/src/replay_hydrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -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::<chrono::Utc>::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::<chrono::Utc>::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::<chrono::Utc>::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)]
Expand Down
10 changes: 8 additions & 2 deletions crates/server/src/runtime/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
108 changes: 108 additions & 0 deletions crates/server/src/runtime/agents/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<devo_protocol::native::rpc_session::SessionNewResult>,
>(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()?;
Expand Down
2 changes: 2 additions & 0 deletions crates/stress-tools/src/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))?;
Expand Down
Loading