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
66 changes: 66 additions & 0 deletions crates/tui/src/task_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ pub struct TaskRecord {
pub started_at: Option<DateTime<Utc>>,
pub ended_at: Option<DateTime<Utc>>,
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hunt_verdict: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result_summary: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
Expand Down Expand Up @@ -234,6 +236,8 @@ pub struct TaskSummary {
pub started_at: Option<DateTime<Utc>>,
pub ended_at: Option<DateTime<Utc>>,
pub duration_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hunt_verdict: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
Expand All @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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")?;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()?;
Expand Down
31 changes: 28 additions & 3 deletions crates/tui/src/tools/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, ToolError> {
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,
Expand Down Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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) {
Expand All @@ -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() {
Expand Down
67 changes: 55 additions & 12 deletions crates/tui/src/tui/subagent_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id> for timeline details.".to_string());
lines.join("\n")
Expand Down Expand Up @@ -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,
Expand All @@ -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());
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/tui/ui/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading