diff --git a/crates/tui/src/task_manager.rs b/crates/tui/src/task_manager.rs index e11208122e..e128629cbe 100644 --- a/crates/tui/src/task_manager.rs +++ b/crates/tui/src/task_manager.rs @@ -196,6 +196,8 @@ pub struct TaskRecord { pub started_at: Option>, pub ended_at: Option>, pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hunt_verdict: Option, #[serde(skip_serializing_if = "Option::is_none")] pub result_summary: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -234,6 +236,8 @@ pub struct TaskSummary { pub started_at: Option>, pub ended_at: Option>, pub duration_ms: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hunt_verdict: Option, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -254,6 +258,7 @@ impl From<&TaskRecord> for TaskSummary { started_at: value.started_at, ended_at: value.ended_at, duration_ms: value.duration_ms, + hunt_verdict: value.hunt_verdict.clone(), error: value.error.clone(), thread_id: value.thread_id.clone(), turn_id: value.turn_id.clone(), @@ -853,6 +858,7 @@ impl TaskManager { started_at: None, ended_at: None, duration_ms: None, + hunt_verdict: None, result_summary: None, result_detail_path: None, error: None, @@ -1418,6 +1424,22 @@ impl TaskManager { }); } + if let Some(value) = updates.get("hunt_verdict") { + let raw = value + .as_str() + .ok_or_else(|| anyhow!("hunt_verdict task update must be a string"))?; + let verdict = normalize_hunt_verdict(raw)?; + if task.hunt_verdict.as_deref() != Some(verdict) { + task.hunt_verdict = Some(verdict.to_string()); + task.timeline.push(TaskTimelineEntry { + timestamp: now, + kind: "hunt_verdict".to_string(), + summary: format!("Hunt verdict updated: {verdict}"), + detail_path: None, + }); + } + } + if let Some(value) = updates.get("attempt") { let attempt: TaskAttemptRecord = serde_json::from_value(value.clone()) .context("Failed to parse attempt task update")?; @@ -1491,6 +1513,18 @@ impl TaskManager { } } +fn normalize_hunt_verdict(raw: &str) -> Result<&'static str> { + match raw.trim() { + "hunting" => Ok("hunting"), + "hunted" => Ok("hunted"), + "wounded" => Ok("wounded"), + "escaped" => Ok("escaped"), + other => bail!( + "unsupported hunt_verdict task update '{other}'. Expected one of: hunting, hunted, wounded, escaped" + ), + } +} + fn load_state( tasks_dir: &Path, queue_path: &Path, @@ -1849,6 +1883,7 @@ mod tests { started_at: Some(started_at), ended_at: None, duration_ms: None, + hunt_verdict: None, result_summary: None, result_detail_path: None, error: None, @@ -1974,6 +2009,37 @@ mod tests { Ok(()) } + #[tokio::test] + async fn record_tool_metadata_updates_hunt_verdict_summary() -> Result<()> { + let root = std::env::temp_dir().join(format!("deepseek-task-test-{}", Uuid::new_v4())); + let manager = + TaskManager::start_with_executor(test_config(root), Arc::new(MockExecutor)).await?; + + let task = manager + .add_task(NewTaskRequest::from_prompt("test verdict metadata")) + .await?; + let finished = wait_for_terminal_state(&manager, &task.id, Duration::from_secs(10)).await?; + let updated = manager + .record_tool_metadata( + &finished.id, + &serde_json::json!({ + "task_updates": { + "hunt_verdict": "wounded" + } + }), + ) + .await?; + + assert_eq!(updated.hunt_verdict.as_deref(), Some("wounded")); + let summaries = manager.list_tasks(Some(10)).await; + let summary = summaries + .iter() + .find(|summary| summary.id == updated.id) + .expect("updated task summary"); + assert_eq!(summary.hunt_verdict.as_deref(), Some("wounded")); + Ok(()) + } + #[tokio::test] async fn write_task_artifact_rejects_traversal_task_id() -> Result<()> { let temp = tempfile::tempdir()?; diff --git a/crates/tui/src/tools/verifier.rs b/crates/tui/src/tools/verifier.rs index fe84c3a569..2ec230b992 100644 --- a/crates/tui/src/tools/verifier.rs +++ b/crates/tui/src/tools/verifier.rs @@ -361,8 +361,7 @@ impl ToolSpec for RunVerifiersTool { summary: "No verifier gates were detected. Provide custom commands or choose a profile that matches this workspace.".to_string(), gates: Vec::new(), }; - return ToolResult::json(&output) - .map_err(|err| ToolError::execution_failed(err.to_string())); + return verifier_tool_result(&output); } if input.background { @@ -432,10 +431,25 @@ impl ToolSpec for RunVerifiersTool { gates: results, }; - ToolResult::json(&output).map_err(|err| ToolError::execution_failed(err.to_string())) + verifier_tool_result(&output) } } +fn verifier_tool_result(output: &RunVerifiersOutput) -> Result { + ToolResult::json(output) + .map_err(|err| ToolError::execution_failed(err.to_string())) + .map(|result| { + result.with_metadata(json!({ + "verifier_verdict": output.verifier_verdict, + "hunt_verdict": output.hunt_verdict, + "goal_status": output.goal_status, + "task_updates": { + "hunt_verdict": output.hunt_verdict + } + })) + }) +} + fn start_background_gates( context: &ToolContext, profile: VerifierProfile, @@ -1328,6 +1342,7 @@ mod tests { .await .expect("execute partial verifier"); assert_hunt_mapping(&partial.content, "partial", "wounded", "paused"); + assert_hunt_metadata(&partial, "partial", "wounded", "paused"); if !crate::dependencies::RustC::available() { return; @@ -1350,6 +1365,7 @@ mod tests { .await .expect("execute passing verifier"); assert_hunt_mapping(&pass.content, "pass", "hunted", "complete"); + assert_hunt_metadata(&pass, "pass", "hunted", "complete"); let fail = tool .execute( @@ -1368,6 +1384,7 @@ mod tests { .await .expect("execute failing verifier"); assert_hunt_mapping(&fail.content, "fail", "escaped", "blocked"); + assert_hunt_metadata(&fail, "fail", "escaped", "blocked"); } fn assert_hunt_mapping(content: &str, verifier: &str, hunt: &str, goal: &str) { @@ -1377,6 +1394,14 @@ mod tests { assert_eq!(parsed["goal_status"], goal, "{content}"); } + fn assert_hunt_metadata(result: &ToolResult, verifier: &str, hunt: &str, goal: &str) { + let metadata = result.metadata.as_ref().expect("hunt metadata"); + assert_eq!(metadata["verifier_verdict"], verifier, "{metadata}"); + assert_eq!(metadata["hunt_verdict"], hunt, "{metadata}"); + assert_eq!(metadata["goal_status"], goal, "{metadata}"); + assert_eq!(metadata["task_updates"]["hunt_verdict"], hunt, "{metadata}"); + } + #[tokio::test] async fn run_verifiers_background_starts_shell_jobs_and_returns_task_ids() { if !crate::dependencies::RustC::available() { diff --git a/crates/tui/src/tui/subagent_routing.rs b/crates/tui/src/tui/subagent_routing.rs index b89c4e7ea0..791c784108 100644 --- a/crates/tui/src/tui/subagent_routing.rs +++ b/crates/tui/src/tui/subagent_routing.rs @@ -371,28 +371,53 @@ fn task_status_label(status: TaskStatus) -> &'static str { } } +fn hunt_verdict_glyph(verdict: Option<&str>) -> &'static str { + match verdict { + Some("hunting") => "·", + Some("hunted") => "✓", + Some("wounded") => "!", + Some("escaped") => "×", + Some(_) => "?", + None => "-", + } +} + pub(super) fn format_task_list(tasks: &[TaskSummary]) -> String { if tasks.is_empty() { return "No tasks found.".to_string(); } - let mut lines = vec![ - format!("Tasks ({})", tasks.len()), - "ID Status Time Title".to_string(), - "------------------------------------------------------------".to_string(), - ]; + let show_verdict = tasks.iter().any(|task| task.hunt_verdict.is_some()); + let mut lines = vec![format!("Tasks ({})", tasks.len())]; + if show_verdict { + lines.push("ID Status Verdict Time Title".to_string()); + } else { + lines.push("ID Status Time Title".to_string()); + } + lines.push("------------------------------------------------------------".to_string()); for task in tasks { let duration = task .duration_ms .map(|ms| format!("{:.2}s", ms as f64 / 1000.0)) .unwrap_or_else(|| "-".to_string()); - lines.push(format!( - "{:<13} {:<9} {:>8} {}", - task.id, - task_status_label(task.status), - duration, - task.prompt_summary - )); + if show_verdict { + lines.push(format!( + "{:<13} {:<9} {:<7} {:>8} {}", + task.id, + task_status_label(task.status), + hunt_verdict_glyph(task.hunt_verdict.as_deref()), + duration, + task.prompt_summary + )); + } else { + lines.push(format!( + "{:<13} {:<9} {:>8} {}", + task.id, + task_status_label(task.status), + duration, + task.prompt_summary + )); + } } lines.push("Use /task show for timeline details.".to_string()); lines.join("\n") @@ -553,6 +578,7 @@ mod tests { started_at: None, ended_at: None, duration_ms, + hunt_verdict: None, error: None, thread_id: None, turn_id: None, @@ -571,6 +597,23 @@ mod tests { assert!(output.contains("task_abcdef12 completed 1.23s Fix task list output")); } + #[test] + fn task_list_renders_hunt_verdict_glyphs_when_present() { + let mut hunted = task_summary("task_hunted", TaskStatus::Completed, Some(1200)); + hunted.hunt_verdict = Some("hunted".to_string()); + let mut wounded = task_summary("task_wounded", TaskStatus::Completed, Some(2300)); + wounded.hunt_verdict = Some("wounded".to_string()); + let mut escaped = task_summary("task_escaped", TaskStatus::Failed, Some(3400)); + escaped.hunt_verdict = Some("escaped".to_string()); + + let output = format_task_list(&[hunted, wounded, escaped]); + + assert!(output.contains("ID Status Verdict")); + assert!(output.contains("task_hunted completed ✓")); + assert!(output.contains("task_wounded completed !")); + assert!(output.contains("task_escaped failed ×")); + } + #[test] fn mailbox_progress_reports_transcript_change_only_for_visible_card_updates() { let mut app = App::new(test_options(), &Config::default()); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index b37ac522fe..d842acac2d 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -11138,6 +11138,7 @@ mod work_sidebar_projection_tests { started_at: Some(Utc.with_ymd_and_hms(2026, 5, 16, 12, 1, 0).unwrap()), ended_at, duration_ms: ended_at.map(|_| 1_234), + hunt_verdict: None, error: None, thread_id: None, turn_id: None,