use std::path::PathBuf;
use std::time::Instant;
use crate::hooks::HookEvent;
use crate::tools::ReviewOutput;
use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input};
use crate::tools::canonical_action::canonical_action_alias;
use crate::tools::plan::PlanSnapshot;
use crate::tools::spec::{ToolError, ToolResult};
use crate::tui::active_cell::ActiveCell;
use crate::tui::app::{App, ToolDetailRecord, ToolEvidence};
use crate::tui::history::{
ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell, McpToolCell,
PatchSummaryCell, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, ViewImageCell,
WebSearchCell, output_looks_like_diff, summarize_mcp_output, summarize_tool_args,
summarize_tool_output,
};
use crate::tui::workspace_context;
#[allow(clippy::too_many_lines)]
pub(super) fn handle_tool_call_started(
app: &mut App,
id: &str,
name: &str,
input: &serde_json::Value,
) {
let id = id.to_string();
let semantic_name = canonical_action_alias(name, input);
if app.active_cell.is_none() {
app.active_cell = Some(ActiveCell::new());
}
if is_exploring_tool(semantic_name) {
let label = exploring_label(semantic_name, input);
let active = app.active_cell.as_mut().expect("active_cell just ensured");
let entry_idx = active.ensure_exploring();
app.active_tool_entry_completed_at.remove(&entry_idx);
let inner = active
.append_to_exploring(
id.clone(),
ExploringEntry {
label,
status: ToolStatus::Running,
},
)
.map_or(0, |(_, inner)| inner);
app.exploring_cell = Some(entry_idx);
let virtual_index = app.history.len() + entry_idx;
app.exploring_entries
.insert(id.clone(), (virtual_index, inner));
register_tool_cell(app, &id, name, input, virtual_index);
app.mark_history_updated();
return;
}
if is_exec_tool(semantic_name) {
let command = exec_target_from_input(input);
let source = exec_source_from_input(input);
let interaction = exec_interaction_summary(semantic_name, input);
let mut is_wait = false;
if let Some((summary, wait)) = interaction.as_ref() {
is_wait = *wait;
if is_wait
&& app
.last_exec_wait_command
.as_ref()
.is_some_and(|last| last == &command)
{
app.ignored_tool_calls.insert(id);
return;
}
if is_wait {
app.last_exec_wait_command = Some(command.clone());
}
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command,
status: ToolStatus::Running,
output: None,
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: Some(Instant::now()),
duration_ms: None,
stale_elapsed_since_output_ms: None,
source,
interaction: Some(summary.clone()),
output_summary: None,
})),
);
return;
}
if exec_is_background(input)
&& app
.last_exec_wait_command
.as_ref()
.is_some_and(|last| last == &command)
{
app.ignored_tool_calls.insert(id);
return;
}
if exec_is_background(input) && !is_wait {
app.last_exec_wait_command = Some(command.clone());
}
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command,
status: ToolStatus::Running,
output: None,
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: Some(Instant::now()),
duration_ms: None,
stale_elapsed_since_output_ms: None,
source,
interaction: None,
output_summary: None,
})),
);
return;
}
if semantic_name == "update_plan" {
let snapshot = parse_plan_input(input);
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
snapshot,
status: ToolStatus::Running,
})),
);
return;
}
if matches!(semantic_name, "write_file" | "edit_file" | "apply_patch") {
let (path, summary) = parse_file_mutation_summary(semantic_name, input);
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell {
path,
summary,
status: ToolStatus::Running,
error: None,
receipt: None,
})),
);
return;
}
if semantic_name == "review" {
let target = review_target_label(input);
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::Review(ReviewCell {
target,
status: ToolStatus::Running,
output: None,
error: None,
})),
);
return;
}
if is_mcp_tool(semantic_name) {
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::Mcp(McpToolCell {
tool: name.to_string(),
status: ToolStatus::Running,
content: None,
is_image: false,
})),
);
return;
}
if is_view_image_tool(semantic_name) {
if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
let raw_path = PathBuf::from(path);
let display_path = raw_path
.strip_prefix(&app.workspace)
.unwrap_or(&raw_path)
.to_path_buf();
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::ViewImage(ViewImageCell { path: display_path })),
);
}
return;
}
if is_web_search_tool(semantic_name) {
let query = web_search_query(input);
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::WebSearch(WebSearchCell {
query,
status: ToolStatus::Running,
summary: None,
source: None,
degraded: None,
ref_count: 0,
})),
);
return;
}
let mut input_summary = summarize_tool_args(input);
if name == "agent"
&& let Some(action) = input.get("action").and_then(serde_json::Value::as_str)
{
let action = action.trim().to_ascii_lowercase();
let already_leads = input_summary
.as_deref()
.is_some_and(|summary| summary.starts_with("action:"));
if !action.is_empty()
&& !already_leads
&& action != "start"
&& action != "spawn"
&& action != "run"
{
input_summary = Some(match input_summary {
Some(rest) => format!("action: {action} {rest}"),
None => format!("action: {action}"),
});
}
}
push_active_tool_cell(
app,
&id,
name,
input,
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: semantic_name.to_string(),
status: ToolStatus::Running,
input_summary,
output: None,
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})),
);
}
fn push_active_tool_cell(
app: &mut App,
tool_id: &str,
tool_name: &str,
input: &serde_json::Value,
cell: HistoryCell,
) {
if app.active_cell.is_none() {
app.active_cell = Some(ActiveCell::new());
}
let active = app.active_cell.as_mut().expect("active_cell just ensured");
let entry_idx = active.push_tool(tool_id.to_string(), cell);
app.active_tool_entry_completed_at.remove(&entry_idx);
let virtual_index = app.history.len() + entry_idx;
register_tool_cell(app, tool_id, tool_name, input, virtual_index);
app.mark_history_updated();
}
fn register_tool_cell(
app: &mut App,
tool_id: &str,
tool_name: &str,
input: &serde_json::Value,
cell_index: usize,
) {
app.tool_cells.insert(tool_id.to_string(), cell_index);
let record = ToolDetailRecord {
tool_id: tool_id.to_string(),
tool_name: tool_name.to_string(),
input: input.clone(),
output: None,
};
if cell_index < app.history.len() {
app.tool_details_by_cell.insert(cell_index, record);
} else {
app.active_tool_details.insert(tool_id.to_string(), record);
}
}
fn store_tool_detail_output(
app: &mut App,
tool_id: &str,
cell_index: usize,
result: &Result<ToolResult, ToolError>,
) {
let payload = Some(match result {
Ok(tool_result) => tool_result.content.clone(),
Err(err) => err.to_string(),
});
if cell_index < app.history.len()
&& let Some(detail) = app.tool_details_by_cell.get_mut(&cell_index)
{
detail.output = payload.clone();
}
if let Some(detail) = app.active_tool_details.get_mut(tool_id) {
detail.output = payload;
}
}
#[allow(clippy::too_many_lines)]
fn accrue_child_token_cost_if_any(app: &mut App, result: &Result<ToolResult, ToolError>) {
let Ok(tool_result) = result else { return };
let Some(metadata) = tool_result.metadata.as_ref() else {
return;
};
let Some(route) = crate::cost_status::child_route_envelope_from_metadata(metadata) else {
return;
};
let Some(usage) = crate::cost_status::child_usage_from_metadata(metadata) else {
return;
};
let audit = route.audit(&usage);
app.record_turn_cost_audit(&audit);
app.record_turn_cost_route_receipt(route.receipt(&audit));
if let Some(cost) = audit.estimate {
app.accrue_subagent_cost_estimate(cost);
}
}
fn record_spillover_artifact_if_any(
app: &mut App,
id: &str,
name: &str,
result: &Result<ToolResult, ToolError>,
) {
let Ok(tool_result) = result else { return };
let Some(path) = tool_result
.metadata
.as_ref()
.and_then(|metadata| metadata.get("spillover_path"))
.and_then(serde_json::Value::as_str)
.map(PathBuf::from)
else {
return;
};
let metadata = tool_result.metadata.as_ref();
let session_id = metadata
.and_then(|metadata| metadata.get("artifact_session_id"))
.and_then(serde_json::Value::as_str)
.or(app.current_session_id.as_deref())
.unwrap_or("");
let storage_path = metadata
.and_then(|metadata| metadata.get("artifact_relative_path"))
.and_then(serde_json::Value::as_str)
.map(PathBuf::from)
.unwrap_or_else(|| path.clone());
let content_for_preview = metadata
.and_then(|metadata| metadata.get("artifact_preview"))
.and_then(serde_json::Value::as_str)
.unwrap_or(&tool_result.content);
let byte_size = metadata
.and_then(|metadata| metadata.get("artifact_byte_size"))
.and_then(serde_json::Value::as_u64)
.unwrap_or_else(|| {
std::fs::metadata(&storage_path)
.map(|metadata| metadata.len())
.unwrap_or(tool_result.content.len() as u64)
});
if app
.session_artifacts
.iter()
.any(|artifact| artifact.tool_call_id == id && artifact.storage_path == storage_path)
{
return;
}
app.session_artifacts
.push(crate::artifacts::record_tool_output_artifact_with_size(
session_id,
id,
name,
storage_path,
byte_size,
content_for_preview,
));
}
pub(super) fn evidence_completion_should_be_ignored(
app: &App,
id: &str,
result: &Result<ToolResult, ToolError>,
) -> bool {
evidence_completion_identity_should_be_ignored(
app.current_session_id.as_deref(),
app.session_artifacts
.iter()
.map(|artifact| (artifact.id.as_str(), artifact.tool_call_id.as_str())),
id,
result,
)
}
fn evidence_completion_identity_should_be_ignored<'a>(
current_session: Option<&str>,
known_artifacts: impl IntoIterator<Item = (&'a str, &'a str)>,
id: &str,
result: &Result<ToolResult, ToolError>,
) -> bool {
let Some(metadata) = result
.as_ref()
.ok()
.and_then(|result| result.metadata.as_ref())
else {
return false;
};
let origin = metadata
.get("artifact_session_id")
.and_then(serde_json::Value::as_str);
if let (Some(origin), Some(current)) = (origin, current_session)
&& origin != current
{
return true;
}
metadata
.get("artifact_id")
.and_then(serde_json::Value::as_str)
.is_some_and(|artifact_id| {
known_artifacts
.into_iter()
.any(|(known_id, known_call)| known_id == artifact_id && known_call == id)
})
}
fn visible_tool_output(content: &str) -> Option<String> {
if content.trim() == "(no output)" {
None
} else {
Some(content.to_string())
}
}
pub(crate) fn reported_tool_exit_code(result: &Result<ToolResult, ToolError>) -> Option<i64> {
let metadata = result.as_ref().ok()?.metadata.as_ref()?;
let code = metadata.get("exit_code")?;
if code.is_null() {
return None;
}
code.as_i64()
}
fn fire_tool_completion_hooks(
app: &mut App,
id: &str,
name: &str,
result: &Result<ToolResult, ToolError>,
) {
let wants_after = app.hooks.has_hooks_for_event(HookEvent::ToolCallAfter);
let wants_error = app
.hooks
.has_hooks_for_event(crate::hooks::HookEvent::OnError);
if !wants_after && !wants_error {
return;
}
let (result_text, success): (String, bool) = match result.as_ref() {
Ok(tool_result) => (tool_result.content.clone(), tool_result.success),
Err(err) => (err.to_string(), false),
};
let exit_code = reported_tool_exit_code(result);
if wants_after {
let context = app
.base_hook_context()
.with_tool_name(name)
.with_tool_call_id(id)
.with_tool_result(&result_text, success, exit_code);
if let Err(error) = app.submit_hooks(HookEvent::ToolCallAfter, context) {
app.surface_observer_hook_submission_failure(error);
}
}
if wants_error && !success {
let context = app
.base_hook_context()
.with_tool_name(name)
.with_tool_call_id(id)
.with_tool_result(&result_text, success, exit_code)
.with_error(&format!("tool `{name}` failed: {result_text}"));
if let Err(error) = app.submit_hooks(crate::hooks::HookEvent::OnError, context) {
app.surface_observer_hook_submission_failure(error);
}
}
}
pub(super) fn handle_tool_call_complete(
app: &mut App,
id: &str,
name: &str,
result: &Result<ToolResult, ToolError>,
) {
if app.ignored_tool_calls.remove(id) {
fire_tool_completion_hooks(app, id, name, result);
return;
}
let semantic_name = app
.active_tool_details
.get(id)
.or_else(|| {
app.tool_cells
.get(id)
.and_then(|cell_index| app.tool_details_by_cell.get(cell_index))
})
.map_or(name, |detail| canonical_action_alias(name, &detail.input))
.to_string();
accrue_child_token_cost_if_any(app, result);
record_spillover_artifact_if_any(app, id, name, result);
fire_tool_completion_hooks(app, id, name, result);
if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) {
app.tool_cells.remove(id);
store_tool_detail_output(app, id, cell_index, result);
if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) =
app.cell_at_virtual_index_mut(cell_index)
&& let Some(entry) = cell.entries.get_mut(entry_index)
{
entry.status = tool_status_from_result(result);
app.mark_history_updated();
if cell_index >= app.history.len() {
app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
if let Some(active) = app.active_cell.as_mut() {
active.bump_revision();
}
}
}
refresh_active_tool_completion_timestamp(app, cell_index);
return;
}
let Some(cell_index) = app.tool_cells.remove(id) else {
push_orphan_tool_completion(app, id, name, result);
return;
};
store_tool_detail_output(app, id, cell_index, result);
let in_active = cell_index >= app.history.len();
let status = tool_status_from_result(result);
let mutation_receipt = matches!(
semantic_name.as_str(),
"write_file" | "edit_file" | "apply_patch"
)
.then(|| {
result.as_ref().ok().and_then(|tool_result| {
crate::tui::history::FileMutationReceipt::from_success(&app.workspace, tool_result)
})
})
.flatten();
let mut workflow_panel_output: Option<String> = None;
if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) {
match cell {
HistoryCell::Tool(ToolCell::Exec(exec)) => {
exec.status = status;
if let Ok(tool_result) = result.as_ref() {
let shell_task_id = tool_result
.metadata
.as_ref()
.and_then(|m| m.get("task_id"))
.and_then(serde_json::Value::as_str)
.filter(|task_id| !task_id.trim().is_empty())
.map(str::to_string);
if shell_task_id.is_some() {
exec.shell_task_id = shell_task_id;
}
exec.owner_agent_id = tool_result
.metadata
.as_ref()
.and_then(|m| m.get("owner_agent_id"))
.and_then(serde_json::Value::as_str)
.filter(|agent_id| !agent_id.trim().is_empty())
.map(str::to_string);
exec.owner_agent_name = tool_result
.metadata
.as_ref()
.and_then(|m| m.get("owner_agent_name"))
.and_then(serde_json::Value::as_str)
.filter(|agent_name| !agent_name.trim().is_empty())
.map(str::to_string);
if let Some(meta_command) = tool_result
.metadata
.as_ref()
.and_then(|m| m.get("command"))
.and_then(serde_json::Value::as_str)
&& !meta_command.trim().is_empty()
&& (exec.command == "command" || exec.command.starts_with("command "))
{
exec.command = meta_command.to_string();
if exec.interaction.as_deref().is_some_and(|interaction| {
interaction.starts_with("Waiting for command")
}) {
let task_suffix = tool_result
.metadata
.as_ref()
.and_then(|m| m.get("task_id"))
.and_then(serde_json::Value::as_str)
.map(|task_id| format!(" ({task_id})"))
.unwrap_or_default();
exec.interaction =
Some(format!("Waiting for \"{meta_command}\"{task_suffix}"));
}
}
exec.duration_ms = tool_result
.metadata
.as_ref()
.and_then(|m| m.get("duration_ms"))
.and_then(serde_json::Value::as_u64);
if status != ToolStatus::Running && exec.interaction.is_none() {
exec.output = visible_tool_output(&tool_result.content);
exec.output_summary = exec
.output
.as_deref()
.map(super::history::summarize_tool_output);
exec.live_output = None;
} else if status == ToolStatus::Running
&& exec.interaction.is_none()
&& !tool_result.content.is_empty()
{
exec.live_output = Some(tool_result.content.clone());
}
} else if let Err(err) = result.as_ref()
&& exec.interaction.is_none()
{
exec.output = Some(err.to_string());
exec.output_summary =
Some(super::history::summarize_tool_output(&err.to_string()));
}
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::PlanUpdate(plan)) => {
plan.status = status;
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::PatchSummary(patch)) => {
patch.status = status;
patch.receipt = mutation_receipt;
match result.as_ref() {
Ok(tool_result) if tool_result.success => {
if let Ok(json) =
serde_json::from_str::<serde_json::Value>(&tool_result.content)
&& let Some(message) = json.get("message").and_then(|v| v.as_str())
{
patch.summary = message.to_string();
}
}
Ok(tool_result) => {
patch.error = Some(tool_result.content.clone());
}
Err(err) => {
patch.error = Some(err.to_string());
}
}
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::Review(review)) => {
review.status = status;
match result.as_ref() {
Ok(tool_result) => {
if tool_result.success {
review.output = Some(ReviewOutput::from_str(&tool_result.content));
} else {
review.error = Some(tool_result.content.clone());
}
}
Err(err) => {
review.error = Some(err.to_string());
}
}
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::Mcp(mcp)) => {
match result.as_ref() {
Ok(tool_result) => {
let summary = summarize_mcp_output(&tool_result.content);
if status == ToolStatus::Hydrated {
mcp.status = status;
} else if summary.is_error == Some(true) {
mcp.status = ToolStatus::Failed;
} else {
mcp.status = status;
}
mcp.is_image = summary.is_image;
mcp.content = summary.content;
}
Err(err) => {
mcp.status = status;
mcp.content = Some(err.to_string());
}
}
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::WebSearch(search)) => {
search.status = status;
match result.as_ref() {
Ok(tool_result) => {
search.summary = Some(summarize_tool_output(&tool_result.content));
let presentation = web_search_presentation(&tool_result.content);
search.source = presentation.source;
search.degraded = presentation.degraded;
search.ref_count = presentation.ref_count;
}
Err(err) => {
search.summary = Some(err.to_string());
}
}
app.mark_history_updated();
}
HistoryCell::Tool(ToolCell::Generic(generic)) => {
generic.status = status;
match result.as_ref() {
Ok(tool_result) => {
generic.output = visible_tool_output(&tool_result.content);
generic.output_summary =
generic.output.as_deref().map(summarize_tool_output);
generic.is_diff = output_looks_like_diff(&tool_result.content);
}
Err(err) => {
generic.output = Some(err.to_string());
generic.output_summary = Some(summarize_tool_output(&err.to_string()));
generic.is_diff = false;
}
}
if generic.name == "workflow" {
workflow_panel_output = generic.output.clone();
}
app.mark_history_updated();
}
_ => {}
}
}
if let Some(output) = workflow_panel_output.as_deref() {
apply_workflow_output_to_panel(app, output);
}
if in_active {
app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
if let Some(active) = app.active_cell.as_mut() {
active.bump_revision();
}
refresh_active_tool_completion_timestamp(app, cell_index);
}
if refreshes_workspace_context_on_completion(&semantic_name) && status != ToolStatus::Running {
workspace_context::refresh_now(app, Instant::now());
}
let evidence_summary = match result.as_ref() {
Ok(tool_result) => {
if tool_result.success {
summarize_tool_output(&tool_result.content)
} else {
format!("failed: {}", summarize_tool_output(&tool_result.content))
}
}
Err(err) => format!("error: {err}"),
};
app.tool_evidence.push(ToolEvidence {
tool_name: name.to_string(),
summary: evidence_summary,
});
}
#[derive(Debug, Default, PartialEq, Eq)]
struct WebSearchPresentation {
source: Option<String>,
degraded: Option<String>,
ref_count: usize,
}
fn web_search_presentation(content: &str) -> WebSearchPresentation {
let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
return WebSearchPresentation::default();
};
let surfaces = if value.get("receipt").is_some() {
vec![&value]
} else {
value
.get("search_query")
.and_then(serde_json::Value::as_array)
.map(|items| items.iter().collect())
.unwrap_or_default()
};
let source = surfaces
.iter()
.filter_map(|surface| surface.get("source").and_then(serde_json::Value::as_str))
.map(str::to_string)
.next();
let mut degraded = Vec::new();
let mut ref_count = 0usize;
for surface in surfaces {
if let Some(results) = surface.get("results").and_then(serde_json::Value::as_array) {
ref_count = ref_count.saturating_add(
results
.iter()
.filter(|result| {
result
.get("ref_id")
.and_then(serde_json::Value::as_str)
.is_some_and(|ref_id| !ref_id.is_empty())
})
.count(),
);
}
if let Some(reasons) = surface
.pointer("/receipt/degraded")
.and_then(serde_json::Value::as_array)
{
for reason in reasons {
if let Some(label) = degraded_reason_label(reason)
&& !degraded.contains(&label)
{
degraded.push(label);
}
}
}
}
WebSearchPresentation {
source,
degraded: (!degraded.is_empty()).then(|| degraded.join("; ")),
ref_count,
}
}
fn degraded_reason_label(reason: &serde_json::Value) -> Option<String> {
let kind = reason.get("kind")?.as_str()?;
let backend = |field: &str| {
reason
.get(field)
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown")
};
Some(match kind {
"backend_unavailable" => format!("{} unavailable", backend("backend")),
"no_usable_results" => format!("{} returned no usable results", backend("backend")),
"backend_fallback" => format!("{} -> {}", backend("from"), backend("to")),
"challenge_detected" => format!("{} challenge", backend("backend")),
"scrape_fallback" => format!("{} -> {} scrape", backend("from"), backend("to")),
"knob_ignored" => format!(
"{} ignored",
reason
.get("knob")
.and_then(serde_json::Value::as_str)
.unwrap_or("filter")
),
"post_filtered" => format!(
"{} post-filtered",
reason
.get("knob")
.and_then(serde_json::Value::as_str)
.unwrap_or("results")
),
"synthesized_results" => "synthesized results".to_string(),
other => other.replace('_', " "),
})
}
fn apply_workflow_output_to_panel(app: &mut App, output: &str) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(output) else {
return;
};
if let Some(events) = value.get("events").and_then(|e| e.as_array()) {
if app.workflow_panel.is_none() {
let run_id = value
.get("run_id")
.and_then(|v| v.as_str())
.unwrap_or("workflow")
.to_string();
let label = value
.get("workflow_goal")
.and_then(|v| v.as_str())
.or_else(|| value.get("workflow_id").and_then(|v| v.as_str()))
.unwrap_or("workflow")
.to_string();
let at_ms = value
.get("started_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let mut panel =
crate::tui::widgets::workflow_panel::WorkflowPanel::new(run_id, label, at_ms);
panel.locale = app.ui_locale;
app.workflow_panel = Some(panel);
}
if let Some(panel) = app.workflow_panel.as_mut() {
let run_id = value
.get("run_id")
.and_then(|v| v.as_str())
.unwrap_or(&panel.run_id)
.to_string();
let mut injected = Vec::with_capacity(events.len());
for event in events {
let mut event = event.clone();
if let Some(obj) = event.as_object_mut() {
obj.entry("run_id".to_string())
.or_insert_with(|| serde_json::Value::String(run_id.clone()));
}
injected.push(event);
}
panel.apply_json_events(&injected);
if let Some(summary) = value
.get("result")
.map(|v| v.to_string())
.filter(|s| s != "null")
{
panel.result_summary = Some(summary);
}
if let Some(path) = value.get("source_path").and_then(|v| v.as_str()) {
panel.source_path = Some(PathBuf::from(path));
}
app.needs_redraw = true;
}
sync_workflow_history_card_from_panel(app);
return;
}
if value.get("action").and_then(|v| v.as_str()) == Some("status") {
if let Some(runs) = value.get("runs").and_then(|r| r.as_array())
&& let Some(run) = runs.last()
{
apply_workflow_output_to_panel(app, &run.to_string());
}
return;
}
if let Some(mut panel) =
crate::tui::widgets::workflow_panel::WorkflowPanel::from_run_json(&value)
{
panel.locale = app.ui_locale;
app.workflow_panel = Some(panel);
app.needs_redraw = true;
sync_workflow_history_card_from_panel(app);
return;
}
if let Some(run_id) = value.get("run_id").and_then(|v| v.as_str()) {
use crate::tui::widgets::workflow_panel::{WorkflowPanelEvent, WorkflowPanelLifecycle};
let label = value
.get("workflow_goal")
.and_then(|v| v.as_str())
.or_else(|| value.get("workflow_id").and_then(|v| v.as_str()))
.unwrap_or(run_id)
.to_string();
let at_ms = value
.get("started_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let status = value
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("running");
app.apply_workflow_panel_event(WorkflowPanelEvent::RunStarted {
run_id: run_id.to_string(),
workflow_id: value
.get("workflow_id")
.and_then(|v| v.as_str())
.map(str::to_string),
workflow_goal: Some(label),
source_path: value
.get("source_path")
.and_then(|v| v.as_str())
.map(PathBuf::from),
token_budget: value.get("token_budget").and_then(|v| v.as_u64()),
at_ms,
});
if status != "running" {
let life = match status {
"completed" | "succeeded" => WorkflowPanelLifecycle::Succeeded,
"failed" => WorkflowPanelLifecycle::Failed,
"cancelled" | "canceled" => WorkflowPanelLifecycle::Cancelled,
_ => WorkflowPanelLifecycle::Running,
};
if life != WorkflowPanelLifecycle::Running {
app.apply_workflow_panel_event(WorkflowPanelEvent::RunCompleted {
status: life,
error: value
.get("error")
.and_then(|v| v.as_str())
.map(str::to_string),
at_ms: value
.get("completed_at_ms")
.and_then(|v| v.as_u64())
.unwrap_or(at_ms),
});
}
}
sync_workflow_history_card_from_panel(app);
}
}
pub(super) fn apply_workflow_ui_event(app: &mut App, run_id: &str, event: &serde_json::Value) {
use crate::tui::widgets::workflow_panel::WorkflowPanelEvent;
let mut event = event.clone();
if let Some(obj) = event.as_object_mut() {
obj.entry("run_id".to_string())
.or_insert_with(|| serde_json::Value::String(run_id.to_string()));
}
if let Some(panel_event) = WorkflowPanelEvent::from_json_value(&event) {
app.apply_workflow_panel_event(panel_event);
}
sync_workflow_history_card_from_panel(app);
}
fn sync_workflow_history_card_from_panel(app: &mut App) {
let Some(panel) = app.workflow_panel.as_ref() else {
return;
};
let run_id = panel.run_id.clone();
let snapshot = panel.to_run_json().to_string();
let mut target: Option<usize> = None;
let history_len = app.history.len();
let total = history_len
+ app
.active_cell
.as_ref()
.map(|a| a.entries().len())
.unwrap_or(0);
for idx in (0..total).rev() {
let Some(cell) = app.cell_at_virtual_index(idx) else {
continue;
};
let HistoryCell::Tool(ToolCell::Generic(generic)) = cell else {
continue;
};
if generic.name != "workflow" {
continue;
}
let matches_run = generic
.output
.as_deref()
.and_then(|out| serde_json::from_str::<serde_json::Value>(out).ok())
.and_then(|v| {
v.get("run_id")
.and_then(|id| id.as_str())
.map(|id| id == run_id)
})
.unwrap_or(false);
let is_running = generic.status == ToolStatus::Running;
if matches_run || (is_running && target.is_none()) {
target = Some(idx);
if matches_run {
break;
}
}
}
let Some(idx) = target else {
return;
};
if let Some(HistoryCell::Tool(ToolCell::Generic(generic))) = app.cell_at_virtual_index_mut(idx)
{
let replace = match generic.output.as_deref() {
None => true,
Some(existing) => {
let Ok(value) = serde_json::from_str::<serde_json::Value>(existing) else {
return;
};
let existing_run = value.get("run_id").and_then(|v| v.as_str()).unwrap_or("");
if !existing_run.is_empty() && existing_run != run_id {
return;
}
if generic.status == ToolStatus::Running {
true
} else {
value
.get("events")
.and_then(|e| e.as_array())
.is_none_or(|e| e.is_empty())
}
}
};
if replace {
generic.output = Some(snapshot);
generic.output_summary = Some(format!("workflow {}", run_id));
app.mark_history_updated();
}
}
}
fn refresh_active_tool_completion_timestamp(app: &mut App, cell_index: usize) {
if cell_index < app.history.len() {
return;
}
let entry_idx = cell_index - app.history.len();
let Some(cell) = app.cell_at_virtual_index(cell_index) else {
app.active_tool_entry_completed_at.remove(&entry_idx);
return;
};
if history_cell_has_running_tool(cell) {
app.active_tool_entry_completed_at.remove(&entry_idx);
} else {
app.active_tool_entry_completed_at
.entry(entry_idx)
.or_insert_with(Instant::now);
}
}
fn history_cell_has_running_tool(cell: &HistoryCell) -> bool {
let HistoryCell::Tool(tool) = cell else {
return false;
};
match tool {
ToolCell::Exec(exec) => exec.status == ToolStatus::Running,
ToolCell::Exploring(explore) => explore
.entries
.iter()
.any(|entry| entry.status == ToolStatus::Running),
ToolCell::PlanUpdate(plan) => plan.status == ToolStatus::Running,
ToolCell::PatchSummary(patch) => patch.status == ToolStatus::Running,
ToolCell::Review(review) => review.status == ToolStatus::Running,
ToolCell::Mcp(mcp) => mcp.status == ToolStatus::Running,
ToolCell::ViewImage(_) => false,
ToolCell::WebSearch(search) => search.status == ToolStatus::Running,
ToolCell::Generic(generic) => generic.status == ToolStatus::Running,
}
}
fn push_orphan_tool_completion(
app: &mut App,
tool_id: &str,
name: &str,
result: &Result<ToolResult, ToolError>,
) {
let status = tool_status_from_result(result);
let output = match result.as_ref() {
Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)),
Err(err) => Some(err.to_string()),
};
let history_threshold_before_push = app.history.len();
let active_in_flight = app.active_cell.is_some();
let spillover_path = result
.as_ref()
.ok()
.and_then(|r| r.metadata.as_ref())
.and_then(|m| m.get("spillover_path"))
.and_then(serde_json::Value::as_str)
.map(std::path::PathBuf::from);
let output_summary = output.as_deref().map(summarize_tool_output);
let is_diff = output.as_deref().is_some_and(output_looks_like_diff);
let mutation_receipt = result.as_ref().ok().and_then(|tool_result| {
crate::tui::history::FileMutationReceipt::from_success(&app.workspace, tool_result)
});
let cell = if let Some(receipt) = mutation_receipt {
let path = receipt
.files
.first()
.map_or_else(|| "<file>".to_string(), |file| file.path.clone());
let summary = receipt.semantic_summary();
HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell {
path,
summary,
status,
error: None,
receipt: Some(receipt),
}))
} else {
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status,
input_summary: None,
output,
prompts: None,
spillover_path,
output_summary,
is_diff,
}))
};
app.add_message(cell);
let cell_index = app.history.len().saturating_sub(1);
app.tool_details_by_cell.insert(
cell_index,
ToolDetailRecord {
tool_id: tool_id.to_string(),
tool_name: name.to_string(),
input: serde_json::Value::Null,
output: match result.as_ref() {
Ok(tool_result) => Some(tool_result.content.clone()),
Err(err) => Some(err.to_string()),
},
},
);
if active_in_flight {
let threshold = history_threshold_before_push;
for idx in app.tool_cells.values_mut() {
if *idx >= threshold {
*idx = idx.wrapping_add(1);
}
}
for (cell_idx, _) in app.exploring_entries.values_mut() {
if *cell_idx >= threshold {
*cell_idx = cell_idx.wrapping_add(1);
}
}
if let Some(idx) = app.exploring_cell.as_mut()
&& *idx >= threshold
{
*idx = idx.wrapping_add(1);
}
}
}
fn tool_status_from_result(result: &Result<ToolResult, ToolError>) -> ToolStatus {
match result.as_ref() {
Ok(tool_result) if is_deferred_schema_hydration(tool_result) => ToolStatus::Hydrated,
Ok(tool_result) => match tool_result.metadata.as_ref() {
Some(meta)
if meta
.get("status")
.and_then(|v| v.as_str())
.is_some_and(|s| s == "Running") =>
{
ToolStatus::Running
}
_ => {
if tool_result.success {
ToolStatus::Success
} else {
ToolStatus::Failed
}
}
},
Err(_) => ToolStatus::Failed,
}
}
fn is_deferred_schema_hydration(tool_result: &ToolResult) -> bool {
if !tool_result.success {
return false;
}
let Some(metadata) = tool_result.metadata.as_ref() else {
return false;
};
metadata
.get("event")
.and_then(serde_json::Value::as_str)
.is_some_and(|event| event == "tool.schema_hydrated")
&& metadata
.get("executed")
.and_then(serde_json::Value::as_bool)
.is_some_and(|executed| !executed)
}
fn is_exploring_tool(name: &str) -> bool {
matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files")
}
fn is_exec_tool(name: &str) -> bool {
matches!(
name,
"exec_shell"
| "exec_shell_wait"
| "exec_shell_interact"
| "exec_shell_cancel"
| "exec_wait"
| "exec_interact"
)
}
pub(super) fn refreshes_workspace_context_on_completion(name: &str) -> bool {
matches!(
name,
"exec_shell"
| "exec_shell_wait"
| "exec_shell_interact"
| "exec_shell_cancel"
| "exec_wait"
| "exec_interact"
| "task_shell_start"
| "task_shell_wait"
| "write_file"
| "edit_file"
| "apply_patch"
)
}
pub(super) fn exploring_label(name: &str, input: &serde_json::Value) -> String {
let fallback = format!("{name} tool");
let obj = input.as_object();
match name {
"read_file" => obj
.and_then(|o| o.get("path"))
.and_then(|v| v.as_str())
.map_or(fallback, |path| format!("Reading {path}")),
"list_dir" => obj
.and_then(|o| o.get("path"))
.and_then(|v| v.as_str())
.map_or("Listing directory".to_string(), |path| {
format!("Listing {path}")
}),
"grep_files" => {
let pattern = obj
.and_then(|o| o.get("pattern"))
.and_then(|v| v.as_str())
.unwrap_or("pattern");
format!("Searching for `{pattern}`")
}
"list_files" => "Listing files".to_string(),
_ => fallback,
}
}
fn is_mcp_tool(name: &str) -> bool {
name.starts_with("mcp_")
}
fn is_view_image_tool(name: &str) -> bool {
matches!(name, "view_image" | "view_image_file" | "view_image_tool")
}
fn is_web_search_tool(name: &str) -> bool {
matches!(name, "web_search" | "search_web" | "search" | "web.run")
|| name.ends_with("_web_search")
}
fn web_search_query(input: &serde_json::Value) -> String {
if let Some(searches) = input.get("search_query").and_then(|v| v.as_array())
&& let Some(first) = searches.first()
&& let Some(q) = first.get("q").and_then(|v| v.as_str())
{
return q.to_string();
}
input
.get("query")
.or_else(|| input.get("q"))
.or_else(|| input.get("search"))
.and_then(|v| v.as_str())
.unwrap_or("Web search")
.to_string()
}
fn review_target_label(input: &serde_json::Value) -> String {
let target = input
.get("target")
.and_then(|v| v.as_str())
.unwrap_or("review")
.trim();
let kind = input
.get("kind")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_ascii_lowercase();
let staged = input
.get("staged")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let target_lower = target.to_ascii_lowercase();
if kind == "diff"
|| target_lower == "diff"
|| target_lower == "git diff"
|| target_lower == "staged"
|| target_lower == "cached"
{
if staged || target_lower == "staged" || target_lower == "cached" {
return "git diff --cached".to_string();
}
return "git diff".to_string();
}
target.to_string()
}
fn parse_plan_input(input: &serde_json::Value) -> PlanSnapshot {
PlanSnapshot::from_tool_input(input)
}
fn parse_file_mutation_summary(semantic_name: &str, input: &serde_json::Value) -> (String, String) {
if semantic_name != "apply_patch" {
let path = input
.get("path")
.and_then(serde_json::Value::as_str)
.filter(|path| !path.trim().is_empty())
.unwrap_or("<file>")
.to_string();
let summary = match semantic_name {
"write_file" => "Writing file",
"edit_file" => "Editing file",
_ => "Changing file",
}
.to_string();
return (path, summary);
}
let patch_text = match normalize_apply_patch_input(input) {
Ok(NormalizedApplyPatchInput::Replacement {
entries: changes, ..
}) => {
let count = changes.len();
let path = changes
.first()
.and_then(|c| c.get("path"))
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| "<file>".to_string());
let label = if count <= 1 {
path
} else {
format!("{count} files")
};
let summary = format!("Changes: {count} file(s)");
return (label, summary);
}
Ok(NormalizedApplyPatchInput::Patch(patch)) => patch,
Err(_) => "",
};
let paths = extract_patch_paths(patch_text);
let path = input
.get("path")
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| {
if paths.len() == 1 {
paths.first().cloned()
} else if paths.is_empty() {
None
} else {
Some(format!("{} files", paths.len()))
}
})
.unwrap_or_else(|| "<file>".to_string());
let (adds, removes) = count_patch_changes(patch_text);
let summary = if adds == 0 && removes == 0 {
"Patch applied".to_string()
} else {
format!("Changes: +{adds} / -{removes}")
};
(path, summary)
}
fn extract_patch_paths(patch: &str) -> Vec<String> {
let mut paths = Vec::new();
for line in patch.lines() {
if let Some(rest) = line.strip_prefix("+++ ") {
let raw = rest.trim();
if raw == "/dev/null" || raw == "dev/null" {
continue;
}
let raw = raw.strip_prefix("b/").unwrap_or(raw);
if !paths.contains(&raw.to_string()) {
paths.push(raw.to_string());
}
} else if let Some(rest) = line.strip_prefix("diff --git ") {
let parts: Vec<&str> = rest.split_whitespace().collect();
if let Some(path) = parts.get(1).or_else(|| parts.first()) {
let raw = path.trim();
let raw = raw
.strip_prefix("b/")
.or_else(|| raw.strip_prefix("a/"))
.unwrap_or(raw);
if !paths.contains(&raw.to_string()) {
paths.push(raw.to_string());
}
}
}
}
paths
}
fn count_patch_changes(patch: &str) -> (usize, usize) {
let mut adds = 0;
let mut removes = 0;
for line in patch.lines() {
if line.starts_with("+++") || line.starts_with("---") {
continue;
}
if line.starts_with('+') {
adds += 1;
} else if line.starts_with('-') {
removes += 1;
}
}
(adds, removes)
}
fn exec_command_from_input(input: &serde_json::Value) -> Option<String> {
input
.get("command")
.and_then(|v| v.as_str())
.map(std::string::ToString::to_string)
}
fn exec_target_from_input(input: &serde_json::Value) -> String {
exec_command_from_input(input).unwrap_or_else(|| {
input
.get("task_id")
.or_else(|| input.get("id"))
.and_then(|v| v.as_str())
.map(|task_id| format!("command {task_id}"))
.unwrap_or_else(|| "command".to_string())
})
}
fn exec_source_from_input(input: &serde_json::Value) -> ExecSource {
match input.get("source").and_then(|v| v.as_str()) {
Some(source) if source.eq_ignore_ascii_case("user") => ExecSource::User,
_ => ExecSource::Assistant,
}
}
fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(String, bool)> {
let command = exec_target_from_input(input);
let command_display = format!("\"{command}\"");
let interaction_input = input
.get("input")
.or_else(|| input.get("stdin"))
.or_else(|| input.get("data"))
.and_then(|v| v.as_str());
let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait");
let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact");
let is_cancel_tool = name == "exec_shell_cancel";
if is_cancel_tool {
let summary = if input.get("all").and_then(serde_json::Value::as_bool) == Some(true) {
"Cancelled all background commands".to_string()
} else if let Some(task_id) = input
.get("task_id")
.or_else(|| input.get("id"))
.and_then(serde_json::Value::as_str)
{
format!("Cancelled command {task_id}")
} else {
"Cancelled background command".to_string()
};
return Some((summary, false));
}
if is_interact_tool || interaction_input.is_some() {
let preview = interaction_input.map(summarize_interaction_input);
let summary = if let Some(preview) = preview {
format!("Interacted with {command_display}, sent {preview}")
} else {
format!("Interacted with {command_display}")
};
return Some((summary, false));
}
if is_wait_tool || input.get("wait").and_then(serde_json::Value::as_bool) == Some(true) {
if exec_command_from_input(input).is_none()
&& let Some(task_id) = input
.get("task_id")
.or_else(|| input.get("id"))
.and_then(|v| v.as_str())
{
return Some((format!("Waiting for command {task_id}"), true));
}
return Some((format!("Waited for {command_display}"), true));
}
None
}
fn summarize_interaction_input(input: &str) -> String {
let mut single_line = input.replace('\r', "");
single_line = single_line.replace('\n', "\\n");
single_line = single_line.replace('\"', "'");
let max_len = 80;
if single_line.chars().count() <= max_len {
return format!("\"{single_line}\"");
}
let mut out = String::new();
for ch in single_line.chars().take(max_len.saturating_sub(3)) {
out.push(ch);
}
out.push_str("...");
format!("\"{out}\"")
}
fn exec_is_background(input: &serde_json::Value) -> bool {
input
.get("background")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::plan::StepStatus;
use serde_json::json;
#[cfg(unix)]
fn hook_log_lines_eventually(path: &std::path::Path, expected: usize) -> Vec<String> {
for _ in 0..100 {
let lines = std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect::<Vec<_>>();
if lines.len() >= expected {
return lines;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
std::fs::read_to_string(path)
.unwrap_or_default()
.lines()
.map(str::to_string)
.collect()
}
#[cfg(unix)]
#[test]
fn ignored_tool_calls_still_fire_after_and_error_hooks_once() {
use crate::hooks::{Hook, HookEvent, HookExecutor, HooksConfig};
let dir = tempfile::tempdir().expect("tempdir");
let after_log = dir.path().join("after.log");
let error_log = dir.path().join("error.log");
let script = |path: &std::path::Path| {
format!(
"printf '%s\\n' \"$DEEPSEEK_TOOL_CALL_ID\" >> {}",
path.display()
)
};
let mut app = crate::test_support::test_app_with_options(
crate::test_support::test_tui_options(dir.path()),
);
app.workspace = dir.path().to_path_buf();
app.hooks = HookExecutor::new(
HooksConfig {
enabled: true,
hooks: vec![
Hook::new(HookEvent::ToolCallAfter, &script(&after_log)).with_name("after"),
Hook::new(HookEvent::OnError, &script(&error_log)).with_name("error"),
],
..HooksConfig::default()
},
dir.path().to_path_buf(),
);
let id = "call_ignored_1";
app.ignored_tool_calls.insert(id.to_string());
let failed: Result<ToolResult, ToolError> = Ok(ToolResult::error("boom"));
handle_tool_call_complete(&mut app, id, "exec_shell", &failed);
assert!(!app.ignored_tool_calls.contains(id));
let after = hook_log_lines_eventually(&after_log, 1);
let errors = hook_log_lines_eventually(&error_log, 1);
assert_eq!(after, vec![id]);
assert_eq!(errors, vec![id]);
let second = "call_ignored_2";
app.ignored_tool_calls.insert(second.to_string());
handle_tool_call_complete(
&mut app,
second,
"exec_shell",
&Ok(ToolResult::success("ok")),
);
let after = hook_log_lines_eventually(&after_log, 2);
let errors = hook_log_lines_eventually(&error_log, 1);
assert_eq!(after, vec![id, second]);
assert_eq!(errors, vec![id]);
}
#[test]
fn adaptive_evidence_late_foreign_and_duplicate_completions_are_ignored() {
let result = Ok(ToolResult::success("bounded").with_metadata(json!({
"artifact_session_id": "session-a",
"artifact_id": "art_call-a"
})));
assert!(evidence_completion_identity_should_be_ignored(
Some("session-b"),
std::iter::empty(),
"call-a",
&result,
));
assert!(evidence_completion_identity_should_be_ignored(
Some("session-a"),
[("art_call-a", "call-a")],
"call-a",
&result,
));
assert!(!evidence_completion_identity_should_be_ignored(
Some("session-a"),
std::iter::empty(),
"call-a",
&result,
));
}
#[test]
fn web_search_presentation_reads_source_degradation_and_citation_count() {
let presentation = web_search_presentation(
&json!({
"source": "provider-native/xai/grok-4.5",
"results": [
{"ref_id": "web_a", "url": "https://example.com/a"},
{"ref_id": "web_b", "url": "https://example.com/b"}
],
"receipt": {
"degraded": [
{"kind": "backend_unavailable", "backend": "provider_native"},
{"kind": "backend_fallback", "from": "provider_native", "to": "tavily"}
]
}
})
.to_string(),
);
assert_eq!(
presentation.source.as_deref(),
Some("provider-native/xai/grok-4.5")
);
assert_eq!(
presentation.degraded.as_deref(),
Some("provider_native unavailable; provider_native -> tavily")
);
assert_eq!(presentation.ref_count, 2);
}
#[test]
fn web_run_presentation_reads_nested_search_receipts() {
let presentation = web_search_presentation(
&json!({
"search_query": [{
"source": "duckduckgo",
"results": [{"ref_id": "web_a"}],
"receipt": {
"degraded": [{"kind": "knob_ignored", "knob": "recency"}]
}
}]
})
.to_string(),
);
assert_eq!(presentation.source.as_deref(), Some("duckduckgo"));
assert_eq!(presentation.degraded.as_deref(), Some("recency ignored"));
assert_eq!(presentation.ref_count, 1);
}
#[test]
fn parse_plan_input_accepts_legacy_payload() {
let snapshot = parse_plan_input(&json!({
"explanation": "Legacy explanation",
"plan": [
{ "step": "inspect", "status": "completed" },
{ "step": "patch", "status": "in_progress" }
]
}));
assert_eq!(snapshot.explanation.as_deref(), Some("Legacy explanation"));
assert_eq!(snapshot.items.len(), 2);
assert_eq!(snapshot.items[0].status, StepStatus::Completed);
assert_eq!(snapshot.items[1].status, StepStatus::InProgress);
}
#[test]
fn parse_plan_input_extracts_rich_artifact_fields() {
let snapshot = parse_plan_input(&json!({
"title": " PlanArtifact ",
"objective": "Make Plan mode reviewable",
"context_summary": "Grounded in issue #2691",
"sources_used": [" gh issue view 2691 ", ""],
"critical_files": ["crates/tui/src/tools/plan.rs"],
"constraints": ["No secrets"],
"recommended_approach": "Enrich update_plan",
"verification_plan": "Run focused tests",
"risks_and_unknowns": "Replay may drift",
"handoff_packet": "Continue with session replay",
"plan": [
{ "step": " ", "status": "completed" },
{ "step": "render all fields", "status": "weird" }
]
}));
assert_eq!(snapshot.title.as_deref(), Some("PlanArtifact"));
assert_eq!(snapshot.sources_used, vec!["gh issue view 2691"]);
assert_eq!(
snapshot.critical_files,
vec!["crates/tui/src/tools/plan.rs"]
);
assert_eq!(snapshot.constraints, vec!["No secrets"]);
assert_eq!(
snapshot.verification_plan.as_deref(),
Some("Run focused tests")
);
assert_eq!(snapshot.items.len(), 1);
assert_eq!(snapshot.items[0].step, "render all fields");
assert_eq!(snapshot.items[0].status, StepStatus::Pending);
}
#[test]
fn parse_patch_summary_treats_replace_and_legacy_changes_equally() {
let replacements = json!([{
"path": "src/lib.rs",
"content": "fn replacement() {}\n"
}]);
let canonical =
parse_file_mutation_summary("apply_patch", &json!({"replace": replacements.clone()}));
let legacy = parse_file_mutation_summary("apply_patch", &json!({"changes": replacements}));
assert_eq!(canonical, legacy);
}
#[test]
fn visible_tool_output_maps_no_output_placeholder_to_none() {
assert_eq!(visible_tool_output("(no output)"), None);
assert_eq!(visible_tool_output(" (no output)\n"), None);
}
#[test]
fn visible_tool_output_preserves_real_content() {
assert_eq!(
visible_tool_output("compiled 3 crates").as_deref(),
Some("compiled 3 crates")
);
assert_eq!(
visible_tool_output("step 1: (no output) — continuing").as_deref(),
Some("step 1: (no output) — continuing")
);
assert_eq!(visible_tool_output("").as_deref(), Some(""));
}
#[test]
fn exec_cell_without_output_suppresses_placeholder_in_live_mode() {
use crate::tui::history::{ExecCell, ExecSource, ToolCell, ToolStatus};
let cell = ToolCell::Exec(ExecCell {
command: "true".to_string(),
status: ToolStatus::Success,
output: None,
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: None,
duration_ms: Some(120),
stale_elapsed_since_output_ms: None,
source: ExecSource::Assistant,
interaction: None,
output_summary: None,
});
let live: String = cell
.lines(80)
.iter()
.flat_map(|line| line.spans.iter().map(|s| s.content.to_string()))
.collect();
assert!(
!live.contains("(no output)"),
"Live mode must suppress the placeholder: {live:?}"
);
let transcript: String = cell
.transcript_lines(80)
.iter()
.flat_map(|line| line.spans.iter().map(|s| s.content.to_string()))
.collect();
assert!(
transcript.contains("(no output)"),
"Transcript mode still records the placeholder: {transcript:?}"
);
}
#[test]
fn reported_tool_exit_code_reads_only_real_metadata_codes() {
let with_code = Ok(ToolResult {
content: "boom".to_string(),
success: false,
metadata: Some(serde_json::json!({ "exit_code": 127 })),
});
assert_eq!(super::reported_tool_exit_code(&with_code), Some(127));
let zero = Ok(ToolResult {
content: "ok".to_string(),
success: true,
metadata: Some(serde_json::json!({ "exit_code": 0 })),
});
assert_eq!(super::reported_tool_exit_code(&zero), Some(0));
let no_metadata = Ok(ToolResult::error("failed"));
assert_eq!(super::reported_tool_exit_code(&no_metadata), None);
let null_code = Ok(ToolResult {
content: String::new(),
success: true,
metadata: Some(serde_json::json!({ "exit_code": serde_json::Value::Null })),
});
assert_eq!(super::reported_tool_exit_code(&null_code), None);
let wrong_type = Ok(ToolResult {
content: String::new(),
success: false,
metadata: Some(serde_json::json!({ "exit_code": "127" })),
});
assert_eq!(super::reported_tool_exit_code(&wrong_type), None);
let windows_crash = Ok(ToolResult {
content: String::new(),
success: false,
metadata: Some(serde_json::json!({ "exit_code": 3_221_225_477_i64 })),
});
assert_eq!(
super::reported_tool_exit_code(&windows_crash),
Some(3_221_225_477)
);
let errored: Result<ToolResult, ToolError> =
Err(ToolError::execution_failed("no such tool"));
assert_eq!(super::reported_tool_exit_code(&errored), None);
}
}