返回 CodeWhale
tool_routing.rs
根目录 / crates / tui / src / tui / tool_routing.rs
1 //! Active tool-card routing helpers for the TUI loop.
2
3 use std::path::PathBuf;
4 use std::time::Instant;
5
6 use crate::hooks::HookEvent;
7 use crate::tools::ReviewOutput;
8 use crate::tools::apply_patch::{NormalizedApplyPatchInput, normalize_apply_patch_input};
9 use crate::tools::canonical_action::canonical_action_alias;
10 use crate::tools::plan::PlanSnapshot;
11 use crate::tools::spec::{ToolError, ToolResult};
12 use crate::tui::active_cell::ActiveCell;
13 use crate::tui::app::{App, ToolDetailRecord, ToolEvidence};
14 use crate::tui::history::{
15 ExecCell, ExecSource, ExploringEntry, GenericToolCell, HistoryCell, McpToolCell,
16 PatchSummaryCell, PlanUpdateCell, ReviewCell, ToolCell, ToolStatus, ViewImageCell,
17 WebSearchCell, output_looks_like_diff, summarize_mcp_output, summarize_tool_args,
18 summarize_tool_output,
19 };
20 use crate::tui::workspace_context;
21
22 #[allow(clippy::too_many_lines)]
23 pub(super) fn handle_tool_call_started(
24 app: &mut App,
25 id: &str,
26 name: &str,
27 input: &serde_json::Value,
28 ) {
29 // #2511: ToolCallBefore gate moved to turn-loop planning loop
30 // (Engine::run_turn). Removing observer-only firing
31 // here to avoid double-firing hooks for each tool call.
32 // Hooks that need observation can configure ToolCallBefore on
33 // the turn-loop gate — it processes the denial (exit code 2).
34
35 let id = id.to_string();
36 let semantic_name = canonical_action_alias(name, input);
37
38 // All in-flight tool work for the current turn lives in `app.active_cell`
39 // until the turn completes. This mirrors Codex's contract: ONE active cell
40 // mutates in place; finalized history isn't touched until flush. This
41 // keeps the transcript stable while parallel completions arrive in any
42 // order.
43 if app.active_cell.is_none() {
44 app.active_cell = Some(ActiveCell::new());
45 }
46
47 if is_exploring_tool(semantic_name) {
48 let label = exploring_label(semantic_name, input);
49 // ensure_exploring + append_to_exploring keeps all parallel exploring
50 // starts in a single ExploringCell entry.
51 let active = app.active_cell.as_mut().expect("active_cell just ensured");
52 let entry_idx = active.ensure_exploring();
53 app.active_tool_entry_completed_at.remove(&entry_idx);
54 let inner = active
55 .append_to_exploring(
56 id.clone(),
57 ExploringEntry {
58 label,
59 status: ToolStatus::Running,
60 },
61 )
62 .map_or(0, |(_, inner)| inner);
63 app.exploring_cell = Some(entry_idx);
64 let virtual_index = app.history.len() + entry_idx;
65 app.exploring_entries
66 .insert(id.clone(), (virtual_index, inner));
67 register_tool_cell(app, &id, name, input, virtual_index);
68 app.mark_history_updated();
69 return;
70 }
71
72 // Non-exploring tool: each is its own entry inside the active cell. We
73 // intentionally do NOT clear `exploring_cell` here — the active cell can
74 // hold both an exploring aggregate AND independent tool entries
75 // simultaneously, which is exactly the case CX#7 fixes.
76
77 if is_exec_tool(semantic_name) {
78 let command = exec_target_from_input(input);
79 let source = exec_source_from_input(input);
80 let interaction = exec_interaction_summary(semantic_name, input);
81 let mut is_wait = false;
82
83 if let Some((summary, wait)) = interaction.as_ref() {
84 is_wait = *wait;
85 if is_wait
86 && app
87 .last_exec_wait_command
88 .as_ref()
89 .is_some_and(|last| last == &command)
90 {
91 app.ignored_tool_calls.insert(id);
92 return;
93 }
94 if is_wait {
95 app.last_exec_wait_command = Some(command.clone());
96 }
97
98 push_active_tool_cell(
99 app,
100 &id,
101 name,
102 input,
103 HistoryCell::Tool(ToolCell::Exec(ExecCell {
104 command,
105 status: ToolStatus::Running,
106 output: None,
107 live_output: None,
108 shell_task_id: None,
109 owner_agent_id: None,
110 owner_agent_name: None,
111 started_at: Some(Instant::now()),
112 duration_ms: None,
113 stale_elapsed_since_output_ms: None,
114 source,
115 interaction: Some(summary.clone()),
116 output_summary: None,
117 })),
118 );
119 return;
120 }
121
122 if exec_is_background(input)
123 && app
124 .last_exec_wait_command
125 .as_ref()
126 .is_some_and(|last| last == &command)
127 {
128 app.ignored_tool_calls.insert(id);
129 return;
130 }
131 if exec_is_background(input) && !is_wait {
132 app.last_exec_wait_command = Some(command.clone());
133 }
134
135 push_active_tool_cell(
136 app,
137 &id,
138 name,
139 input,
140 HistoryCell::Tool(ToolCell::Exec(ExecCell {
141 command,
142 status: ToolStatus::Running,
143 output: None,
144 live_output: None,
145 shell_task_id: None,
146 owner_agent_id: None,
147 owner_agent_name: None,
148 started_at: Some(Instant::now()),
149 duration_ms: None,
150 stale_elapsed_since_output_ms: None,
151 source,
152 interaction: None,
153 output_summary: None,
154 })),
155 );
156 return;
157 }
158
159 if semantic_name == "update_plan" {
160 let snapshot = parse_plan_input(input);
161 push_active_tool_cell(
162 app,
163 &id,
164 name,
165 input,
166 HistoryCell::Tool(ToolCell::PlanUpdate(PlanUpdateCell {
167 snapshot,
168 status: ToolStatus::Running,
169 })),
170 );
171 return;
172 }
173
174 if matches!(semantic_name, "write_file" | "edit_file" | "apply_patch") {
175 let (path, summary) = parse_file_mutation_summary(semantic_name, input);
176 push_active_tool_cell(
177 app,
178 &id,
179 name,
180 input,
181 HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell {
182 path,
183 summary,
184 status: ToolStatus::Running,
185 error: None,
186 receipt: None,
187 })),
188 );
189 return;
190 }
191
192 if semantic_name == "review" {
193 let target = review_target_label(input);
194 push_active_tool_cell(
195 app,
196 &id,
197 name,
198 input,
199 HistoryCell::Tool(ToolCell::Review(ReviewCell {
200 target,
201 status: ToolStatus::Running,
202 output: None,
203 error: None,
204 })),
205 );
206 return;
207 }
208
209 if is_mcp_tool(semantic_name) {
210 push_active_tool_cell(
211 app,
212 &id,
213 name,
214 input,
215 HistoryCell::Tool(ToolCell::Mcp(McpToolCell {
216 tool: name.to_string(),
217 status: ToolStatus::Running,
218 content: None,
219 is_image: false,
220 })),
221 );
222 return;
223 }
224
225 if is_view_image_tool(semantic_name) {
226 if let Some(path) = input.get("path").and_then(|v| v.as_str()) {
227 let raw_path = PathBuf::from(path);
228 let display_path = raw_path
229 .strip_prefix(&app.workspace)
230 .unwrap_or(&raw_path)
231 .to_path_buf();
232 push_active_tool_cell(
233 app,
234 &id,
235 name,
236 input,
237 HistoryCell::Tool(ToolCell::ViewImage(ViewImageCell { path: display_path })),
238 );
239 }
240 return;
241 }
242
243 if is_web_search_tool(semantic_name) {
244 let query = web_search_query(input);
245 push_active_tool_cell(
246 app,
247 &id,
248 name,
249 input,
250 HistoryCell::Tool(ToolCell::WebSearch(WebSearchCell {
251 query,
252 status: ToolStatus::Running,
253 summary: None,
254 source: None,
255 degraded: None,
256 ref_count: 0,
257 })),
258 );
259 return;
260 }
261
262 let mut input_summary = summarize_tool_args(input);
263 // Lead the `agent` args summary with the non-default action so renderers
264 // can tell inspections (peek/status/wait) apart from spawns without a
265 // schema change — a peek must not draw the same "delegate done" line as
266 // a launch (#4112, dogfood A5).
267 if name == "agent"
268 && let Some(action) = input.get("action").and_then(serde_json::Value::as_str)
269 {
270 let action = action.trim().to_ascii_lowercase();
271 let already_leads = input_summary
272 .as_deref()
273 .is_some_and(|summary| summary.starts_with("action:"));
274 if !action.is_empty()
275 && !already_leads
276 && action != "start"
277 && action != "spawn"
278 && action != "run"
279 {
280 input_summary = Some(match input_summary {
281 Some(rest) => format!("action: {action} {rest}"),
282 None => format!("action: {action}"),
283 });
284 }
285 }
286 push_active_tool_cell(
287 app,
288 &id,
289 name,
290 input,
291 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
292 name: semantic_name.to_string(),
293 status: ToolStatus::Running,
294 input_summary,
295 output: None,
296 prompts: None,
297 spillover_path: None,
298 output_summary: None,
299 is_diff: false,
300 })),
301 );
302 }
303
304 /// Push a tool cell as a new entry in `active_cell`, register the tool id,
305 /// and write a stub detail record so the pager / Ctrl+O can find it.
306 fn push_active_tool_cell(
307 app: &mut App,
308 tool_id: &str,
309 tool_name: &str,
310 input: &serde_json::Value,
311 cell: HistoryCell,
312 ) {
313 if app.active_cell.is_none() {
314 app.active_cell = Some(ActiveCell::new());
315 }
316 let active = app.active_cell.as_mut().expect("active_cell just ensured");
317 let entry_idx = active.push_tool(tool_id.to_string(), cell);
318 app.active_tool_entry_completed_at.remove(&entry_idx);
319 let virtual_index = app.history.len() + entry_idx;
320 register_tool_cell(app, tool_id, tool_name, input, virtual_index);
321 app.mark_history_updated();
322 }
323
324 fn register_tool_cell(
325 app: &mut App,
326 tool_id: &str,
327 tool_name: &str,
328 input: &serde_json::Value,
329 cell_index: usize,
330 ) {
331 app.tool_cells.insert(tool_id.to_string(), cell_index);
332 let record = ToolDetailRecord {
333 tool_id: tool_id.to_string(),
334 tool_name: tool_name.to_string(),
335 input: input.clone(),
336 output: None,
337 };
338 if cell_index < app.history.len() {
339 app.tool_details_by_cell.insert(cell_index, record);
340 } else {
341 // Active-cell entry: keep the detail record in `active_tool_details`
342 // until the active cell flushes. `flush_active_cell` migrates these
343 // records into `tool_details_by_cell` keyed by the eventual real
344 // cell index.
345 app.active_tool_details.insert(tool_id.to_string(), record);
346 }
347 }
348
349 /// Per-record ceiling on a retained tool output (#5472 finding 3).
350 ///
351 /// These strings are kept for the transcript's expand-tool-output view, which
352 /// shows an excerpt — nothing reads the whole thing. `Bash` already arrives
353 /// truncated at 30 KB, but tools with no such contract (`rlm`, large file
354 /// reads, MCP responses) previously stored whatever they returned, for every
355 /// call, until the 5,000-cell history fold.
356 const TOOL_DETAIL_OUTPUT_MAX_BYTES: usize = 64 * 1024;
357
358 /// Ceiling on retained tool outputs across the whole transcript.
359 ///
360 /// The history cap is counted in *cells*, so 5,000 cells each holding a large
361 /// output was bounded only in principle. Past this budget the oldest cells'
362 /// outputs are released — oldest first, because both other consumers of this
363 /// map (`context_inspector`, `file_picker_relevance`) already read only the
364 /// most recent records, and the expand view degrades to "not retained" rather
365 /// than lying about the content.
366 const TOOL_DETAIL_TOTAL_BUDGET_BYTES: usize = 8 * 1024 * 1024;
367
368 /// Truncate to a whole-character boundary, naming what was dropped.
369 fn bounded_tool_detail_output(mut text: String) -> String {
370 if text.len() <= TOOL_DETAIL_OUTPUT_MAX_BYTES {
371 return text;
372 }
373 let original = text.len();
374 let mut end = TOOL_DETAIL_OUTPUT_MAX_BYTES;
375 while end > 0 && !text.is_char_boundary(end) {
376 end -= 1;
377 }
378 text.truncate(end);
379 text.push_str(&format!(
380 "\n\n[Tool output retained up to {TOOL_DETAIL_OUTPUT_MAX_BYTES} bytes of {original}; \
381 the transcript keeps an excerpt, not the whole result.]"
382 ));
383 text
384 }
385
386 fn store_tool_detail_output(
387 app: &mut App,
388 tool_id: &str,
389 cell_index: usize,
390 result: &Result<ToolResult, ToolError>,
391 ) {
392 let payload = bounded_tool_detail_output(match result {
393 Ok(tool_result) => tool_result.content.clone(),
394 Err(err) => err.to_string(),
395 });
396 if cell_index < app.history.len()
397 && let Some(detail) = app.tool_details_by_cell.get_mut(&cell_index)
398 {
399 detail.output = Some(payload.clone());
400 }
401 // Also write to the active table while the entry might still live there;
402 // some callsites pre-rewrite cell_index but the active_tool_details map is
403 // the canonical source for in-flight outputs.
404 if let Some(detail) = app.active_tool_details.get_mut(tool_id) {
405 detail.output = Some(payload);
406 }
407 release_oldest_tool_detail_outputs(app);
408 }
409
410 /// Hold the retained-output total under [`TOOL_DETAIL_TOTAL_BUDGET_BYTES`] by
411 /// dropping the oldest cells' outputs. The records themselves stay, so the
412 /// inspector still lists the call and its input.
413 fn release_oldest_tool_detail_outputs(app: &mut App) {
414 let mut total = 0usize;
415 for detail in app.tool_details_by_cell.values() {
416 total = total.saturating_add(detail.output.as_ref().map_or(0, String::len));
417 }
418 if total <= TOOL_DETAIL_TOTAL_BUDGET_BYTES {
419 return;
420 }
421 let mut oldest_first: Vec<usize> = app
422 .tool_details_by_cell
423 .iter()
424 .filter(|(_, detail)| detail.output.is_some())
425 .map(|(index, _)| *index)
426 .collect();
427 oldest_first.sort_unstable();
428 for index in oldest_first {
429 if total <= TOOL_DETAIL_TOTAL_BUDGET_BYTES {
430 break;
431 }
432 if let Some(detail) = app.tool_details_by_cell.get_mut(&index) {
433 let freed = detail.output.as_ref().map_or(0, String::len);
434 detail.output = None;
435 total = total.saturating_sub(freed);
436 }
437 }
438 }
439
440 #[allow(clippy::too_many_lines)]
441 /// Inspect a tool's success metadata for the `child_*` token-usage
442 /// fields that tools spawning their own LLM calls populate (e.g.
443 /// `rlm`). Roll any reported child-token cost into the session's
444 /// running sub-agent cost counter so the footer total reflects all
445 /// tokens the user is actually billed for, not just the parent turn's
446 /// tokens.
447 ///
448 /// Without this hook, an RLM-heavy session shows a fraction of the
449 /// real spend because the parent turn's `Usage` only counts the
450 /// orchestrator's tokens, not the dozens of `deepseek-v4-flash` child
451 /// rounds RLM fans out under the hood (#524).
452 fn accrue_child_token_cost_if_any(app: &mut App, result: &Result<ToolResult, ToolError>) {
453 let Ok(tool_result) = result else { return };
454 let Some(metadata) = tool_result.metadata.as_ref() else {
455 return;
456 };
457 if let Some(batch) = crate::cost_status::child_usage_records_from_metadata(metadata) {
458 for record in &batch.records {
459 accrue_child_route_usage(app, &record.usage);
460 }
461 for record in &batch.drop_records {
462 let pending = crate::cost_status::background_cost_for_runtime_drop(record);
463 app.absorb_pending_background_cost(&pending);
464 }
465 let residual_dropped_records = batch
466 .dropped_records
467 .saturating_sub(u64::try_from(batch.drop_records.len()).unwrap_or(u64::MAX));
468 if residual_dropped_records > 0 {
469 let dropped = u32::try_from(residual_dropped_records).unwrap_or(u32::MAX);
470 app.session.cost_unpriced_turns =
471 app.session.cost_unpriced_turns.saturating_add(dropped);
472 app.session.cost_cny_unpriced_turns =
473 app.session.cost_cny_unpriced_turns.saturating_add(dropped);
474 app.session
475 .cost_unpriced_reasons
476 .insert("routed_usage_receipt_missing".to_string());
477 app.session
478 .cost_cny_unpriced_reasons
479 .insert("routed_usage_receipt_missing".to_string());
480 }
481 return;
482 }
483 let Some(route) = crate::cost_status::child_route_envelope_from_metadata(metadata) else {
484 return;
485 };
486 // Use the same parser as the runtime host. It deliberately returns a
487 // zero-valued usage record when the producer emitted the canonical child
488 // fields: a model-backed call is still an auditable/priced-zero call, and
489 // replay/server-tool telemetry must not disappear in the TUI projection.
490 let Some(usage) = crate::cost_status::child_usage_from_metadata(metadata) else {
491 return;
492 };
493 accrue_child_route_usage(
494 app,
495 &crate::cost_status::EffectiveRouteUsage { route, usage },
496 );
497 }
498
499 fn accrue_child_route_usage(app: &mut App, routed: &crate::cost_status::EffectiveRouteUsage) {
500 // `route` is the child's own dispatch receipt, rehydrated from the
501 // complete `child_*` metadata `attach_child_usage_metadata` emits at the
502 // child's wire boundary (review/verify/rlm are the three producers). An
503 // incomplete or legacy payload rehydrates as `RouteBillingMode::Unknown`,
504 // so a child never inherits the live `app.billing_presentation` chip and a
505 // `/provider` switch between dispatch and arrival cannot retro-bill it.
506 //
507 // Sub-agent spend lands in the same displayed total as parent turns, so it
508 // has to feed the same completeness counters — otherwise `/cost` would call
509 // a total complete while an unpriced child turn is missing from it.
510 let audit = routed.route.audit(&routed.usage);
511 app.record_turn_cost_audit(&audit);
512 app.record_turn_cost_route_receipt(routed.route.receipt(&audit));
513 if let Some(cost) = audit.estimate {
514 app.accrue_subagent_cost_estimate(cost);
515 }
516 }
517
518 fn record_spillover_artifact_if_any(
519 app: &mut App,
520 id: &str,
521 name: &str,
522 result: &Result<ToolResult, ToolError>,
523 ) {
524 let Ok(tool_result) = result else { return };
525 let Some(path) = tool_result
526 .metadata
527 .as_ref()
528 .and_then(|metadata| metadata.get("spillover_path"))
529 .and_then(serde_json::Value::as_str)
530 .map(PathBuf::from)
531 else {
532 return;
533 };
534 let metadata = tool_result.metadata.as_ref();
535 let session_id = metadata
536 .and_then(|metadata| metadata.get("artifact_session_id"))
537 .and_then(serde_json::Value::as_str)
538 .or(app.current_session_id.as_deref())
539 .unwrap_or("");
540 let storage_path = metadata
541 .and_then(|metadata| metadata.get("artifact_relative_path"))
542 .and_then(serde_json::Value::as_str)
543 .map(PathBuf::from)
544 .unwrap_or_else(|| path.clone());
545 let content_for_preview = metadata
546 .and_then(|metadata| metadata.get("artifact_preview"))
547 .and_then(serde_json::Value::as_str)
548 .unwrap_or(&tool_result.content);
549 let byte_size = metadata
550 .and_then(|metadata| metadata.get("artifact_byte_size"))
551 .and_then(serde_json::Value::as_u64)
552 .unwrap_or_else(|| {
553 std::fs::metadata(&storage_path)
554 .map(|metadata| metadata.len())
555 .unwrap_or(tool_result.content.len() as u64)
556 });
557 if app
558 .session_artifacts
559 .iter()
560 .any(|artifact| artifact.tool_call_id == id && artifact.storage_path == storage_path)
561 {
562 return;
563 }
564 app.session_artifacts
565 .push(crate::artifacts::record_tool_output_artifact_with_size(
566 session_id,
567 id,
568 name,
569 storage_path,
570 byte_size,
571 content_for_preview,
572 ));
573 }
574
575 pub(super) fn evidence_completion_should_be_ignored(
576 app: &App,
577 id: &str,
578 result: &Result<ToolResult, ToolError>,
579 ) -> bool {
580 evidence_completion_identity_should_be_ignored(
581 app.current_session_id.as_deref(),
582 app.session_artifacts
583 .iter()
584 .map(|artifact| (artifact.id.as_str(), artifact.tool_call_id.as_str())),
585 id,
586 result,
587 )
588 }
589
590 fn evidence_completion_identity_should_be_ignored<'a>(
591 current_session: Option<&str>,
592 known_artifacts: impl IntoIterator<Item = (&'a str, &'a str)>,
593 id: &str,
594 result: &Result<ToolResult, ToolError>,
595 ) -> bool {
596 let Some(metadata) = result
597 .as_ref()
598 .ok()
599 .and_then(|result| result.metadata.as_ref())
600 else {
601 return false;
602 };
603 let origin = metadata
604 .get("artifact_session_id")
605 .and_then(serde_json::Value::as_str);
606 if let (Some(origin), Some(current)) = (origin, current_session)
607 && origin != current
608 {
609 return true;
610 }
611 metadata
612 .get("artifact_id")
613 .and_then(serde_json::Value::as_str)
614 .is_some_and(|artifact_id| {
615 known_artifacts
616 .into_iter()
617 .any(|(known_id, known_call)| known_id == artifact_id && known_call == id)
618 })
619 }
620
621 /// #3031: shell/tasks tools embed the literal `"(no output)"` into successful
622 /// `ToolResult` content (the model-facing transcript needs a non-empty tool
623 /// result). Treat it as no output on the TUI side so the compact-mode
624 /// suppression gate in `history.rs` actually fires; the raw content remains
625 /// available through the tool-detail store.
626 fn visible_tool_output(content: &str) -> Option<String> {
627 if content.trim() == "(no output)" {
628 None
629 } else {
630 Some(content.to_string())
631 }
632 }
633
634 /// Read the process exit code a tool reported, when it reported one.
635 ///
636 /// Only process-backed tools (`exec_shell`, task runners) carry one, and only
637 /// a real, integer-valued `exit_code` counts. Everything else stays `None` so
638 /// an `exit_code` condition never matches on a fabricated value.
639 /// Reported as `i64`, not `i32`: a Windows crash code such as `3221225477`
640 /// (`0xC0000005`) is a real value the shell tool records in its metadata, and
641 /// narrowing it dropped exactly those codes — the hook saw no exit code at all
642 /// for the crashes it most wanted to catch.
643 pub(crate) fn reported_tool_exit_code(result: &Result<ToolResult, ToolError>) -> Option<i64> {
644 let metadata = result.as_ref().ok()?.metadata.as_ref()?;
645 let code = metadata.get("exit_code")?;
646 if code.is_null() {
647 return None;
648 }
649 code.as_i64()
650 }
651
652 /// Fire `tool_call_after` for every settled tool call, plus `on_error` when
653 /// the call failed.
654 ///
655 /// `on_error` is documented as covering tool failures, not just transport and
656 /// auth failures, so the tool path has to raise it too — the engine-error path
657 /// in `apply_engine_error_to_app` never sees a tool that returned
658 /// `success: false`.
659 ///
660 /// Both are observer events: their stdout is ignored and neither can change
661 /// the result that goes back to the model. That is a statement about
662 /// Codewhale's control flow only — the commands themselves are arbitrary
663 /// shells and may have any external side effect.
664 fn fire_tool_completion_hooks(
665 app: &mut App,
666 id: &str,
667 name: &str,
668 result: &Result<ToolResult, ToolError>,
669 ) {
670 let wants_after = app.hooks.has_hooks_for_event(HookEvent::ToolCallAfter);
671 let wants_error = app
672 .hooks
673 .has_hooks_for_event(crate::hooks::HookEvent::OnError);
674 if !wants_after && !wants_error {
675 // Fast path: skip the result clone and HookContext allocation when
676 // the user has configured neither event.
677 return;
678 }
679
680 let (result_text, success): (String, bool) = match result.as_ref() {
681 Ok(tool_result) => (tool_result.content.clone(), tool_result.success),
682 Err(err) => (err.to_string(), false),
683 };
684 let exit_code = reported_tool_exit_code(result);
685
686 if wants_after {
687 let context = app
688 .base_hook_context()
689 .with_tool_name(name)
690 .with_tool_call_id(id)
691 .with_tool_result(&result_text, success, exit_code);
692 if let Err(error) = app.submit_hooks(HookEvent::ToolCallAfter, context) {
693 app.surface_observer_hook_submission_failure(error);
694 }
695 }
696
697 if wants_error && !success {
698 let context = app
699 .base_hook_context()
700 .with_tool_name(name)
701 .with_tool_call_id(id)
702 .with_tool_result(&result_text, success, exit_code)
703 .with_error(&format!("tool `{name}` failed: {result_text}"));
704 if let Err(error) = app.submit_hooks(crate::hooks::HookEvent::OnError, context) {
705 app.surface_observer_hook_submission_failure(error);
706 }
707 }
708 }
709
710 pub(super) fn handle_tool_call_complete(
711 app: &mut App,
712 id: &str,
713 name: &str,
714 result: &Result<ToolResult, ToolError>,
715 ) {
716 if app.ignored_tool_calls.remove(id) {
717 // "Ignored" is a *presentation* decision: these are real settled
718 // results — repeated `wait` polls, background-shell status reads —
719 // that the transcript deliberately does not redraw. Observers still
720 // have to see them, or `tool_call_after` silently skips a whole class
721 // of completions while claiming to fire after each tool call. Fired
722 // here and returned immediately, so each id emits exactly once.
723 fire_tool_completion_hooks(app, id, name, result);
724 return;
725 }
726 // Preserve the execution/audit name while recovering the action-qualified
727 // semantic name from the registered call input. Active entries and
728 // already-flushed history use separate detail stores.
729 let semantic_name = app
730 .active_tool_details
731 .get(id)
732 .or_else(|| {
733 app.tool_cells
734 .get(id)
735 .and_then(|cell_index| app.tool_details_by_cell.get(cell_index))
736 })
737 .map_or(name, |detail| canonical_action_alias(name, &detail.input))
738 .to_string();
739
740 // Roll any child-LLM token usage the tool reports into the
741 // session-cost counter. Runs unconditionally so future tools that
742 // spawn their own LLM calls (RLM, summarizers, retrieval helpers)
743 // get accrued without needing a per-tool hook (#524).
744 accrue_child_token_cost_if_any(app, result);
745 record_spillover_artifact_if_any(app, id, name, result);
746
747 // #455: fire `tool_call_after` (and `on_error` for failures) here, before
748 // any of the presentation early-returns below. Firing it further down meant
749 // exploring-tool completions and orphaned completions never emitted the
750 // event at all, so "fires after each tool call" was not true.
751 fire_tool_completion_hooks(app, id, name, result);
752
753 // Exploring entries land in the per-tool map regardless of whether they
754 // live in the active cell or in finalized history; the path is the same.
755 if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) {
756 app.tool_cells.remove(id);
757 store_tool_detail_output(app, id, cell_index, result);
758 if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) =
759 app.cell_at_virtual_index_mut(cell_index)
760 && let Some(entry) = cell.entries.get_mut(entry_index)
761 {
762 entry.status = tool_status_from_result(result);
763 app.mark_history_updated();
764 // Mutating the in-flight exploring cell needs an active-cell
765 // revision bump so the transcript cache invalidates the synthetic
766 // tail row.
767 if cell_index >= app.history.len() {
768 app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
769 if let Some(active) = app.active_cell.as_mut() {
770 active.bump_revision();
771 }
772 }
773 }
774 refresh_active_tool_completion_timestamp(app, cell_index);
775 return;
776 }
777
778 // Look up the cell by tool id. If the id isn't registered, that's an
779 // orphan completion (race condition where the started event was lost or
780 // a tool result arrived after the active cell was already flushed). Build
781 // a finalized standalone cell from the result so the user can still see
782 // the output, but DO NOT touch the active cell.
783 let Some(cell_index) = app.tool_cells.remove(id) else {
784 push_orphan_tool_completion(app, id, name, result);
785 return;
786 };
787
788 store_tool_detail_output(app, id, cell_index, result);
789 let in_active = cell_index >= app.history.len();
790
791 let status = tool_status_from_result(result);
792 let mutation_receipt = matches!(
793 semantic_name.as_str(),
794 "write_file" | "edit_file" | "apply_patch"
795 )
796 .then(|| {
797 result.as_ref().ok().and_then(|tool_result| {
798 crate::tui::history::FileMutationReceipt::from_success(&app.workspace, tool_result)
799 })
800 })
801 .flatten();
802 let mut workflow_panel_output: Option<String> = None;
803
804 if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) {
805 match cell {
806 HistoryCell::Tool(ToolCell::Exec(exec)) => {
807 exec.status = status;
808 if let Ok(tool_result) = result.as_ref() {
809 let shell_task_id = tool_result
810 .metadata
811 .as_ref()
812 .and_then(|m| m.get("task_id"))
813 .and_then(serde_json::Value::as_str)
814 .filter(|task_id| !task_id.trim().is_empty())
815 .map(str::to_string);
816 if shell_task_id.is_some() {
817 exec.shell_task_id = shell_task_id;
818 }
819 exec.owner_agent_id = tool_result
820 .metadata
821 .as_ref()
822 .and_then(|m| m.get("owner_agent_id"))
823 .and_then(serde_json::Value::as_str)
824 .filter(|agent_id| !agent_id.trim().is_empty())
825 .map(str::to_string);
826 exec.owner_agent_name = tool_result
827 .metadata
828 .as_ref()
829 .and_then(|m| m.get("owner_agent_name"))
830 .and_then(serde_json::Value::as_str)
831 .filter(|agent_name| !agent_name.trim().is_empty())
832 .map(str::to_string);
833 if let Some(meta_command) = tool_result
834 .metadata
835 .as_ref()
836 .and_then(|m| m.get("command"))
837 .and_then(serde_json::Value::as_str)
838 && !meta_command.trim().is_empty()
839 && (exec.command == "command" || exec.command.starts_with("command "))
840 {
841 exec.command = meta_command.to_string();
842 if exec.interaction.as_deref().is_some_and(|interaction| {
843 interaction.starts_with("Waiting for command")
844 }) {
845 let task_suffix = tool_result
846 .metadata
847 .as_ref()
848 .and_then(|m| m.get("task_id"))
849 .and_then(serde_json::Value::as_str)
850 .map(|task_id| format!(" ({task_id})"))
851 .unwrap_or_default();
852 exec.interaction =
853 Some(format!("Waiting for \"{meta_command}\"{task_suffix}"));
854 }
855 }
856 exec.duration_ms = tool_result
857 .metadata
858 .as_ref()
859 .and_then(|m| m.get("duration_ms"))
860 .and_then(serde_json::Value::as_u64);
861 if status != ToolStatus::Running && exec.interaction.is_none() {
862 exec.output = visible_tool_output(&tool_result.content);
863 exec.output_summary = exec
864 .output
865 .as_deref()
866 .map(super::history::summarize_tool_output);
867 exec.live_output = None;
868 } else if status == ToolStatus::Running
869 && exec.interaction.is_none()
870 && !tool_result.content.is_empty()
871 {
872 exec.live_output = Some(tool_result.content.clone());
873 }
874 } else if let Err(err) = result.as_ref()
875 && exec.interaction.is_none()
876 {
877 exec.output = Some(err.to_string());
878 exec.output_summary =
879 Some(super::history::summarize_tool_output(&err.to_string()));
880 }
881 app.mark_history_updated();
882 }
883 HistoryCell::Tool(ToolCell::PlanUpdate(plan)) => {
884 plan.status = status;
885 app.mark_history_updated();
886 }
887 HistoryCell::Tool(ToolCell::PatchSummary(patch)) => {
888 patch.status = status;
889 patch.receipt = mutation_receipt;
890 match result.as_ref() {
891 Ok(tool_result) if tool_result.success => {
892 if let Ok(json) =
893 serde_json::from_str::<serde_json::Value>(&tool_result.content)
894 && let Some(message) = json.get("message").and_then(|v| v.as_str())
895 {
896 patch.summary = message.to_string();
897 }
898 }
899 Ok(tool_result) => {
900 patch.error = Some(tool_result.content.clone());
901 }
902 Err(err) => {
903 patch.error = Some(err.to_string());
904 }
905 }
906 app.mark_history_updated();
907 }
908 HistoryCell::Tool(ToolCell::Review(review)) => {
909 review.status = status;
910 match result.as_ref() {
911 Ok(tool_result) => {
912 if tool_result.success {
913 review.output = Some(ReviewOutput::from_str(&tool_result.content));
914 } else {
915 review.error = Some(tool_result.content.clone());
916 }
917 }
918 Err(err) => {
919 review.error = Some(err.to_string());
920 }
921 }
922 app.mark_history_updated();
923 }
924 HistoryCell::Tool(ToolCell::Mcp(mcp)) => {
925 match result.as_ref() {
926 Ok(tool_result) => {
927 let summary = summarize_mcp_output(&tool_result.content);
928 if status == ToolStatus::Hydrated {
929 mcp.status = status;
930 } else if summary.is_error == Some(true) {
931 mcp.status = ToolStatus::Failed;
932 } else {
933 mcp.status = status;
934 }
935 mcp.is_image = summary.is_image;
936 mcp.content = summary.content;
937 }
938 Err(err) => {
939 mcp.status = status;
940 mcp.content = Some(err.to_string());
941 }
942 }
943 app.mark_history_updated();
944 }
945 HistoryCell::Tool(ToolCell::WebSearch(search)) => {
946 search.status = status;
947 match result.as_ref() {
948 Ok(tool_result) => {
949 search.summary = Some(summarize_tool_output(&tool_result.content));
950 let presentation = web_search_presentation(&tool_result.content);
951 search.source = presentation.source;
952 search.degraded = presentation.degraded;
953 search.ref_count = presentation.ref_count;
954 }
955 Err(err) => {
956 search.summary = Some(err.to_string());
957 }
958 }
959 app.mark_history_updated();
960 }
961 HistoryCell::Tool(ToolCell::Generic(generic)) => {
962 generic.status = status;
963 match result.as_ref() {
964 Ok(tool_result) => {
965 generic.output = visible_tool_output(&tool_result.content);
966 generic.output_summary =
967 generic.output.as_deref().map(summarize_tool_output);
968 generic.is_diff = output_looks_like_diff(&tool_result.content);
969 }
970 Err(err) => {
971 generic.output = Some(err.to_string());
972 generic.output_summary = Some(summarize_tool_output(&err.to_string()));
973 generic.is_diff = false;
974 }
975 }
976 // #4121: capture workflow JSON before releasing the cell borrow
977 // so we can hydrate the panel without overlapping borrows.
978 if generic.name == "workflow" {
979 workflow_panel_output = generic.output.clone();
980 }
981 app.mark_history_updated();
982 }
983 _ => {}
984 }
985 }
986
987 // #4121 / #4122: feed typed workflow events into the panel *and* keep the
988 // history card snapshot in sync. Live streaming also arrives via
989 // `Event::WorkflowUi`; this path covers tool-complete hydration.
990 if let Some(output) = workflow_panel_output.as_deref() {
991 apply_workflow_output_to_panel(app, output);
992 }
993
994 // If the mutated cell lived inside the active group, bump the active-cell
995 // revision so the transcript cache re-renders the synthetic tail row.
996 if in_active {
997 app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
998 if let Some(active) = app.active_cell.as_mut() {
999 active.bump_revision();
1000 }
1001 refresh_active_tool_completion_timestamp(app, cell_index);
1002 }
1003
1004 if refreshes_workspace_context_on_completion(&semantic_name) && status != ToolStatus::Running {
1005 workspace_context::refresh_now(app, Instant::now());
1006 }
1007
1008 // Collect evidence for the post-turn receipt.
1009 let evidence_summary = match result.as_ref() {
1010 Ok(tool_result) => {
1011 if tool_result.success {
1012 summarize_tool_output(&tool_result.content)
1013 } else {
1014 format!("failed: {}", summarize_tool_output(&tool_result.content))
1015 }
1016 }
1017 Err(err) => format!("error: {err}"),
1018 };
1019 app.tool_evidence.push(ToolEvidence {
1020 tool_name: name.to_string(),
1021 summary: evidence_summary,
1022 });
1023 }
1024
1025 #[derive(Debug, Default, PartialEq, Eq)]
1026 struct WebSearchPresentation {
1027 source: Option<String>,
1028 degraded: Option<String>,
1029 ref_count: usize,
1030 }
1031
1032 fn web_search_presentation(content: &str) -> WebSearchPresentation {
1033 let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
1034 return WebSearchPresentation::default();
1035 };
1036 let surfaces = if value.get("receipt").is_some() {
1037 vec![&value]
1038 } else {
1039 value
1040 .get("search_query")
1041 .and_then(serde_json::Value::as_array)
1042 .map(|items| items.iter().collect())
1043 .unwrap_or_default()
1044 };
1045 let source = surfaces
1046 .iter()
1047 .filter_map(|surface| surface.get("source").and_then(serde_json::Value::as_str))
1048 .map(str::to_string)
1049 .next();
1050 let mut degraded = Vec::new();
1051 let mut ref_count = 0usize;
1052 for surface in surfaces {
1053 if let Some(results) = surface.get("results").and_then(serde_json::Value::as_array) {
1054 ref_count = ref_count.saturating_add(
1055 results
1056 .iter()
1057 .filter(|result| {
1058 result
1059 .get("ref_id")
1060 .and_then(serde_json::Value::as_str)
1061 .is_some_and(|ref_id| !ref_id.is_empty())
1062 })
1063 .count(),
1064 );
1065 }
1066 if let Some(reasons) = surface
1067 .pointer("/receipt/degraded")
1068 .and_then(serde_json::Value::as_array)
1069 {
1070 for reason in reasons {
1071 if let Some(label) = degraded_reason_label(reason)
1072 && !degraded.contains(&label)
1073 {
1074 degraded.push(label);
1075 }
1076 }
1077 }
1078 }
1079 WebSearchPresentation {
1080 source,
1081 degraded: (!degraded.is_empty()).then(|| degraded.join("; ")),
1082 ref_count,
1083 }
1084 }
1085
1086 fn degraded_reason_label(reason: &serde_json::Value) -> Option<String> {
1087 let kind = reason.get("kind")?.as_str()?;
1088 let backend = |field: &str| {
1089 reason
1090 .get(field)
1091 .and_then(serde_json::Value::as_str)
1092 .unwrap_or("unknown")
1093 };
1094 Some(match kind {
1095 "backend_unavailable" => format!("{} unavailable", backend("backend")),
1096 "no_usable_results" => format!("{} returned no usable results", backend("backend")),
1097 "backend_fallback" => format!("{} -> {}", backend("from"), backend("to")),
1098 "challenge_detected" => format!("{} challenge", backend("backend")),
1099 "scrape_fallback" => format!("{} -> {} scrape", backend("from"), backend("to")),
1100 "knob_ignored" => format!(
1101 "{} ignored",
1102 reason
1103 .get("knob")
1104 .and_then(serde_json::Value::as_str)
1105 .unwrap_or("filter")
1106 ),
1107 "post_filtered" => format!(
1108 "{} post-filtered",
1109 reason
1110 .get("knob")
1111 .and_then(serde_json::Value::as_str)
1112 .unwrap_or("results")
1113 ),
1114 "synthesized_results" => "synthesized results".to_string(),
1115 other => other.replace('_', " "),
1116 })
1117 }
1118
1119 /// Hydrate or advance the WorkflowPanel from a workflow tool JSON payload.
1120 /// Accepts a single run record (with optional `events` array) or a status
1121 /// list. Log-only events are filtered by the panel itself so the transcript
1122 /// stays free of progress spam (#4121). Also keeps the matching history card
1123 /// snapshot aligned (#4122).
1124 fn apply_workflow_output_to_panel(app: &mut App, output: &str) {
1125 let Ok(value) = serde_json::from_str::<serde_json::Value>(output) else {
1126 return;
1127 };
1128
1129 // A status response is an envelope rather than a run. Route only its
1130 // selected record through the same identity checks as direct results.
1131 if value.get("action").and_then(|v| v.as_str()) == Some("status") {
1132 if let Some(runs) = value.get("runs").and_then(|r| r.as_array())
1133 && let Some(run) = runs.last()
1134 {
1135 apply_workflow_output_to_panel(app, &run.to_string());
1136 }
1137 return;
1138 }
1139
1140 // Tool completions can arrive after a newer run has already selected the
1141 // shared panel. Bind the entire payload to one run before replaying any of
1142 // its retained events. A different run may replace a settled panel only
1143 // when its recorded start is strictly newer; missing/older provenance
1144 // fails closed instead of contaminating the displayed run.
1145 let Some(run_id) = value
1146 .get("run_id")
1147 .and_then(|v| v.as_str())
1148 .filter(|run_id| !run_id.trim().is_empty())
1149 .map(str::to_string)
1150 .or_else(|| {
1151 value
1152 .get("events")
1153 .and_then(|events| events.as_array())
1154 .and_then(|events| {
1155 events.iter().find_map(|event| {
1156 event
1157 .get("run_id")
1158 .and_then(|v| v.as_str())
1159 .filter(|run_id| !run_id.trim().is_empty())
1160 .map(str::to_string)
1161 })
1162 })
1163 })
1164 else {
1165 return;
1166 };
1167 if let Some(panel) = app.workflow_panel.as_ref()
1168 && panel.run_id != run_id
1169 {
1170 let incoming_started_at = value.get("started_at_ms").and_then(|v| v.as_u64());
1171 if panel.lifecycle.is_running()
1172 || incoming_started_at.is_none_or(|at_ms| at_ms <= panel.started_at_ms)
1173 {
1174 return;
1175 }
1176 }
1177
1178 // Prefer the typed event stream when present.
1179 if let Some(events) = value.get("events").and_then(|e| e.as_array()) {
1180 // Ensure the selected panel belongs to this payload before applying.
1181 // A newer settled run can reach this branch without a retained
1182 // run_started event, so replace it with a correctly identified shell.
1183 if app
1184 .workflow_panel
1185 .as_ref()
1186 .is_none_or(|panel| panel.run_id != run_id)
1187 {
1188 let label = value
1189 .get("workflow_goal")
1190 .and_then(|v| v.as_str())
1191 .or_else(|| value.get("workflow_id").and_then(|v| v.as_str()))
1192 .unwrap_or(&run_id)
1193 .to_string();
1194 let at_ms = value
1195 .get("started_at_ms")
1196 .and_then(|v| v.as_u64())
1197 .unwrap_or(0);
1198 let mut panel = crate::tui::widgets::workflow_panel::WorkflowPanel::new(
1199 run_id.clone(),
1200 label,
1201 at_ms,
1202 );
1203 panel.locale = app.ui_locale;
1204 app.workflow_panel = Some(panel);
1205 }
1206 if let Some(panel) = app.workflow_panel.as_mut() {
1207 let mut injected = Vec::with_capacity(events.len());
1208 for event in events {
1209 let mut event = event.clone();
1210 if let Some(obj) = event.as_object_mut() {
1211 // The top-level run record is authoritative. Do not let a
1212 // stale/malformed embedded id retarget one replay event.
1213 obj.insert(
1214 "run_id".to_string(),
1215 serde_json::Value::String(run_id.clone()),
1216 );
1217 }
1218 injected.push(event);
1219 }
1220 panel.apply_json_events(&injected);
1221 // Completion/status payloads replay a retained event tail. Merge
1222 // the authoritative exact count + bounded structured ledger after
1223 // replay so live dispatch failures are neither duplicated nor
1224 // lost when older events have fallen out of the tail (#5528).
1225 panel.merge_dispatch_failures_from_run_json(&value);
1226 // Carry final result / source into panel for expanded history card.
1227 if let Some(summary) = value
1228 .get("result")
1229 .map(|v| v.to_string())
1230 .filter(|s| s != "null")
1231 {
1232 panel.result_summary = Some(summary);
1233 }
1234 if let Some(path) = value.get("source_path").and_then(|v| v.as_str()) {
1235 panel.source_path = Some(PathBuf::from(path));
1236 }
1237 app.needs_redraw = true;
1238 }
1239 sync_workflow_history_card_from_panel(app);
1240 return;
1241 }
1242
1243 // Prefer full panel hydration from summary/phases snapshot when present.
1244 if let Some(mut panel) =
1245 crate::tui::widgets::workflow_panel::WorkflowPanel::from_run_json(&value)
1246 {
1247 panel.locale = app.ui_locale;
1248 app.workflow_panel = Some(panel);
1249 app.needs_redraw = true;
1250 sync_workflow_history_card_from_panel(app);
1251 return;
1252 }
1253
1254 // Fallback: bare run record without events — at least surface header state.
1255 if value.get("run_id").and_then(|v| v.as_str()).is_some() {
1256 use crate::tui::widgets::workflow_panel::{WorkflowPanelEvent, WorkflowPanelLifecycle};
1257 let label = value
1258 .get("workflow_goal")
1259 .and_then(|v| v.as_str())
1260 .or_else(|| value.get("workflow_id").and_then(|v| v.as_str()))
1261 .unwrap_or(&run_id)
1262 .to_string();
1263 let at_ms = value
1264 .get("started_at_ms")
1265 .and_then(|v| v.as_u64())
1266 .unwrap_or(0);
1267 let status = value
1268 .get("status")
1269 .and_then(|v| v.as_str())
1270 .unwrap_or("running");
1271 let started_applied = app.apply_workflow_panel_event(
1272 &run_id,
1273 WorkflowPanelEvent::RunStarted {
1274 run_id: run_id.clone(),
1275 workflow_id: value
1276 .get("workflow_id")
1277 .and_then(|v| v.as_str())
1278 .map(str::to_string),
1279 workflow_goal: Some(label),
1280 source_path: value
1281 .get("source_path")
1282 .and_then(|v| v.as_str())
1283 .map(PathBuf::from),
1284 token_budget: value.get("token_budget").and_then(|v| v.as_u64()),
1285 at_ms,
1286 },
1287 );
1288 if !started_applied {
1289 return;
1290 }
1291 if status != "running" {
1292 let life = match status {
1293 "completed" | "succeeded" => WorkflowPanelLifecycle::Succeeded,
1294 "degraded" => WorkflowPanelLifecycle::Degraded,
1295 "failed" => WorkflowPanelLifecycle::Failed,
1296 "cancelled" | "canceled" => WorkflowPanelLifecycle::Cancelled,
1297 _ => WorkflowPanelLifecycle::Running,
1298 };
1299 if life != WorkflowPanelLifecycle::Running {
1300 app.apply_workflow_panel_event(
1301 &run_id,
1302 WorkflowPanelEvent::RunCompleted {
1303 status: life,
1304 error: value
1305 .get("error")
1306 .and_then(|v| v.as_str())
1307 .map(str::to_string),
1308 at_ms: value
1309 .get("completed_at_ms")
1310 .and_then(|v| v.as_u64())
1311 .unwrap_or(at_ms),
1312 },
1313 );
1314 }
1315 }
1316 sync_workflow_history_card_from_panel(app);
1317 }
1318 }
1319
1320 /// Apply one live `WorkflowUi` engine event to the panel and history card.
1321 pub(super) fn apply_workflow_ui_event(app: &mut App, run_id: &str, event: &serde_json::Value) {
1322 use crate::tui::widgets::workflow_panel::WorkflowPanelEvent;
1323
1324 let mut event = event.clone();
1325 if let Some(obj) = event.as_object_mut() {
1326 // The engine envelope owns route identity. An embedded stale id must
1327 // not move this event onto another run's panel.
1328 obj.insert(
1329 "run_id".to_string(),
1330 serde_json::Value::String(run_id.to_string()),
1331 );
1332 }
1333 if let Some(panel_event) = WorkflowPanelEvent::from_json_value(&event)
1334 && !app.apply_workflow_panel_event(run_id, panel_event)
1335 {
1336 return;
1337 }
1338 sync_workflow_history_card_from_panel(app);
1339 }
1340
1341 /// Apply a live workflow event only when its immutable owner is the active
1342 /// conversation. This check deliberately sits in the mutation helper so every
1343 /// caller fails closed before touching the panel or transcript history.
1344 pub(super) fn apply_owned_workflow_ui_event(
1345 app: &mut App,
1346 owner_session_id: &str,
1347 run_id: &str,
1348 event: &serde_json::Value,
1349 ) -> bool {
1350 if app.current_session_id.as_deref() != Some(owner_session_id) {
1351 return false;
1352 }
1353 apply_workflow_ui_event(app, run_id, event);
1354 true
1355 }
1356
1357 /// Mirror the live WorkflowPanel snapshot into the in-flight (or most recent)
1358 /// workflow history tool cell so compact/expanded cards stay current.
1359 fn sync_workflow_history_card_from_panel(app: &mut App) {
1360 let Some(panel) = app.workflow_panel.as_ref() else {
1361 return;
1362 };
1363 let run_id = panel.run_id.clone();
1364 let snapshot = panel.to_run_json().to_string();
1365 let degraded = matches!(
1366 panel.lifecycle,
1367 crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Degraded
1368 );
1369
1370 // Prefer an in-flight Generic(workflow) cell whose output already carries
1371 // this run_id, else the newest running workflow cell, else any workflow
1372 // cell (tool-complete path already wrote the final output).
1373 let mut target: Option<usize> = None;
1374 let history_len = app.history.len();
1375 let total = history_len
1376 + app
1377 .active_cell
1378 .as_ref()
1379 .map(|a| a.entries().len())
1380 .unwrap_or(0);
1381
1382 for idx in (0..total).rev() {
1383 let Some(cell) = app.cell_at_virtual_index(idx) else {
1384 continue;
1385 };
1386 let HistoryCell::Tool(ToolCell::Generic(generic)) = cell else {
1387 continue;
1388 };
1389 if generic.name != "workflow" {
1390 continue;
1391 }
1392 let matches_run = generic
1393 .output
1394 .as_deref()
1395 .and_then(|out| serde_json::from_str::<serde_json::Value>(out).ok())
1396 .and_then(|v| {
1397 v.get("run_id")
1398 .and_then(|id| id.as_str())
1399 .map(|id| id == run_id)
1400 })
1401 .unwrap_or(false);
1402 let is_running = generic.status == ToolStatus::Running;
1403 if matches_run || (is_running && target.is_none()) {
1404 target = Some(idx);
1405 if matches_run {
1406 break;
1407 }
1408 }
1409 }
1410
1411 let Some(idx) = target else {
1412 return;
1413 };
1414 if let Some(HistoryCell::Tool(ToolCell::Generic(generic))) = app.cell_at_virtual_index_mut(idx)
1415 {
1416 // Preserve a richer final output if the tool completion already wrote
1417 // a full run record with an events array longer than the snapshot.
1418 let replace = match generic.output.as_deref() {
1419 None => true,
1420 Some(existing) => {
1421 let Ok(value) = serde_json::from_str::<serde_json::Value>(existing) else {
1422 return;
1423 };
1424 let existing_run = value.get("run_id").and_then(|v| v.as_str()).unwrap_or("");
1425 if !existing_run.is_empty() && existing_run != run_id {
1426 return;
1427 }
1428 // Prefer full event-bearing records when the tool has completed.
1429 if generic.status == ToolStatus::Running {
1430 true
1431 } else {
1432 value
1433 .get("events")
1434 .and_then(|e| e.as_array())
1435 .is_none_or(|e| e.is_empty())
1436 }
1437 }
1438 };
1439 let status_changed = degraded && generic.status != ToolStatus::Warning;
1440 if status_changed {
1441 generic.status = ToolStatus::Warning;
1442 }
1443 if replace {
1444 generic.output = Some(snapshot);
1445 generic.output_summary = Some(format!("workflow {}", run_id));
1446 }
1447 if replace || status_changed {
1448 app.mark_history_updated();
1449 }
1450 }
1451 }
1452
1453 fn refresh_active_tool_completion_timestamp(app: &mut App, cell_index: usize) {
1454 if cell_index < app.history.len() {
1455 return;
1456 }
1457 let entry_idx = cell_index - app.history.len();
1458 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
1459 app.active_tool_entry_completed_at.remove(&entry_idx);
1460 return;
1461 };
1462
1463 if history_cell_has_running_tool(cell) {
1464 app.active_tool_entry_completed_at.remove(&entry_idx);
1465 } else {
1466 app.active_tool_entry_completed_at
1467 .entry(entry_idx)
1468 .or_insert_with(Instant::now);
1469 }
1470 }
1471
1472 fn history_cell_has_running_tool(cell: &HistoryCell) -> bool {
1473 let HistoryCell::Tool(tool) = cell else {
1474 return false;
1475 };
1476 match tool {
1477 ToolCell::Exec(exec) => exec.status == ToolStatus::Running,
1478 ToolCell::Exploring(explore) => explore
1479 .entries
1480 .iter()
1481 .any(|entry| entry.status == ToolStatus::Running),
1482 ToolCell::PlanUpdate(plan) => plan.status == ToolStatus::Running,
1483 ToolCell::PatchSummary(patch) => patch.status == ToolStatus::Running,
1484 ToolCell::Review(review) => review.status == ToolStatus::Running,
1485 ToolCell::Mcp(mcp) => mcp.status == ToolStatus::Running,
1486 ToolCell::ViewImage(_) => false,
1487 ToolCell::WebSearch(search) => search.status == ToolStatus::Running,
1488 ToolCell::Generic(generic) => generic.status == ToolStatus::Running,
1489 }
1490 }
1491
1492 /// Build a finalized standalone history cell for a tool completion whose
1493 /// start was never registered (orphan). This preserves the contract that
1494 /// every tool result is visible somewhere; the alternative (silently
1495 /// dropping it) hides errors and breaks debuggability.
1496 ///
1497 /// Choice of cell type: success-only mutation metadata is sufficient to
1498 /// reconstruct a structured File receipt; other orphans stay generic because
1499 /// no input payload remains. The pager remains usable in both cases because
1500 /// `tool_details_by_cell` is populated with the result text.
1501 ///
1502 /// ## Index drift
1503 ///
1504 /// If an active cell is in flight when the orphan arrives, pushing the
1505 /// orphan into `app.history` shifts every active-cell virtual index forward
1506 /// by 1. We must rewrite `tool_cells` / `exploring_entries` accordingly so
1507 /// later completion lookups still find the right entries.
1508 fn push_orphan_tool_completion(
1509 app: &mut App,
1510 tool_id: &str,
1511 name: &str,
1512 result: &Result<ToolResult, ToolError>,
1513 ) {
1514 let status = tool_status_from_result(result);
1515 let output = match result.as_ref() {
1516 Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)),
1517 Err(err) => Some(err.to_string()),
1518 };
1519 let spillover_path = result
1520 .as_ref()
1521 .ok()
1522 .and_then(|r| r.metadata.as_ref())
1523 .and_then(|m| m.get("spillover_path"))
1524 .and_then(serde_json::Value::as_str)
1525 .map(std::path::PathBuf::from);
1526 let output_summary = output.as_deref().map(summarize_tool_output);
1527 let is_diff = output.as_deref().is_some_and(output_looks_like_diff);
1528 let mutation_receipt = result.as_ref().ok().and_then(|tool_result| {
1529 crate::tui::history::FileMutationReceipt::from_success(&app.workspace, tool_result)
1530 });
1531 let cell = if let Some(receipt) = mutation_receipt {
1532 let path = receipt
1533 .files
1534 .first()
1535 .map_or_else(|| "<file>".to_string(), |file| file.path.clone());
1536 let summary = receipt.semantic_summary();
1537 HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell {
1538 path,
1539 summary,
1540 status,
1541 error: None,
1542 receipt: Some(receipt),
1543 }))
1544 } else {
1545 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1546 name: name.to_string(),
1547 status,
1548 input_summary: None,
1549 output,
1550 prompts: None,
1551 spillover_path,
1552 output_summary,
1553 is_diff,
1554 }))
1555 };
1556 app.add_message(cell);
1557 let cell_index = app.history.len().saturating_sub(1);
1558 app.tool_details_by_cell.insert(
1559 cell_index,
1560 ToolDetailRecord {
1561 tool_id: tool_id.to_string(),
1562 tool_name: name.to_string(),
1563 input: serde_json::Value::Null,
1564 output: match result.as_ref() {
1565 Ok(tool_result) => Some(tool_result.content.clone()),
1566 Err(err) => Some(err.to_string()),
1567 },
1568 },
1569 );
1570
1571 // The virtual-index rebase this path used to do inline now lives in
1572 // `App::add_message`, so every mid-turn history insert gets it — not just
1573 // orphan completions. That gap was #5478: `/rename`'s note shifted the
1574 // indices with nothing to re-base them.
1575 }
1576
1577 fn tool_status_from_result(result: &Result<ToolResult, ToolError>) -> ToolStatus {
1578 match result.as_ref() {
1579 Ok(tool_result) if is_deferred_schema_hydration(tool_result) => ToolStatus::Hydrated,
1580 Ok(tool_result) => match tool_result.metadata.as_ref() {
1581 Some(meta)
1582 if meta
1583 .get("status")
1584 .and_then(|v| v.as_str())
1585 .is_some_and(|s| s == "Running") =>
1586 {
1587 ToolStatus::Running
1588 }
1589 _ => {
1590 if tool_result.success {
1591 ToolStatus::Success
1592 } else {
1593 ToolStatus::Failed
1594 }
1595 }
1596 },
1597 Err(_) => ToolStatus::Failed,
1598 }
1599 }
1600
1601 fn is_deferred_schema_hydration(tool_result: &ToolResult) -> bool {
1602 if !tool_result.success {
1603 return false;
1604 }
1605 let Some(metadata) = tool_result.metadata.as_ref() else {
1606 return false;
1607 };
1608 metadata
1609 .get("event")
1610 .and_then(serde_json::Value::as_str)
1611 .is_some_and(|event| event == "tool.schema_hydrated")
1612 && metadata
1613 .get("executed")
1614 .and_then(serde_json::Value::as_bool)
1615 .is_some_and(|executed| !executed)
1616 }
1617
1618 fn is_exploring_tool(name: &str) -> bool {
1619 matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files")
1620 }
1621
1622 fn is_exec_tool(name: &str) -> bool {
1623 matches!(
1624 name,
1625 "exec_shell"
1626 | "exec_shell_wait"
1627 | "exec_shell_interact"
1628 | "exec_shell_cancel"
1629 | "exec_wait"
1630 | "exec_interact"
1631 )
1632 }
1633
1634 pub(super) fn refreshes_workspace_context_on_completion(name: &str) -> bool {
1635 matches!(
1636 name,
1637 "exec_shell"
1638 | "exec_shell_wait"
1639 | "exec_shell_interact"
1640 | "exec_shell_cancel"
1641 | "exec_wait"
1642 | "exec_interact"
1643 | "task_shell_start"
1644 | "task_shell_wait"
1645 | "write_file"
1646 | "edit_file"
1647 | "apply_patch"
1648 )
1649 }
1650
1651 pub(super) fn exploring_label(name: &str, input: &serde_json::Value) -> String {
1652 let fallback = format!("{name} tool");
1653 let obj = input.as_object();
1654 match name {
1655 "read_file" => obj
1656 .and_then(|o| o.get("path"))
1657 .and_then(|v| v.as_str())
1658 .map_or(fallback, |path| format!("Reading {path}")),
1659 "list_dir" => obj
1660 .and_then(|o| o.get("path"))
1661 .and_then(|v| v.as_str())
1662 .map_or("Listing directory".to_string(), |path| {
1663 format!("Listing {path}")
1664 }),
1665 "grep_files" => {
1666 let pattern = obj
1667 .and_then(|o| o.get("pattern"))
1668 .and_then(|v| v.as_str())
1669 .unwrap_or("pattern");
1670 format!("Searching for `{pattern}`")
1671 }
1672 "list_files" => "Listing files".to_string(),
1673 _ => fallback,
1674 }
1675 }
1676
1677 fn is_mcp_tool(name: &str) -> bool {
1678 name.starts_with("mcp_")
1679 }
1680
1681 fn is_view_image_tool(name: &str) -> bool {
1682 matches!(name, "view_image" | "view_image_file" | "view_image_tool")
1683 }
1684
1685 fn is_web_search_tool(name: &str) -> bool {
1686 matches!(name, "web_search" | "search_web" | "search" | "web.run")
1687 || name.ends_with("_web_search")
1688 }
1689
1690 fn web_search_query(input: &serde_json::Value) -> String {
1691 if let Some(searches) = input.get("search_query").and_then(|v| v.as_array())
1692 && let Some(first) = searches.first()
1693 && let Some(q) = first.get("q").and_then(|v| v.as_str())
1694 {
1695 return q.to_string();
1696 }
1697
1698 input
1699 .get("query")
1700 .or_else(|| input.get("q"))
1701 .or_else(|| input.get("search"))
1702 .and_then(|v| v.as_str())
1703 .unwrap_or("Web search")
1704 .to_string()
1705 }
1706
1707 fn review_target_label(input: &serde_json::Value) -> String {
1708 let target = input
1709 .get("target")
1710 .and_then(|v| v.as_str())
1711 .unwrap_or("review")
1712 .trim();
1713 let kind = input
1714 .get("kind")
1715 .and_then(|v| v.as_str())
1716 .unwrap_or("")
1717 .trim()
1718 .to_ascii_lowercase();
1719 let staged = input
1720 .get("staged")
1721 .and_then(|v| v.as_bool())
1722 .unwrap_or(false);
1723 let target_lower = target.to_ascii_lowercase();
1724
1725 if kind == "diff"
1726 || target_lower == "diff"
1727 || target_lower == "git diff"
1728 || target_lower == "staged"
1729 || target_lower == "cached"
1730 {
1731 if staged || target_lower == "staged" || target_lower == "cached" {
1732 return "git diff --cached".to_string();
1733 }
1734 return "git diff".to_string();
1735 }
1736
1737 target.to_string()
1738 }
1739
1740 fn parse_plan_input(input: &serde_json::Value) -> PlanSnapshot {
1741 PlanSnapshot::from_tool_input(input)
1742 }
1743
1744 fn parse_file_mutation_summary(semantic_name: &str, input: &serde_json::Value) -> (String, String) {
1745 if semantic_name != "apply_patch" {
1746 let path = input
1747 .get("path")
1748 .and_then(serde_json::Value::as_str)
1749 .filter(|path| !path.trim().is_empty())
1750 .unwrap_or("<file>")
1751 .to_string();
1752 let summary = match semantic_name {
1753 "write_file" => "Writing file",
1754 "edit_file" => "Editing file",
1755 _ => "Changing file",
1756 }
1757 .to_string();
1758 return (path, summary);
1759 }
1760 let patch_text = match normalize_apply_patch_input(input) {
1761 Ok(NormalizedApplyPatchInput::Replacement {
1762 entries: changes, ..
1763 }) => {
1764 let count = changes.len();
1765 let path = changes
1766 .first()
1767 .and_then(|c| c.get("path"))
1768 .and_then(|v| v.as_str())
1769 .map(str::to_string)
1770 .unwrap_or_else(|| "<file>".to_string());
1771 let label = if count <= 1 {
1772 path
1773 } else {
1774 format!("{count} files")
1775 };
1776 let summary = format!("Changes: {count} file(s)");
1777 return (label, summary);
1778 }
1779 Ok(NormalizedApplyPatchInput::Patch(patch)) => patch,
1780 Err(_) => "",
1781 };
1782 let paths = extract_patch_paths(patch_text);
1783 let path = input
1784 .get("path")
1785 .and_then(|v| v.as_str())
1786 .map(str::to_string)
1787 .or_else(|| {
1788 if paths.len() == 1 {
1789 paths.first().cloned()
1790 } else if paths.is_empty() {
1791 None
1792 } else {
1793 Some(format!("{} files", paths.len()))
1794 }
1795 })
1796 .unwrap_or_else(|| "<file>".to_string());
1797
1798 let (adds, removes) = count_patch_changes(patch_text);
1799 let summary = if adds == 0 && removes == 0 {
1800 "Patch applied".to_string()
1801 } else {
1802 format!("Changes: +{adds} / -{removes}")
1803 };
1804 (path, summary)
1805 }
1806
1807 fn extract_patch_paths(patch: &str) -> Vec<String> {
1808 let mut paths = Vec::new();
1809 for line in patch.lines() {
1810 if let Some(rest) = line.strip_prefix("+++ ") {
1811 let raw = rest.trim();
1812 if raw == "/dev/null" || raw == "dev/null" {
1813 continue;
1814 }
1815 let raw = raw.strip_prefix("b/").unwrap_or(raw);
1816 if !paths.contains(&raw.to_string()) {
1817 paths.push(raw.to_string());
1818 }
1819 } else if let Some(rest) = line.strip_prefix("diff --git ") {
1820 let parts: Vec<&str> = rest.split_whitespace().collect();
1821 if let Some(path) = parts.get(1).or_else(|| parts.first()) {
1822 let raw = path.trim();
1823 let raw = raw
1824 .strip_prefix("b/")
1825 .or_else(|| raw.strip_prefix("a/"))
1826 .unwrap_or(raw);
1827 if !paths.contains(&raw.to_string()) {
1828 paths.push(raw.to_string());
1829 }
1830 }
1831 }
1832 }
1833 paths
1834 }
1835
1836 fn count_patch_changes(patch: &str) -> (usize, usize) {
1837 let mut adds = 0;
1838 let mut removes = 0;
1839 for line in patch.lines() {
1840 if line.starts_with("+++") || line.starts_with("---") {
1841 continue;
1842 }
1843 if line.starts_with('+') {
1844 adds += 1;
1845 } else if line.starts_with('-') {
1846 removes += 1;
1847 }
1848 }
1849 (adds, removes)
1850 }
1851
1852 fn exec_command_from_input(input: &serde_json::Value) -> Option<String> {
1853 input
1854 .get("command")
1855 .and_then(|v| v.as_str())
1856 .map(std::string::ToString::to_string)
1857 }
1858
1859 fn exec_target_from_input(input: &serde_json::Value) -> String {
1860 exec_command_from_input(input).unwrap_or_else(|| {
1861 input
1862 .get("task_id")
1863 .or_else(|| input.get("id"))
1864 .and_then(|v| v.as_str())
1865 .map(|task_id| format!("command {task_id}"))
1866 .unwrap_or_else(|| "command".to_string())
1867 })
1868 }
1869
1870 fn exec_source_from_input(input: &serde_json::Value) -> ExecSource {
1871 match input.get("source").and_then(|v| v.as_str()) {
1872 Some(source) if source.eq_ignore_ascii_case("user") => ExecSource::User,
1873 _ => ExecSource::Assistant,
1874 }
1875 }
1876
1877 fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(String, bool)> {
1878 let command = exec_target_from_input(input);
1879 let command_display = format!("\"{command}\"");
1880 let interaction_input = input
1881 .get("input")
1882 .or_else(|| input.get("stdin"))
1883 .or_else(|| input.get("data"))
1884 .and_then(|v| v.as_str());
1885
1886 let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait");
1887 let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact");
1888 let is_cancel_tool = name == "exec_shell_cancel";
1889
1890 if is_cancel_tool {
1891 let summary = if input.get("all").and_then(serde_json::Value::as_bool) == Some(true) {
1892 "Cancelled all background commands".to_string()
1893 } else if let Some(task_id) = input
1894 .get("task_id")
1895 .or_else(|| input.get("id"))
1896 .and_then(serde_json::Value::as_str)
1897 {
1898 format!("Cancelled command {task_id}")
1899 } else {
1900 "Cancelled background command".to_string()
1901 };
1902 return Some((summary, false));
1903 }
1904
1905 if is_interact_tool || interaction_input.is_some() {
1906 let preview = interaction_input.map(summarize_interaction_input);
1907 let summary = if let Some(preview) = preview {
1908 format!("Interacted with {command_display}, sent {preview}")
1909 } else {
1910 format!("Interacted with {command_display}")
1911 };
1912 return Some((summary, false));
1913 }
1914
1915 if is_wait_tool || input.get("wait").and_then(serde_json::Value::as_bool) == Some(true) {
1916 if exec_command_from_input(input).is_none()
1917 && let Some(task_id) = input
1918 .get("task_id")
1919 .or_else(|| input.get("id"))
1920 .and_then(|v| v.as_str())
1921 {
1922 return Some((format!("Waiting for command {task_id}"), true));
1923 }
1924 return Some((format!("Waited for {command_display}"), true));
1925 }
1926
1927 None
1928 }
1929
1930 fn summarize_interaction_input(input: &str) -> String {
1931 let mut single_line = input.replace('\r', "");
1932 single_line = single_line.replace('\n', "\\n");
1933 single_line = single_line.replace('\"', "'");
1934 let max_len = 80;
1935 if single_line.chars().count() <= max_len {
1936 return format!("\"{single_line}\"");
1937 }
1938 let mut out = String::new();
1939 for ch in single_line.chars().take(max_len.saturating_sub(3)) {
1940 out.push(ch);
1941 }
1942 out.push_str("...");
1943 format!("\"{out}\"")
1944 }
1945
1946 fn exec_is_background(input: &serde_json::Value) -> bool {
1947 input
1948 .get("background")
1949 .and_then(serde_json::Value::as_bool)
1950 .unwrap_or(false)
1951 }
1952
1953 #[cfg(test)]
1954 mod tests {
1955 use super::*;
1956 use crate::tools::plan::StepStatus;
1957 use serde_json::json;
1958
1959 #[test]
1960 fn late_live_event_from_prior_run_does_not_mutate_active_run() {
1961 let mut app = crate::test_support::test_app_with_options(
1962 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
1963 );
1964 apply_workflow_ui_event(
1965 &mut app,
1966 "run-a",
1967 &json!({
1968 "type": "run_started",
1969 "workflow_goal": "first run",
1970 "at_ms": 1_000,
1971 }),
1972 );
1973 apply_workflow_ui_event(
1974 &mut app,
1975 "run-b",
1976 &json!({
1977 "type": "run_started",
1978 "workflow_goal": "second run",
1979 "at_ms": 2_000,
1980 }),
1981 );
1982 apply_workflow_ui_event(
1983 &mut app,
1984 "run-b",
1985 &json!({"type": "phase_started", "title": "Build", "at_ms": 2_100}),
1986 );
1987 let before = app
1988 .workflow_panel
1989 .as_ref()
1990 .expect("run B panel")
1991 .to_run_json();
1992
1993 // Even a delayed start cannot rewind the selected panel to an older
1994 // run. A genuinely newer run B was already accepted above.
1995 apply_workflow_ui_event(
1996 &mut app,
1997 "run-a",
1998 &json!({
1999 "type": "run_started",
2000 "workflow_goal": "delayed first run",
2001 "at_ms": 1_500,
2002 }),
2003 );
2004 // The immutable envelope says A even if a malformed embedded field
2005 // claims B. Neither this failure nor A's terminal event belongs to B.
2006 apply_workflow_ui_event(
2007 &mut app,
2008 "run-a",
2009 &json!({
2010 "type": "task_dispatch_failed",
2011 "run_id": "run-b",
2012 "label": "late task",
2013 "message": "late A failure",
2014 "at_ms": 2_200,
2015 }),
2016 );
2017 apply_workflow_ui_event(
2018 &mut app,
2019 "run-a",
2020 &json!({
2021 "type": "run_completed",
2022 "status": "failed",
2023 "error": "late A completion",
2024 "at_ms": 2_300,
2025 }),
2026 );
2027
2028 let panel = app.workflow_panel.as_ref().expect("run B remains active");
2029 assert_eq!(panel.run_id, "run-b");
2030 assert_eq!(panel.to_run_json(), before);
2031 }
2032
2033 #[test]
2034 fn prior_run_completion_replay_does_not_replace_active_run() {
2035 let mut app = crate::test_support::test_app_with_options(
2036 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2037 );
2038 apply_workflow_ui_event(
2039 &mut app,
2040 "run-b",
2041 &json!({
2042 "type": "run_started",
2043 "workflow_goal": "active run",
2044 "at_ms": 2_000,
2045 }),
2046 );
2047 apply_workflow_ui_event(
2048 &mut app,
2049 "run-b",
2050 &json!({"type": "phase_started", "title": "Verify", "at_ms": 2_100}),
2051 );
2052 let before = app
2053 .workflow_panel
2054 .as_ref()
2055 .expect("run B panel")
2056 .to_run_json();
2057
2058 // A retained completion tail can contain run_started. The top-level
2059 // run identity and timestamp keep the whole replay off run B.
2060 apply_workflow_output_to_panel(
2061 &mut app,
2062 &json!({
2063 "run_id": "run-a",
2064 "workflow_goal": "prior run",
2065 "started_at_ms": 1_000,
2066 "completed_at_ms": 2_200,
2067 "status": "failed",
2068 "events": [
2069 {
2070 "type": "run_started",
2071 "run_id": "run-a",
2072 "workflow_goal": "prior run",
2073 "at_ms": 1_000,
2074 },
2075 {
2076 "type": "task_dispatch_failed",
2077 "run_id": "run-a",
2078 "message": "prior failure",
2079 "at_ms": 1_100,
2080 },
2081 {
2082 "type": "run_completed",
2083 "run_id": "run-a",
2084 "status": "failed",
2085 "at_ms": 2_200,
2086 }
2087 ],
2088 "dispatch_failure_count": 1,
2089 "dispatch_failures": [{
2090 "message": "prior failure",
2091 "at_ms": 1_100,
2092 }],
2093 })
2094 .to_string(),
2095 );
2096
2097 let panel = app.workflow_panel.as_ref().expect("run B remains active");
2098 assert_eq!(panel.run_id, "run-b");
2099 assert_eq!(panel.to_run_json(), before);
2100 }
2101
2102 #[test]
2103 fn workflow_completion_replay_uses_authoritative_dispatch_failure_ledger() {
2104 let mut app = crate::test_support::test_app_with_options(
2105 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2106 );
2107 let failure = json!({
2108 "type": "task_dispatch_failed",
2109 "label": "review docs",
2110 "phase": "Analyze",
2111 "message": "profile unavailable",
2112 "at_ms": 1_250,
2113 });
2114 apply_workflow_ui_event(
2115 &mut app,
2116 "run-1",
2117 &json!({
2118 "type": "run_started",
2119 "workflow_goal": "audit",
2120 "at_ms": 1_000,
2121 }),
2122 );
2123 apply_workflow_ui_event(&mut app, "run-1", &failure);
2124 assert_eq!(
2125 app.workflow_panel
2126 .as_ref()
2127 .expect("live panel")
2128 .dispatch_failure_count,
2129 1
2130 );
2131
2132 // A long run's retained tail may no longer include run_started, so
2133 // this event is a replay of the live failure rather than a new slot.
2134 apply_workflow_output_to_panel(
2135 &mut app,
2136 &json!({
2137 "run_id": "run-1",
2138 "workflow_goal": "audit",
2139 "started_at_ms": 1_000,
2140 "events": [failure],
2141 "dispatch_failure_count": 1,
2142 "dispatch_failures": [{
2143 "label": "review docs",
2144 "phase": "Analyze",
2145 "message": "profile unavailable",
2146 "at_ms": 1_250,
2147 }],
2148 })
2149 .to_string(),
2150 );
2151
2152 let panel = app.workflow_panel.as_ref().expect("completed panel");
2153 assert_eq!(panel.dispatch_failure_count, 1);
2154 assert_eq!(panel.dispatch_failures.len(), 1);
2155 assert_eq!(panel.failure_cancel_counts(), (1, 0));
2156 }
2157
2158 #[test]
2159 fn degraded_workflow_snapshot_marks_history_receipt_as_warning() {
2160 let mut app = crate::test_support::test_app_with_options(
2161 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2162 );
2163 app.history
2164 .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2165 name: "workflow".to_string(),
2166 status: ToolStatus::Running,
2167 input_summary: Some("action: run".to_string()),
2168 output: None,
2169 prompts: None,
2170 spillover_path: None,
2171 output_summary: None,
2172 is_diff: false,
2173 })));
2174
2175 apply_workflow_output_to_panel(
2176 &mut app,
2177 &json!({
2178 "run_id": "run-partial",
2179 "workflow_goal": "audit",
2180 "status": "degraded",
2181 "started_at_ms": 1_000,
2182 "completed_at_ms": 2_000,
2183 "dispatch_failure_count": 1,
2184 "dispatch_failures": [{
2185 "label": "review docs",
2186 "message": "profile unavailable",
2187 "at_ms": 1_500,
2188 }],
2189 })
2190 .to_string(),
2191 );
2192
2193 let HistoryCell::Tool(ToolCell::Generic(receipt)) = app.history.last().expect("receipt")
2194 else {
2195 panic!("workflow receipt must remain generic")
2196 };
2197 assert_eq!(receipt.status, ToolStatus::Warning);
2198 assert!(!history_cell_has_running_tool(
2199 app.history.last().expect("receipt")
2200 ));
2201 }
2202
2203 #[cfg(unix)]
2204 fn hook_log_lines_eventually(path: &std::path::Path, expected: usize) -> Vec<String> {
2205 for _ in 0..100 {
2206 let lines = std::fs::read_to_string(path)
2207 .unwrap_or_default()
2208 .lines()
2209 .map(str::to_string)
2210 .collect::<Vec<_>>();
2211 if lines.len() >= expected {
2212 return lines;
2213 }
2214 std::thread::sleep(std::time::Duration::from_millis(10));
2215 }
2216 std::fs::read_to_string(path)
2217 .unwrap_or_default()
2218 .lines()
2219 .map(str::to_string)
2220 .collect()
2221 }
2222
2223 /// A UI-ignored completion is still a completion. `tool_call_after` and
2224 /// `on_error` must fire for it — exactly once — or the documented "fires
2225 /// after each tool call" silently excludes repeated `wait` and background
2226 /// results, which is the class of call an observer most wants to record.
2227 #[cfg(unix)]
2228 #[test]
2229 fn ignored_tool_calls_still_fire_after_and_error_hooks_once() {
2230 use crate::hooks::{Hook, HookEvent, HookExecutor, HooksConfig};
2231
2232 let dir = tempfile::tempdir().expect("tempdir");
2233 let after_log = dir.path().join("after.log");
2234 let error_log = dir.path().join("error.log");
2235 let script = |path: &std::path::Path| {
2236 format!(
2237 "printf '%s\\n' \"$DEEPSEEK_TOOL_CALL_ID\" >> {}",
2238 path.display()
2239 )
2240 };
2241
2242 let mut app = crate::test_support::test_app_with_options(
2243 crate::test_support::test_tui_options(dir.path()),
2244 );
2245 app.workspace = dir.path().to_path_buf();
2246 app.hooks = HookExecutor::new(
2247 HooksConfig {
2248 enabled: true,
2249 hooks: vec![
2250 Hook::new(HookEvent::ToolCallAfter, &script(&after_log)).with_name("after"),
2251 Hook::new(HookEvent::OnError, &script(&error_log)).with_name("error"),
2252 ],
2253 ..HooksConfig::default()
2254 },
2255 dir.path().to_path_buf(),
2256 );
2257
2258 let id = "call_ignored_1";
2259 app.ignored_tool_calls.insert(id.to_string());
2260 let failed: Result<ToolResult, ToolError> = Ok(ToolResult::error("boom"));
2261
2262 handle_tool_call_complete(&mut app, id, "exec_shell", &failed);
2263
2264 // The presentation state still consumed the id...
2265 assert!(!app.ignored_tool_calls.contains(id));
2266 // ...and both observers saw the call, once each.
2267 let after = hook_log_lines_eventually(&after_log, 1);
2268 let errors = hook_log_lines_eventually(&error_log, 1);
2269 assert_eq!(after, vec![id]);
2270 assert_eq!(errors, vec![id]);
2271
2272 // A successful ignored completion fires `tool_call_after` only.
2273 let second = "call_ignored_2";
2274 app.ignored_tool_calls.insert(second.to_string());
2275 handle_tool_call_complete(
2276 &mut app,
2277 second,
2278 "exec_shell",
2279 &Ok(ToolResult::success("ok")),
2280 );
2281 let after = hook_log_lines_eventually(&after_log, 2);
2282 let errors = hook_log_lines_eventually(&error_log, 1);
2283 assert_eq!(after, vec![id, second]);
2284 assert_eq!(errors, vec![id]);
2285 }
2286
2287 #[test]
2288 fn adaptive_evidence_late_foreign_and_duplicate_completions_are_ignored() {
2289 let result = Ok(ToolResult::success("bounded").with_metadata(json!({
2290 "artifact_session_id": "session-a",
2291 "artifact_id": "art_call-a"
2292 })));
2293 assert!(evidence_completion_identity_should_be_ignored(
2294 Some("session-b"),
2295 std::iter::empty(),
2296 "call-a",
2297 &result,
2298 ));
2299 assert!(evidence_completion_identity_should_be_ignored(
2300 Some("session-a"),
2301 [("art_call-a", "call-a")],
2302 "call-a",
2303 &result,
2304 ));
2305 assert!(!evidence_completion_identity_should_be_ignored(
2306 Some("session-a"),
2307 std::iter::empty(),
2308 "call-a",
2309 &result,
2310 ));
2311 }
2312
2313 #[test]
2314 fn web_search_presentation_reads_source_degradation_and_citation_count() {
2315 let presentation = web_search_presentation(
2316 &json!({
2317 "source": "provider-native/xai/grok-4.5",
2318 "results": [
2319 {"ref_id": "web_a", "url": "https://example.com/a"},
2320 {"ref_id": "web_b", "url": "https://example.com/b"}
2321 ],
2322 "receipt": {
2323 "degraded": [
2324 {"kind": "backend_unavailable", "backend": "provider_native"},
2325 {"kind": "backend_fallback", "from": "provider_native", "to": "tavily"}
2326 ]
2327 }
2328 })
2329 .to_string(),
2330 );
2331
2332 assert_eq!(
2333 presentation.source.as_deref(),
2334 Some("provider-native/xai/grok-4.5")
2335 );
2336 assert_eq!(
2337 presentation.degraded.as_deref(),
2338 Some("provider_native unavailable; provider_native -> tavily")
2339 );
2340 assert_eq!(presentation.ref_count, 2);
2341 }
2342
2343 #[test]
2344 fn web_run_presentation_reads_nested_search_receipts() {
2345 let presentation = web_search_presentation(
2346 &json!({
2347 "search_query": [{
2348 "source": "duckduckgo",
2349 "results": [{"ref_id": "web_a"}],
2350 "receipt": {
2351 "degraded": [{"kind": "knob_ignored", "knob": "recency"}]
2352 }
2353 }]
2354 })
2355 .to_string(),
2356 );
2357
2358 assert_eq!(presentation.source.as_deref(), Some("duckduckgo"));
2359 assert_eq!(presentation.degraded.as_deref(), Some("recency ignored"));
2360 assert_eq!(presentation.ref_count, 1);
2361 }
2362
2363 #[test]
2364 fn parse_plan_input_accepts_legacy_payload() {
2365 let snapshot = parse_plan_input(&json!({
2366 "explanation": "Legacy explanation",
2367 "plan": [
2368 { "step": "inspect", "status": "completed" },
2369 { "step": "patch", "status": "in_progress" }
2370 ]
2371 }));
2372
2373 assert_eq!(snapshot.explanation.as_deref(), Some("Legacy explanation"));
2374 assert_eq!(snapshot.items.len(), 2);
2375 assert_eq!(snapshot.items[0].status, StepStatus::Completed);
2376 assert_eq!(snapshot.items[1].status, StepStatus::InProgress);
2377 }
2378
2379 #[test]
2380 fn parse_plan_input_extracts_rich_artifact_fields() {
2381 let snapshot = parse_plan_input(&json!({
2382 "title": " PlanArtifact ",
2383 "objective": "Make Plan mode reviewable",
2384 "context_summary": "Grounded in issue #2691",
2385 "sources_used": [" gh issue view 2691 ", ""],
2386 "critical_files": ["crates/tui/src/tools/plan.rs"],
2387 "constraints": ["No secrets"],
2388 "recommended_approach": "Enrich update_plan",
2389 "verification_plan": "Run focused tests",
2390 "risks_and_unknowns": "Replay may drift",
2391 "handoff_packet": "Continue with session replay",
2392 "plan": [
2393 { "step": " ", "status": "completed" },
2394 { "step": "render all fields", "status": "weird" }
2395 ]
2396 }));
2397
2398 assert_eq!(snapshot.title.as_deref(), Some("PlanArtifact"));
2399 assert_eq!(snapshot.sources_used, vec!["gh issue view 2691"]);
2400 assert_eq!(
2401 snapshot.critical_files,
2402 vec!["crates/tui/src/tools/plan.rs"]
2403 );
2404 assert_eq!(snapshot.constraints, vec!["No secrets"]);
2405 assert_eq!(
2406 snapshot.verification_plan.as_deref(),
2407 Some("Run focused tests")
2408 );
2409 assert_eq!(snapshot.items.len(), 1);
2410 assert_eq!(snapshot.items[0].step, "render all fields");
2411 assert_eq!(snapshot.items[0].status, StepStatus::Pending);
2412 }
2413
2414 #[test]
2415 fn parse_patch_summary_treats_replace_and_legacy_changes_equally() {
2416 let replacements = json!([{
2417 "path": "src/lib.rs",
2418 "content": "fn replacement() {}\n"
2419 }]);
2420
2421 let canonical =
2422 parse_file_mutation_summary("apply_patch", &json!({"replace": replacements.clone()}));
2423 let legacy = parse_file_mutation_summary("apply_patch", &json!({"changes": replacements}));
2424
2425 assert_eq!(canonical, legacy);
2426 }
2427
2428 // ── #3031: "(no output)" placeholder must not defeat compact rendering ─
2429
2430 #[test]
2431 fn visible_tool_output_maps_no_output_placeholder_to_none() {
2432 assert_eq!(visible_tool_output("(no output)"), None);
2433 assert_eq!(visible_tool_output(" (no output)\n"), None);
2434 }
2435
2436 #[test]
2437 fn visible_tool_output_preserves_real_content() {
2438 assert_eq!(
2439 visible_tool_output("compiled 3 crates").as_deref(),
2440 Some("compiled 3 crates")
2441 );
2442 // Output that merely CONTAINS the placeholder is real output.
2443 assert_eq!(
2444 visible_tool_output("step 1: (no output) — continuing").as_deref(),
2445 Some("step 1: (no output) — continuing")
2446 );
2447 assert_eq!(visible_tool_output("").as_deref(), Some(""));
2448 }
2449
2450 #[test]
2451 fn exec_cell_without_output_suppresses_placeholder_in_live_mode() {
2452 use crate::tui::history::{ExecCell, ExecSource, ToolCell, ToolStatus};
2453
2454 let cell = ToolCell::Exec(ExecCell {
2455 command: "true".to_string(),
2456 status: ToolStatus::Success,
2457 output: None,
2458 live_output: None,
2459 shell_task_id: None,
2460 owner_agent_id: None,
2461 owner_agent_name: None,
2462 started_at: None,
2463 duration_ms: Some(120),
2464 stale_elapsed_since_output_ms: None,
2465 source: ExecSource::Assistant,
2466 interaction: None,
2467 output_summary: None,
2468 });
2469
2470 let live: String = cell
2471 .lines(80)
2472 .iter()
2473 .flat_map(|line| line.spans.iter().map(|s| s.content.to_string()))
2474 .collect();
2475 assert!(
2476 !live.contains("(no output)"),
2477 "Live mode must suppress the placeholder: {live:?}"
2478 );
2479
2480 let transcript: String = cell
2481 .transcript_lines(80)
2482 .iter()
2483 .flat_map(|line| line.spans.iter().map(|s| s.content.to_string()))
2484 .collect();
2485 assert!(
2486 transcript.contains("(no output)"),
2487 "Transcript mode still records the placeholder: {transcript:?}"
2488 );
2489 }
2490
2491 /// #455 — `exit_code` conditions must only ever see a real, reported exit
2492 /// code. `tool_call_after` used to hard-code `None`, which made every
2493 /// `{ type = "exit_code" }` condition permanently unmatchable.
2494 #[test]
2495 fn reported_tool_exit_code_reads_only_real_metadata_codes() {
2496 let with_code = Ok(ToolResult {
2497 content: "boom".to_string(),
2498 success: false,
2499 metadata: Some(serde_json::json!({ "exit_code": 127 })),
2500 });
2501 assert_eq!(super::reported_tool_exit_code(&with_code), Some(127));
2502
2503 // Zero is a real code, not a missing one.
2504 let zero = Ok(ToolResult {
2505 content: "ok".to_string(),
2506 success: true,
2507 metadata: Some(serde_json::json!({ "exit_code": 0 })),
2508 });
2509 assert_eq!(super::reported_tool_exit_code(&zero), Some(0));
2510
2511 // Tools that report no exit code stay `None` — never synthesized from
2512 // the success flag.
2513 let no_metadata = Ok(ToolResult::error("failed"));
2514 assert_eq!(super::reported_tool_exit_code(&no_metadata), None);
2515
2516 let null_code = Ok(ToolResult {
2517 content: String::new(),
2518 success: true,
2519 metadata: Some(serde_json::json!({ "exit_code": serde_json::Value::Null })),
2520 });
2521 assert_eq!(super::reported_tool_exit_code(&null_code), None);
2522
2523 let wrong_type = Ok(ToolResult {
2524 content: String::new(),
2525 success: false,
2526 metadata: Some(serde_json::json!({ "exit_code": "127" })),
2527 });
2528 assert_eq!(super::reported_tool_exit_code(&wrong_type), None);
2529
2530 // A Windows crash code does not fit in an `i32`, but it is a real code
2531 // and a hook scoped to it must be able to see it.
2532 let windows_crash = Ok(ToolResult {
2533 content: String::new(),
2534 success: false,
2535 metadata: Some(serde_json::json!({ "exit_code": 3_221_225_477_i64 })),
2536 });
2537 assert_eq!(
2538 super::reported_tool_exit_code(&windows_crash),
2539 Some(3_221_225_477)
2540 );
2541
2542 // A transport-level tool error has no metadata at all.
2543 let errored: Result<ToolResult, ToolError> =
2544 Err(ToolError::execution_failed("no such tool"));
2545 assert_eq!(super::reported_tool_exit_code(&errored), None);
2546 }
2547
2548 // === #5472 finding 3: retained tool outputs are bounded ===
2549
2550 #[test]
2551 fn tool_detail_output_is_capped_and_says_what_it_dropped() {
2552 let small = "short output".to_string();
2553 assert_eq!(super::bounded_tool_detail_output(small.clone()), small);
2554
2555 let huge = "x".repeat(super::TOOL_DETAIL_OUTPUT_MAX_BYTES * 3);
2556 let bounded = super::bounded_tool_detail_output(huge);
2557 assert!(
2558 bounded.len() < super::TOOL_DETAIL_OUTPUT_MAX_BYTES + 200,
2559 "retained {} bytes",
2560 bounded.len()
2561 );
2562 assert!(bounded.contains("the transcript keeps an excerpt"));
2563 }
2564
2565 #[test]
2566 fn tool_detail_cap_never_splits_a_character() {
2567 // Every char is 3 bytes, so a byte-exact cut lands mid-character.
2568 let wide = "宽".repeat(super::TOOL_DETAIL_OUTPUT_MAX_BYTES);
2569 let bounded = super::bounded_tool_detail_output(wide);
2570 assert!(bounded.starts_with('宽'));
2571 assert!(bounded.contains("excerpt"));
2572 }
2573
2574 #[test]
2575 fn oldest_tool_outputs_are_released_once_the_budget_is_exceeded() {
2576 let mut app = crate::tui::app::App::new(
2577 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2578 &crate::config::Config::default(),
2579 );
2580 // 200 records x 64 KiB = 12.8 MiB, well past the 8 MiB budget.
2581 let record_count = 200usize;
2582 for index in 0..record_count {
2583 app.tool_details_by_cell.insert(
2584 index,
2585 ToolDetailRecord {
2586 tool_id: format!("tool-{index}"),
2587 tool_name: "Bash".to_string(),
2588 input: serde_json::Value::Null,
2589 output: Some("y".repeat(super::TOOL_DETAIL_OUTPUT_MAX_BYTES)),
2590 },
2591 );
2592 }
2593 super::release_oldest_tool_detail_outputs(&mut app);
2594
2595 let retained: usize = app
2596 .tool_details_by_cell
2597 .values()
2598 .map(|detail| detail.output.as_ref().map_or(0, String::len))
2599 .sum();
2600 assert!(
2601 retained <= super::TOOL_DETAIL_TOTAL_BUDGET_BYTES,
2602 "retained {retained} bytes over the {} budget",
2603 super::TOOL_DETAIL_TOTAL_BUDGET_BYTES
2604 );
2605 assert_eq!(
2606 app.tool_details_by_cell.len(),
2607 record_count,
2608 "records stay listed; only their outputs are released"
2609 );
2610 assert!(
2611 app.tool_details_by_cell[&(record_count - 1)]
2612 .output
2613 .is_some(),
2614 "the newest output must survive — it is the one the user can still expand"
2615 );
2616 assert!(
2617 app.tool_details_by_cell[&0].output.is_none(),
2618 "the oldest output is the first to go"
2619 );
2620 }
2621 }
2622
2622 lines RUST