From c09f5bf03964aed88ed0a5e75f3a86ae03492896 Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Sat, 23 May 2026 13:05:36 -0500 Subject: [PATCH] fix(subagent): keep agent_eval recoverable after child termination Keep agent_eval recoverable after a child session terminates by preserving the available transcript result instead of losing the final output. --- crates/tui/src/tools/subagent/mod.rs | 35 +++++++++++++--- crates/tui/src/tools/subagent/tests.rs | 57 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 5 deletions(-) diff --git a/crates/tui/src/tools/subagent/mod.rs b/crates/tui/src/tools/subagent/mod.rs index 25d6a85c..7ace66e5 100644 --- a/crates/tui/src/tools/subagent/mod.rs +++ b/crates/tui/src/tools/subagent/mod.rs @@ -2421,11 +2421,35 @@ impl ToolSpec for AgentEvalTool { .map_err(|e| ToolError::execution_failed(e.to_string()))? }; + // Track whether a supplied follow-up message actually reached the + // child. A completed/failed/cancelled session cannot accept input, but + // that must NOT abort the whole call: the parent still needs the + // session projection (and its `transcript_handle`) to retrieve the + // child's full output. Hard-failing here was #1738 — "agent_eval on a + // completed session returns 'not running', no way to recover the full + // child output". + let mut message_delivery: Option = None; if let Some(message) = message { - let mut manager = self.manager.write().await; - manager - .send_input(&agent_id, message, interrupt) - .map_err(|e| ToolError::execution_failed(e.to_string()))?; + let terminal = { + let manager = self.manager.read().await; + manager + .get_result(&agent_id) + .map(|snap| snap.status != SubAgentStatus::Running) + .unwrap_or(false) + }; + if terminal { + message_delivery = Some(json!({ + "delivered": false, + "reason": "session already terminated; follow-up not delivered", + "recover_full_output": "read the returned transcript_handle with handle_read" + })); + } else { + let mut manager = self.manager.write().await; + manager + .send_input(&agent_id, message, interrupt) + .map_err(|e| ToolError::execution_failed(e.to_string()))?; + message_delivery = Some(json!({ "delivered": true })); + } } let (snapshot, timed_out) = if block { @@ -2448,7 +2472,8 @@ impl ToolSpec for AgentEvalTool { "timed_out": timed_out, "terminal": projection.terminal, "context_mode": projection.context_mode, - "timeout_ms": timeout_ms + "timeout_ms": timeout_ms, + "message_delivery": message_delivery })); Ok(result) } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 30b3494d..40148f5b 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -825,6 +825,63 @@ async fn test_wait_for_result_reports_timeout_when_still_running() { assert_eq!(snapshot.status, SubAgentStatus::Running); } +// Regression for #1738: agent_eval on a terminated session must not +// hard-fail with "not running" when a follow-up message is supplied. The +// parent still needs the projection (and its transcript_handle) to recover +// the child's full output. +#[tokio::test] +async fn agent_eval_on_completed_session_returns_full_projection_not_running_error() { + let manager = Arc::new(RwLock::new(SubAgentManager::new(PathBuf::from("."), 1))); + let (input_tx, _input_rx) = mpsc::unbounded_channel(); + let mut agent = SubAgent::new( + SubAgentType::Explore, + "analyze 14 issues".to_string(), + make_assignment(), + "deepseek-v4-flash".to_string(), + Some("Blue".to_string()), + Some(vec!["read_file".to_string()]), + input_tx, + "boot_test".to_string(), + ); + let full_output = "Per-issue analysis:\n".to_string() + &"detail line\n".repeat(400); + agent.status = SubAgentStatus::Completed; + agent.result = Some(full_output.clone()); + let agent_id = agent.id.clone(); + { + let mut guard = manager.write().await; + guard.agents.insert(agent_id.clone(), agent); + } + + let ctx = ToolContext::new("."); + let tool = AgentEvalTool::new(manager.clone()); + let result = tool + .execute( + json!({ + "agent_id": agent_id, + "message": "give me the full per-issue breakdown", + "block": false + }), + &ctx, + ) + .await + .expect("agent_eval on a completed session must not error"); + + let meta = result.metadata.expect("metadata present"); + assert_eq!(meta["terminal"], json!(true)); + assert_eq!(meta["message_delivery"]["delivered"], json!(false)); + + let projection: SubAgentSessionProjection = + serde_json::from_str(&result.content).expect("projection deserializes"); + assert_eq!(projection.status, "completed"); + assert_eq!(projection.transcript_handle.kind, "var_handle"); + // The full, untruncated child output survives in the snapshot the + // transcript_handle points at. + assert_eq!( + projection.snapshot.result.as_deref(), + Some(full_output.as_str()) + ); +} + #[tokio::test] async fn test_running_count_counts_only_agents_with_live_task_handles() { let mut manager = SubAgentManager::new(PathBuf::from("."), 1);