diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 25c51535..de5b4504 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -318,6 +318,16 @@ pub struct SubAgentResult { pub result: Option, pub steps_taken: u32, pub duration_ms: u64, + /// `true` when this agent was loaded from a prior-session persisted + /// state file rather than spawned in the current session (#405). + /// Lets `agent_list` filter out historical noise by default while + /// keeping the records reachable via `include_archived=true`. + #[serde(default, skip_serializing_if = "is_false")] + pub from_prior_session: bool, +} + +fn is_false(b: &bool) -> bool { + !*b } #[derive(Debug, Clone, Default)] @@ -405,6 +415,14 @@ struct PersistedSubAgent { duration_ms: u64, allowed_tools: Vec, updated_at_ms: u64, + /// Stable id of the manager / process boot that spawned this agent + /// (#405). Lets a fresh manager filter out agents that were + /// persisted by a prior session. Optional with `#[serde(default)]` + /// for backward compatibility — older records lack the field and + /// load with an empty string, which the manager treats as + /// "from_prior_session" because it can't match any current id. + #[serde(default)] + session_boot_id: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -587,12 +605,17 @@ pub struct SubAgent { /// `None` = full registry inheritance (v0.6.6 default). /// `Some(list)` = explicit narrow allowlist (Custom agents, legacy). pub allowed_tools: Option>, + /// Stable id of the manager that spawned this agent (#405). Compared + /// against the manager's `current_session_boot_id` to classify the + /// agent as in-session vs prior-session at list time. + pub session_boot_id: String, input_tx: Option>, task_handle: Option>, } impl SubAgent { /// Create a new sub-agent. + #[allow(clippy::too_many_arguments)] fn new( agent_type: SubAgentType, prompt: String, @@ -601,6 +624,7 @@ impl SubAgent { nickname: Option, allowed_tools: Option>, input_tx: mpsc::UnboundedSender, + session_boot_id: String, ) -> Self { let id = format!("agent_{}", &Uuid::new_v4().to_string()[..8]); @@ -616,6 +640,7 @@ impl SubAgent { steps_taken: 0, started_at: Instant::now(), allowed_tools, + session_boot_id, input_tx: Some(input_tx), task_handle: None, } @@ -634,6 +659,11 @@ impl SubAgent { result: self.result.clone(), steps_taken: self.steps_taken, duration_ms: u64::try_from(self.started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + // Snapshots from the agent itself don't know the manager's + // current boot id, so default to false. The manager fills + // this in when it produces a snapshot via its own + // `snapshot_for_listing` helper (#405). + from_prior_session: false, } } } @@ -646,6 +676,13 @@ pub struct SubAgentManager { state_path: Option, max_steps: u32, max_agents: usize, + /// Stable id assigned at manager construction (#405). Stamped on + /// every agent the manager spawns; agents loaded from the + /// persisted state file carry whatever id the prior session + /// stamped (or empty for pre-#405 records). The manager classifies + /// agents whose `session_boot_id` doesn't match this value as + /// "from prior session" so `agent_list` can hide them by default. + current_session_boot_id: String, } impl SubAgentManager { @@ -658,9 +695,27 @@ impl SubAgentManager { state_path: None, max_steps: DEFAULT_MAX_STEPS, max_agents, + // Fresh boot id per manager. Used by #405 to classify + // re-loaded persisted agents as "prior session". + current_session_boot_id: format!("boot_{}", &Uuid::new_v4().to_string()[..12]), } } + /// Return the boot id this manager stamps on agents it spawns. + /// Exposed for tests; internal callers use the field directly. + #[cfg(test)] + pub fn session_boot_id(&self) -> &str { + &self.current_session_boot_id + } + + /// Classify an agent by its `session_boot_id`: `true` when the + /// agent was either (a) loaded from disk with no id, or (b) carries + /// a different id than the manager's current boot. Filters + /// `agent_list` output by default (#405). + fn is_from_prior_session(&self, agent: &SubAgent) -> bool { + agent.session_boot_id.is_empty() || agent.session_boot_id != self.current_session_boot_id + } + #[must_use] fn with_state_path(mut self, path: PathBuf) -> Self { self.state_path = Some(path); @@ -690,6 +745,7 @@ impl SubAgentManager { // Reload converts empty vec back to None (full inheritance). allowed_tools: agent.allowed_tools.clone().unwrap_or_default(), updated_at_ms: now_ms, + session_boot_id: agent.session_boot_id.clone(), }); } agents.sort_by(|a, b| a.id.cmp(&b.id)); @@ -755,6 +811,10 @@ impl SubAgentManager { steps_taken: persisted.steps_taken, started_at, allowed_tools, + // Empty string when loading pre-#405 records; the + // manager treats that the same as a non-matching id — + // i.e. agent classified as prior-session. + session_boot_id: persisted.session_boot_id, input_tx: None, task_handle: None, }; @@ -863,6 +923,7 @@ impl SubAgentManager { nickname, tools.clone(), input_tx, + self.current_session_boot_id.clone(), ); let agent_id = agent.id.clone(); let started_at = agent.started_at; @@ -1147,8 +1208,50 @@ impl SubAgentManager { /// List all agents and their status. #[must_use] + /// Snapshot a single agent and tag it with the manager's + /// classification. The bare `SubAgent::snapshot` defaults + /// `from_prior_session` to `false`; only the manager knows the + /// matching boot id, so listing goes through here. + fn snapshot_for_listing(&self, agent: &SubAgent) -> SubAgentResult { + let mut snap = agent.snapshot(); + snap.from_prior_session = self.is_from_prior_session(agent); + snap + } + + /// List all agents currently held by the manager, regardless of + /// session origin. Use [`Self::list_filtered`] in user-facing tool + /// paths so prior-session agents stay hidden by default (#405). pub fn list(&self) -> Vec { - self.agents.values().map(SubAgent::snapshot).collect() + self.agents + .values() + .map(|agent| self.snapshot_for_listing(agent)) + .collect() + } + + /// List agents respecting the session-boundary filter (#405). + /// + /// `include_archived = false` (the default for `agent_list`) drops + /// any prior-session agent that is no longer running. Prior-session + /// agents that are still `Running` (e.g. interrupted by a process + /// restart) stay visible — they may matter for ongoing recovery. + /// + /// `include_archived = true` returns everything, with the + /// `from_prior_session` flag on each `SubAgentResult` so the model + /// can tell active and archived apart at a glance. + pub fn list_filtered(&self, include_archived: bool) -> Vec { + self.agents + .values() + .filter(|agent| { + if include_archived { + return true; + } + if agent.status == SubAgentStatus::Running { + return true; + } + !self.is_from_prior_session(agent) + }) + .map(|agent| self.snapshot_for_listing(agent)) + .collect() } /// Clean up completed agents older than the given duration. @@ -1767,14 +1870,22 @@ impl ToolSpec for AgentListTool { } fn description(&self) -> &'static str { - "List all active and recently completed sub-agents with their status, type, assignment, \ - steps taken, and duration." + "List sub-agents from the current session with their status, type, assignment, steps, \ + and duration. Pass `include_archived=true` to also see agents that were spawned in a \ + prior session (e.g. before the TUI restarted) and persisted on disk; those carry \ + `from_prior_session: true` in the result. Default is the current-session view because \ + prior-session agents almost never matter for the live turn." } fn input_schema(&self) -> Value { json!({ "type": "object", - "properties": {} + "properties": { + "include_archived": { + "type": "boolean", + "description": "When true, include agents from prior sessions in the listing. Default false." + } + } }) } @@ -1782,14 +1893,14 @@ impl ToolSpec for AgentListTool { vec![ToolCapability::ReadOnly] } - async fn execute( - &self, - _input: Value, - _context: &ToolContext, - ) -> Result { + async fn execute(&self, input: Value, _context: &ToolContext) -> Result { + let include_archived = input + .get("include_archived") + .and_then(Value::as_bool) + .unwrap_or(false); let mut manager = self.manager.write().await; manager.cleanup(COMPLETED_AGENT_RETENTION); - let results = manager.list(); + let results = manager.list_filtered(include_archived); ToolResult::json(&results).map_err(|e| ToolError::execution_failed(e.to_string())) } } @@ -2431,6 +2542,7 @@ async fn run_subagent( result: None, steps_taken: steps, duration_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + from_prior_session: false, }); } @@ -2504,6 +2616,7 @@ async fn run_subagent( steps_taken: steps, duration_ms: u64::try_from(started_at.elapsed().as_millis()) .unwrap_or(u64::MAX), + from_prior_session: false, }); } api = tokio::time::timeout(STEP_API_TIMEOUT, runtime.client.create_message(request)) => { @@ -2637,6 +2750,7 @@ async fn run_subagent( result: final_result, steps_taken: steps, duration_ms: u64::try_from(started_at.elapsed().as_millis()).unwrap_or(u64::MAX), + from_prior_session: false, }) } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 754ba29f..4c5b82f6 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -16,6 +16,7 @@ fn make_snapshot(status: SubAgentStatus) -> SubAgentResult { result: None, steps_taken: 0, duration_ms: 0, + from_prior_session: false, } } @@ -310,6 +311,7 @@ async fn test_wait_for_result_reports_timeout_when_still_running() { Some("Blue".to_string()), Some(vec!["read_file".to_string()]), input_tx, + "boot_test".to_string(), ); let agent_id = agent.id.clone(); { @@ -336,6 +338,7 @@ async fn test_running_count_counts_only_agents_with_live_task_handles() { Some("Blue".to_string()), Some(vec!["read_file".to_string()]), input_tx, + "boot_test".to_string(), ); agent.status = SubAgentStatus::Running; let handle = tokio::spawn(async { @@ -366,6 +369,7 @@ fn test_running_count_ignores_running_status_without_task_handle() { Some("Blue".to_string()), Some(vec!["read_file".to_string()]), input_tx, + "boot_test".to_string(), ); agent.status = SubAgentStatus::Running; manager.agents.insert(agent.id.clone(), agent); @@ -385,6 +389,7 @@ async fn test_running_count_ignores_finished_task_handles() { Some("Blue".to_string()), Some(vec!["read_file".to_string()]), input_tx, + "boot_test".to_string(), ); agent.status = SubAgentStatus::Running; let handle = tokio::spawn(async {}); @@ -412,6 +417,7 @@ fn test_assign_updates_running_agent_and_sends_message() { Some("Blue".to_string()), Some(vec!["read_file".to_string()]), input_tx, + "boot_test".to_string(), ); let agent_id = agent.id.clone(); manager.agents.insert(agent_id.clone(), agent); @@ -448,6 +454,7 @@ fn test_assign_rejects_message_for_non_running_agent() { Some("Blue".to_string()), Some(vec!["read_file".to_string()]), input_tx, + "boot_test".to_string(), ); agent.status = SubAgentStatus::Completed; let agent_id = agent.id.clone(); @@ -471,6 +478,7 @@ fn test_assign_updates_non_running_metadata_without_message() { Some("Blue".to_string()), Some(vec!["read_file".to_string()]), input_tx, + "boot_test".to_string(), ); agent.status = SubAgentStatus::Completed; let agent_id = agent.id.clone(); @@ -505,6 +513,7 @@ fn test_persist_and_reload_marks_running_agent_as_interrupted() { Some("Blue".to_string()), Some(vec!["read_file".to_string()]), input_tx, + "boot_test".to_string(), ); let running_id = running.id.clone(); manager.agents.insert(running_id.clone(), running); @@ -1000,3 +1009,184 @@ fn stub_client() -> DeepSeekClient { }; DeepSeekClient::new(&config).expect("stub client should construct") } + +// ---- #405 session-boundary classification ---- +// +// Each manager assigns a fresh session_boot_id; agents stamp the id at +// spawn time. After persist + reload by a *new* manager, those agents +// carry the prior boot id and are classified as `from_prior_session`. +// `agent_list` defaults to current-session only; `include_archived=true` +// surfaces the prior-session records with the flag set. + +fn insert_prior_session_agent( + manager: &mut SubAgentManager, + id: &str, + status: SubAgentStatus, + boot_id: &str, +) { + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut agent = SubAgent::new( + SubAgentType::General, + "old prompt".to_string(), + make_assignment(), + "deepseek-v4-flash".to_string(), + None, + None, + input_tx, + boot_id.to_string(), + ); + agent.status = status; + agent.id = id.to_string(); + manager.agents.insert(id.to_string(), agent); +} + +#[test] +fn session_boot_ids_are_unique_per_manager() { + let a = SubAgentManager::new(PathBuf::from("."), 1); + let b = SubAgentManager::new(PathBuf::from("."), 1); + assert_ne!(a.session_boot_id(), b.session_boot_id()); +} + +#[test] +fn list_filtered_drops_prior_session_terminals_by_default() { + let mut manager = SubAgentManager::new(PathBuf::from("."), 5); + let current_boot = manager.session_boot_id().to_string(); + insert_prior_session_agent( + &mut manager, + "current_running", + SubAgentStatus::Running, + ¤t_boot, + ); + insert_prior_session_agent( + &mut manager, + "prior_completed", + SubAgentStatus::Completed, + "boot_old_session", + ); + insert_prior_session_agent( + &mut manager, + "prior_running", + SubAgentStatus::Running, + "boot_old_session", + ); + + let listed = manager.list_filtered(false); + let ids: Vec<&str> = listed.iter().map(|s| s.agent_id.as_str()).collect(); + assert!(ids.contains(&"current_running"), "{ids:?}"); + assert!( + ids.contains(&"prior_running"), + "still-running prior-session agents stay visible: {ids:?}" + ); + assert!( + !ids.contains(&"prior_completed"), + "completed prior-session agents are hidden by default: {ids:?}" + ); + + let prior = listed + .iter() + .find(|s| s.agent_id == "prior_running") + .unwrap(); + assert!(prior.from_prior_session); + let current = listed + .iter() + .find(|s| s.agent_id == "current_running") + .unwrap(); + assert!(!current.from_prior_session); +} + +#[test] +fn list_filtered_with_include_archived_returns_everything() { + let mut manager = SubAgentManager::new(PathBuf::from("."), 5); + let current_boot = manager.session_boot_id().to_string(); + insert_prior_session_agent( + &mut manager, + "current_done", + SubAgentStatus::Completed, + ¤t_boot, + ); + insert_prior_session_agent( + &mut manager, + "prior_done", + SubAgentStatus::Completed, + "boot_old", + ); + insert_prior_session_agent( + &mut manager, + "prior_failed", + SubAgentStatus::Failed("boom".to_string()), + "boot_old", + ); + + let listed = manager.list_filtered(true); + assert_eq!(listed.len(), 3, "{listed:?}"); + let prior = listed.iter().find(|s| s.agent_id == "prior_done").unwrap(); + assert!(prior.from_prior_session); + let current = listed + .iter() + .find(|s| s.agent_id == "current_done") + .unwrap(); + assert!(!current.from_prior_session); +} + +#[test] +fn agents_with_empty_boot_id_classify_as_prior_session() { + // Records persisted before #405 land with an empty `session_boot_id` + // due to `#[serde(default)]`. The manager treats those the same as + // a non-matching id — i.e. prior session. + let mut manager = SubAgentManager::new(PathBuf::from("."), 5); + insert_prior_session_agent(&mut manager, "legacy", SubAgentStatus::Completed, ""); + + let listed_default = manager.list_filtered(false); + assert!( + listed_default.iter().all(|s| s.agent_id != "legacy"), + "legacy completed agents are hidden by default" + ); + + let listed_archived = manager.list_filtered(true); + let legacy = listed_archived + .iter() + .find(|s| s.agent_id == "legacy") + .unwrap(); + assert!(legacy.from_prior_session); +} + +#[test] +fn persist_round_trip_preserves_session_boot_id() { + let dir = tempdir().expect("tempdir"); + let state_path = dir.path().join(SUBAGENT_STATE_FILE); + + let original_boot; + { + let mut writer = + SubAgentManager::new(dir.path().to_path_buf(), 2).with_state_path(state_path.clone()); + original_boot = writer.session_boot_id().to_string(); + insert_prior_session_agent( + &mut writer, + "agent_persist", + SubAgentStatus::Completed, + &original_boot, + ); + writer + .persist_state() + .expect("persist round-trip should write"); + } + + // A fresh manager comes up with a *different* boot id and reloads + // the persisted state; the agent should now be classified prior. + let mut reader = + SubAgentManager::new(dir.path().to_path_buf(), 2).with_state_path(state_path.clone()); + reader.load_state().expect("reload should succeed"); + assert_ne!(reader.session_boot_id(), original_boot); + + let listed_default = reader.list_filtered(false); + assert!( + !listed_default.iter().any(|s| s.agent_id == "agent_persist"), + "completed prior-session agent hidden after reload: {listed_default:?}" + ); + let listed_all = reader.list_filtered(true); + let snap = listed_all + .iter() + .find(|s| s.agent_id == "agent_persist") + .unwrap(); + assert!(snap.from_prior_session); +} diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 0b6687fb..d1377fbb 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -777,6 +777,7 @@ fn make_subagent( result: None, steps_taken: 0, duration_ms: 0, + from_prior_session: false, } }