返回 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 /// Fire `tool_call_after` for every settled tool call, plus `on_error` when
635 /// the call failed.
636 ///
637 /// `on_error` is documented as covering tool failures, not just transport and
638 /// auth failures, so the tool path has to raise it too — the engine-error path
639 /// in `apply_engine_error_to_app` never sees a tool that returned
640 /// `success: false`.
641 ///
642 /// Both are observer events: their stdout is ignored and neither can change
643 /// the result that goes back to the model. That is a statement about
644 /// Codewhale's control flow only — the commands themselves are arbitrary
645 /// shells and may have any external side effect.
646 fn fire_tool_completion_hooks(
647 app: &mut App,
648 id: &str,
649 name: &str,
650 result: &Result<ToolResult, ToolError>,
651 ) {
652 let wants_after = app.hooks.has_hooks_for_event(HookEvent::ToolCallAfter);
653 let wants_error = app
654 .hooks
655 .has_hooks_for_event(crate::hooks::HookEvent::OnError);
656 if !wants_after && !wants_error {
657 // Fast path: skip the result clone and HookContext allocation when
658 // the user has configured neither event.
659 return;
660 }
661
662 let input = app
663 .active_tool_details
664 .get(id)
665 .or_else(|| {
666 app.tool_cells
667 .get(id)
668 .and_then(|index| app.tool_details_by_cell.get(index))
669 })
670 .map(|detail| detail.input.clone());
671 let context = app
672 .base_hook_context()
673 .with_tool_name(name)
674 .with_tool_call_id(id)
675 .with_tool_outcome(result);
676 let context = input.as_ref().map_or_else(
677 || context.clone(),
678 |input| context.clone().with_tool_args(input),
679 );
680 let failed = context.tool_success == Some(false);
681 let error_context = (wants_error && failed).then(|| {
682 let text = context.tool_result.as_deref().unwrap_or_default();
683 let message = format!("tool `{name}` failed: {text}");
684 context.clone().with_error(&message)
685 });
686
687 if wants_after && let Err(error) = app.submit_hooks(HookEvent::ToolCallAfter, context) {
688 app.surface_observer_hook_submission_failure(error);
689 }
690
691 if let Some(context) = error_context
692 && let Err(error) = app.submit_hooks(crate::hooks::HookEvent::OnError, context)
693 {
694 app.surface_observer_hook_submission_failure(error);
695 }
696 }
697
698 pub(super) fn handle_tool_call_complete(
699 app: &mut App,
700 id: &str,
701 name: &str,
702 result: &Result<ToolResult, ToolError>,
703 ) {
704 if app.ignored_tool_calls.remove(id) {
705 // "Ignored" is a *presentation* decision: these are real settled
706 // results — repeated `wait` polls, background-shell status reads —
707 // that the transcript deliberately does not redraw. Observers still
708 // have to see them, or `tool_call_after` silently skips a whole class
709 // of completions while claiming to fire after each tool call. Fired
710 // here and returned immediately, so each id emits exactly once.
711 fire_tool_completion_hooks(app, id, name, result);
712 return;
713 }
714 // Preserve the execution/audit name while recovering the action-qualified
715 // semantic name from the registered call input. Active entries and
716 // already-flushed history use separate detail stores.
717 let semantic_name = app
718 .active_tool_details
719 .get(id)
720 .or_else(|| {
721 app.tool_cells
722 .get(id)
723 .and_then(|cell_index| app.tool_details_by_cell.get(cell_index))
724 })
725 .map_or(name, |detail| canonical_action_alias(name, &detail.input))
726 .to_string();
727
728 // Roll any child-LLM token usage the tool reports into the
729 // session-cost counter. Runs unconditionally so future tools that
730 // spawn their own LLM calls (RLM, summarizers, retrieval helpers)
731 // get accrued without needing a per-tool hook (#524).
732 accrue_child_token_cost_if_any(app, result);
733 record_spillover_artifact_if_any(app, id, name, result);
734
735 // #455: fire `tool_call_after` (and `on_error` for failures) here, before
736 // any of the presentation early-returns below. Firing it further down meant
737 // exploring-tool completions and orphaned completions never emitted the
738 // event at all, so "fires after each tool call" was not true.
739 fire_tool_completion_hooks(app, id, name, result);
740
741 // The engine prefixes an approved call's result with a note for the model
742 // ("[approval] This tool call required approval…"). The person gave that
743 // approval a moment ago; repeating it as the first line of the output
744 // reads as an internal log (#6566). The model's copy keeps the note. The
745 // engine's approval stamp decides what is a note, never the text alone.
746 let displayed;
747 let result = match result {
748 Ok(tool_result) => {
749 let shown_content = crate::core::engine::content_without_approval_note(tool_result);
750 if shown_content.len() == tool_result.content.len() {
751 result
752 } else {
753 let mut shown = tool_result.clone();
754 shown.content = shown_content.to_string();
755 displayed = Ok(shown);
756 &displayed
757 }
758 }
759 Err(_) => result,
760 };
761
762 // Exploring entries land in the per-tool map regardless of whether they
763 // live in the active cell or in finalized history; the path is the same.
764 if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) {
765 app.tool_cells.remove(id);
766 store_tool_detail_output(app, id, cell_index, result);
767 if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) =
768 app.cell_at_virtual_index_mut(cell_index)
769 && let Some(entry) = cell.entries.get_mut(entry_index)
770 {
771 entry.status = tool_status_from_result(result);
772 app.mark_history_updated();
773 // Mutating the in-flight exploring cell needs an active-cell
774 // revision bump so the transcript cache invalidates the synthetic
775 // tail row.
776 if cell_index >= app.history.len() {
777 app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
778 if let Some(active) = app.active_cell.as_mut() {
779 active.bump_revision();
780 }
781 }
782 }
783 refresh_active_tool_completion_timestamp(app, cell_index);
784 return;
785 }
786
787 // Look up the cell by tool id. If the id isn't registered, that's an
788 // orphan completion (race condition where the started event was lost or
789 // a tool result arrived after the active cell was already flushed). Build
790 // a finalized standalone cell from the result so the user can still see
791 // the output, but DO NOT touch the active cell.
792 let Some(cell_index) = app.tool_cells.remove(id) else {
793 push_orphan_tool_completion(app, id, name, result);
794 return;
795 };
796
797 store_tool_detail_output(app, id, cell_index, result);
798 let in_active = cell_index >= app.history.len();
799
800 let status = tool_status_from_result(result);
801 let mutation_receipt = matches!(
802 semantic_name.as_str(),
803 "write_file" | "edit_file" | "apply_patch"
804 )
805 .then(|| {
806 result.as_ref().ok().and_then(|tool_result| {
807 crate::tui::history::FileMutationReceipt::from_success(&app.workspace, tool_result)
808 })
809 })
810 .flatten();
811 let mut workflow_panel_output: Option<String> = None;
812
813 if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) {
814 match cell {
815 HistoryCell::Tool(ToolCell::Exec(exec)) => {
816 exec.status = status;
817 if let Ok(tool_result) = result.as_ref() {
818 let shell_task_id = tool_result
819 .metadata
820 .as_ref()
821 .and_then(|m| m.get("task_id"))
822 .and_then(serde_json::Value::as_str)
823 .filter(|task_id| !task_id.trim().is_empty())
824 .map(str::to_string);
825 if shell_task_id.is_some() {
826 exec.shell_task_id = shell_task_id;
827 }
828 exec.owner_agent_id = tool_result
829 .metadata
830 .as_ref()
831 .and_then(|m| m.get("owner_agent_id"))
832 .and_then(serde_json::Value::as_str)
833 .filter(|agent_id| !agent_id.trim().is_empty())
834 .map(str::to_string);
835 exec.owner_agent_name = tool_result
836 .metadata
837 .as_ref()
838 .and_then(|m| m.get("owner_agent_name"))
839 .and_then(serde_json::Value::as_str)
840 .filter(|agent_name| !agent_name.trim().is_empty())
841 .map(str::to_string);
842 if let Some(meta_command) = tool_result
843 .metadata
844 .as_ref()
845 .and_then(|m| m.get("command"))
846 .and_then(serde_json::Value::as_str)
847 && !meta_command.trim().is_empty()
848 && (exec.command == "command" || exec.command.starts_with("command "))
849 {
850 exec.command = meta_command.to_string();
851 if exec.interaction.as_deref().is_some_and(|interaction| {
852 interaction.starts_with("Waiting for command")
853 }) {
854 let task_suffix = tool_result
855 .metadata
856 .as_ref()
857 .and_then(|m| m.get("task_id"))
858 .and_then(serde_json::Value::as_str)
859 .map(|task_id| format!(" ({task_id})"))
860 .unwrap_or_default();
861 exec.interaction =
862 Some(format!("Waiting for \"{meta_command}\"{task_suffix}"));
863 }
864 }
865 exec.duration_ms = tool_result
866 .metadata
867 .as_ref()
868 .and_then(|m| m.get("duration_ms"))
869 .and_then(serde_json::Value::as_u64);
870 if status != ToolStatus::Running && exec.interaction.is_none() {
871 exec.output = visible_tool_output(&tool_result.content);
872 exec.output_summary = exec
873 .output
874 .as_deref()
875 .map(super::history::summarize_tool_output);
876 exec.live_output = None;
877 } else if status == ToolStatus::Running
878 && exec.interaction.is_none()
879 && !tool_result.content.is_empty()
880 {
881 exec.live_output = Some(tool_result.content.clone());
882 }
883 } else if let Err(err) = result.as_ref()
884 && exec.interaction.is_none()
885 {
886 exec.output = Some(err.to_string());
887 exec.output_summary =
888 Some(super::history::summarize_tool_output(&err.to_string()));
889 }
890 app.mark_history_updated();
891 }
892 HistoryCell::Tool(ToolCell::PlanUpdate(plan)) => {
893 plan.status = status;
894 app.mark_history_updated();
895 }
896 HistoryCell::Tool(ToolCell::PatchSummary(patch)) => {
897 patch.status = status;
898 patch.receipt = mutation_receipt;
899 match result.as_ref() {
900 Ok(tool_result) if tool_result.success => {
901 if let Ok(json) =
902 serde_json::from_str::<serde_json::Value>(&tool_result.content)
903 && let Some(message) = json.get("message").and_then(|v| v.as_str())
904 {
905 patch.summary = message.to_string();
906 }
907 }
908 Ok(tool_result) => {
909 patch.error = Some(tool_result.content.clone());
910 }
911 Err(err) => {
912 patch.error = Some(err.to_string());
913 }
914 }
915 app.mark_history_updated();
916 }
917 HistoryCell::Tool(ToolCell::Review(review)) => {
918 review.status = status;
919 match result.as_ref() {
920 Ok(tool_result) => {
921 if tool_result.success {
922 review.output = Some(ReviewOutput::from_str(&tool_result.content));
923 } else {
924 review.error = Some(tool_result.content.clone());
925 }
926 }
927 Err(err) => {
928 review.error = Some(err.to_string());
929 }
930 }
931 app.mark_history_updated();
932 }
933 HistoryCell::Tool(ToolCell::Mcp(mcp)) => {
934 match result.as_ref() {
935 Ok(tool_result) => {
936 let summary = summarize_mcp_output(&tool_result.content);
937 if status == ToolStatus::Hydrated {
938 mcp.status = status;
939 } else if summary.is_error == Some(true) {
940 mcp.status = ToolStatus::Failed;
941 } else {
942 mcp.status = status;
943 }
944 mcp.is_image = summary.is_image;
945 mcp.content = summary.content;
946 }
947 Err(err) => {
948 mcp.status = status;
949 mcp.content = Some(err.to_string());
950 }
951 }
952 app.mark_history_updated();
953 }
954 HistoryCell::Tool(ToolCell::WebSearch(search)) => {
955 search.status = status;
956 match result.as_ref() {
957 Ok(tool_result) => {
958 search.summary = Some(summarize_tool_output(&tool_result.content));
959 let presentation = web_search_presentation(&tool_result.content);
960 search.source = presentation.source;
961 search.degraded = presentation.degraded;
962 search.ref_count = presentation.ref_count;
963 }
964 Err(err) => {
965 search.summary = Some(err.to_string());
966 }
967 }
968 app.mark_history_updated();
969 }
970 HistoryCell::Tool(ToolCell::Generic(generic)) => {
971 generic.status = status;
972 match result.as_ref() {
973 Ok(tool_result) => {
974 generic.output = visible_tool_output(&tool_result.content);
975 generic.output_summary =
976 generic.output.as_deref().map(summarize_tool_output);
977 generic.is_diff = output_looks_like_diff(&tool_result.content);
978 }
979 Err(err) => {
980 generic.output = Some(err.to_string());
981 generic.output_summary = Some(summarize_tool_output(&err.to_string()));
982 generic.is_diff = false;
983 }
984 }
985 // #4121: capture workflow JSON before releasing the cell borrow
986 // so we can hydrate the panel without overlapping borrows.
987 if generic.name == "workflow" {
988 workflow_panel_output = generic.output.clone();
989 }
990 app.mark_history_updated();
991 }
992 _ => {}
993 }
994 }
995
996 // #4121 / #4122: feed typed workflow events into the panel *and* keep the
997 // history card snapshot in sync. Live streaming also arrives via
998 // `Event::WorkflowUi`; this path covers tool-complete hydration.
999 if let Some(output) = workflow_panel_output.as_deref() {
1000 apply_workflow_output_to_panel(app, output);
1001 }
1002
1003 // If the mutated cell lived inside the active group, bump the active-cell
1004 // revision so the transcript cache re-renders the synthetic tail row.
1005 if in_active {
1006 app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
1007 if let Some(active) = app.active_cell.as_mut() {
1008 active.bump_revision();
1009 }
1010 refresh_active_tool_completion_timestamp(app, cell_index);
1011 }
1012
1013 if refreshes_workspace_context_on_completion(&semantic_name) && status != ToolStatus::Running {
1014 workspace_context::refresh_now(app, Instant::now());
1015 }
1016
1017 // Collect evidence for the post-turn receipt.
1018 let evidence_summary = match result.as_ref() {
1019 Ok(tool_result) => {
1020 if tool_result.success {
1021 summarize_tool_output(&tool_result.content)
1022 } else {
1023 format!("failed: {}", summarize_tool_output(&tool_result.content))
1024 }
1025 }
1026 Err(err) => format!("error: {err}"),
1027 };
1028 app.tool_evidence.push(ToolEvidence {
1029 tool_name: name.to_string(),
1030 summary: evidence_summary,
1031 });
1032 }
1033
1034 #[derive(Debug, Default, PartialEq, Eq)]
1035 struct WebSearchPresentation {
1036 source: Option<String>,
1037 degraded: Option<String>,
1038 ref_count: usize,
1039 }
1040
1041 fn web_search_presentation(content: &str) -> WebSearchPresentation {
1042 let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
1043 return WebSearchPresentation::default();
1044 };
1045 let surfaces = if value.get("receipt").is_some() {
1046 vec![&value]
1047 } else {
1048 value
1049 .get("search_query")
1050 .and_then(serde_json::Value::as_array)
1051 .map(|items| items.iter().collect())
1052 .unwrap_or_default()
1053 };
1054 let source = surfaces
1055 .iter()
1056 .filter_map(|surface| surface.get("source").and_then(serde_json::Value::as_str))
1057 .map(str::to_string)
1058 .next();
1059 let mut degraded = Vec::new();
1060 let mut ref_count = 0usize;
1061 for surface in surfaces {
1062 if let Some(results) = surface.get("results").and_then(serde_json::Value::as_array) {
1063 ref_count = ref_count.saturating_add(
1064 results
1065 .iter()
1066 .filter(|result| {
1067 result
1068 .get("ref_id")
1069 .and_then(serde_json::Value::as_str)
1070 .is_some_and(|ref_id| !ref_id.is_empty())
1071 })
1072 .count(),
1073 );
1074 }
1075 if let Some(reasons) = surface
1076 .pointer("/receipt/degraded")
1077 .and_then(serde_json::Value::as_array)
1078 {
1079 for reason in reasons {
1080 if let Some(label) = degraded_reason_label(reason)
1081 && !degraded.contains(&label)
1082 {
1083 degraded.push(label);
1084 }
1085 }
1086 }
1087 }
1088 WebSearchPresentation {
1089 source,
1090 degraded: (!degraded.is_empty()).then(|| degraded.join("; ")),
1091 ref_count,
1092 }
1093 }
1094
1095 fn degraded_reason_label(reason: &serde_json::Value) -> Option<String> {
1096 let kind = reason.get("kind")?.as_str()?;
1097 let backend = |field: &str| {
1098 reason
1099 .get(field)
1100 .and_then(serde_json::Value::as_str)
1101 .unwrap_or("unknown")
1102 };
1103 Some(match kind {
1104 "backend_unavailable" => format!("{} unavailable", backend("backend")),
1105 "no_usable_results" => format!("{} returned no usable results", backend("backend")),
1106 "backend_fallback" => format!("{} -> {}", backend("from"), backend("to")),
1107 "challenge_detected" => format!("{} challenge", backend("backend")),
1108 "scrape_fallback" => format!("{} -> {} scrape", backend("from"), backend("to")),
1109 "knob_ignored" => format!(
1110 "{} ignored",
1111 reason
1112 .get("knob")
1113 .and_then(serde_json::Value::as_str)
1114 .unwrap_or("filter")
1115 ),
1116 "post_filtered" => format!(
1117 "{} post-filtered",
1118 reason
1119 .get("knob")
1120 .and_then(serde_json::Value::as_str)
1121 .unwrap_or("results")
1122 ),
1123 "synthesized_results" => "synthesized results".to_string(),
1124 other => other.replace('_', " "),
1125 })
1126 }
1127
1128 /// Hydrate or advance a workflow run from a workflow tool JSON payload: a
1129 /// run record (with its retained `events` tail) or a status envelope. A
1130 /// status envelope only refreshes runs this view already holds — polling an
1131 /// old run must not bring it back, or its finish line would be written twice.
1132 fn apply_workflow_output_to_panel(app: &mut App, output: &str) {
1133 let Ok(value) = serde_json::from_str::<serde_json::Value>(output) else {
1134 return;
1135 };
1136 if value.get("action").and_then(|v| v.as_str()) == Some("status") {
1137 if let Some(runs) = value.get("runs").and_then(|r| r.as_array()) {
1138 for run in runs {
1139 apply_workflow_run_record(app, run, false);
1140 }
1141 }
1142 } else {
1143 apply_workflow_run_record(app, &value, true);
1144 }
1145 app.announce_settled_workflows();
1146 }
1147
1148 fn apply_workflow_run_record(app: &mut App, value: &serde_json::Value, may_create: bool) {
1149 let Some(run_id) = value
1150 .get("run_id")
1151 .and_then(|v| v.as_str())
1152 .filter(|run_id| !run_id.trim().is_empty())
1153 .map(str::to_string)
1154 .or_else(|| {
1155 value
1156 .get("events")
1157 .and_then(|events| events.as_array())
1158 .and_then(|events| {
1159 events.iter().find_map(|event| {
1160 event
1161 .get("run_id")
1162 .and_then(|v| v.as_str())
1163 .filter(|run_id| !run_id.trim().is_empty())
1164 .map(str::to_string)
1165 })
1166 })
1167 })
1168 else {
1169 return;
1170 };
1171 if app.workflow_run(&run_id).is_none() && !may_create {
1172 return;
1173 }
1174 let label = value
1175 .get("workflow_goal")
1176 .and_then(|v| v.as_str())
1177 .or_else(|| value.get("workflow_id").and_then(|v| v.as_str()))
1178 .unwrap_or(&run_id)
1179 .to_string();
1180 let at_ms = value
1181 .get("started_at_ms")
1182 .and_then(|v| v.as_u64())
1183 .unwrap_or(0);
1184
1185 // Prefer the typed event stream when present.
1186 if let Some(events) = value.get("events").and_then(|e| e.as_array()) {
1187 if app.workflow_run(&run_id).is_none() {
1188 let mut panel = crate::tui::widgets::workflow_panel::WorkflowPanel::new(
1189 run_id.clone(),
1190 label,
1191 at_ms,
1192 );
1193 panel.locale = app.ui_locale;
1194 app.push_workflow_run(panel);
1195 }
1196 let Some(panel) = app.workflow_run_mut(&run_id) else {
1197 return;
1198 };
1199 let injected: Vec<serde_json::Value> = events
1200 .iter()
1201 .map(|event| {
1202 let mut event = event.clone();
1203 if let Some(obj) = event.as_object_mut() {
1204 // The top-level run record is authoritative. Do not let a
1205 // stale/malformed embedded id retarget one replay event.
1206 obj.insert(
1207 "run_id".to_string(),
1208 serde_json::Value::String(run_id.clone()),
1209 );
1210 }
1211 event
1212 })
1213 .collect();
1214 // A replayed tail can repeat this run's `run_started`, which resets
1215 // its state; whether the finish line was written survives that.
1216 let announced = panel.finish_announced;
1217 panel.apply_json_events(&injected);
1218 panel.finish_announced = announced || panel.finish_announced;
1219 // Completion/status payloads replay a retained event tail. Merge
1220 // the authoritative exact count + bounded structured ledger after
1221 // replay so live dispatch failures are neither duplicated nor
1222 // lost when older events have fallen out of the tail (#5528).
1223 panel.merge_dispatch_failures_from_run_json(value);
1224 if let Some(summary) = value
1225 .get("result")
1226 .and_then(crate::tools::workflow::workflow_result_preview)
1227 {
1228 panel.result_summary = Some(summary);
1229 }
1230 if let Some(path) = value.get("source_path").and_then(|v| v.as_str()) {
1231 panel.source_path = Some(PathBuf::from(path));
1232 }
1233 app.needs_redraw = true;
1234 sync_workflow_history_card(app, &run_id);
1235 return;
1236 }
1237
1238 // A summary/phases snapshot hydrates the whole run.
1239 if let Some(mut panel) =
1240 crate::tui::widgets::workflow_panel::WorkflowPanel::from_run_json(value)
1241 {
1242 panel.locale = app.ui_locale;
1243 match app.workflow_run_mut(&run_id) {
1244 Some(existing) => {
1245 panel.finish_announced = existing.finish_announced;
1246 *existing = panel;
1247 }
1248 None => app.push_workflow_run(panel),
1249 }
1250 app.needs_redraw = true;
1251 sync_workflow_history_card(app, &run_id);
1252 return;
1253 }
1254
1255 // Fallback: bare run record without events — at least surface its state.
1256 use crate::tui::widgets::workflow_panel::{WorkflowPanelEvent, WorkflowPanelLifecycle};
1257 let status = value
1258 .get("status")
1259 .and_then(|v| v.as_str())
1260 .unwrap_or("running");
1261 if app.workflow_run(&run_id).is_none() {
1262 app.apply_workflow_panel_event(
1263 &run_id,
1264 WorkflowPanelEvent::RunStarted {
1265 run_id: run_id.clone(),
1266 workflow_id: value
1267 .get("workflow_id")
1268 .and_then(|v| v.as_str())
1269 .map(str::to_string),
1270 workflow_goal: Some(label),
1271 source_path: value
1272 .get("source_path")
1273 .and_then(|v| v.as_str())
1274 .map(PathBuf::from),
1275 token_budget: value.get("token_budget").and_then(|v| v.as_u64()),
1276 at_ms,
1277 },
1278 );
1279 }
1280 let life = match status {
1281 "completed" | "succeeded" => WorkflowPanelLifecycle::Succeeded,
1282 "degraded" => WorkflowPanelLifecycle::Degraded,
1283 "failed" => WorkflowPanelLifecycle::Failed,
1284 "cancelled" | "canceled" => WorkflowPanelLifecycle::Cancelled,
1285 _ => WorkflowPanelLifecycle::Running,
1286 };
1287 if life != WorkflowPanelLifecycle::Running
1288 && app
1289 .workflow_run(&run_id)
1290 .is_some_and(|run| !run.lifecycle.is_terminal())
1291 {
1292 app.apply_workflow_panel_event(
1293 &run_id,
1294 WorkflowPanelEvent::RunCompleted {
1295 status: life,
1296 error: value
1297 .get("error")
1298 .and_then(|v| v.as_str())
1299 .map(str::to_string),
1300 at_ms: value
1301 .get("completed_at_ms")
1302 .and_then(|v| v.as_u64())
1303 .unwrap_or(at_ms),
1304 },
1305 );
1306 }
1307 sync_workflow_history_card(app, &run_id);
1308 }
1309
1310 /// Apply one live `WorkflowUi` engine event to its run.
1311 ///
1312 /// Progress is applied to state at once but costs nothing else: only a run's
1313 /// start and end touch the transcript, so a 75-agent fan-out's stream of
1314 /// task and budget events never rescans history or reserializes a card.
1315 pub(super) fn apply_workflow_ui_event(app: &mut App, run_id: &str, event: &serde_json::Value) {
1316 use crate::tui::widgets::workflow_panel::WorkflowPanelEvent;
1317
1318 let mut event = event.clone();
1319 if let Some(obj) = event.as_object_mut() {
1320 // The engine envelope owns route identity. An embedded stale id must
1321 // not move this event onto another run's state.
1322 obj.insert(
1323 "run_id".to_string(),
1324 serde_json::Value::String(run_id.to_string()),
1325 );
1326 }
1327 let event_type = event
1328 .get("type")
1329 .and_then(|v| v.as_str())
1330 .unwrap_or_default()
1331 .to_string();
1332 // The engine names what the run returned; keep it for the finish line.
1333 // Set before the event applies, so the line written on this very event
1334 // already carries it.
1335 if event_type == "run_completed"
1336 && let Some(preview) = event
1337 .get("result_preview")
1338 .and_then(|v| v.as_str())
1339 .map(str::to_string)
1340 && let Some(run) = app.workflow_run_mut(run_id)
1341 {
1342 run.result_summary = Some(preview);
1343 }
1344 let Some(panel_event) = WorkflowPanelEvent::from_json_value(&event) else {
1345 return;
1346 };
1347 if !app.apply_workflow_panel_event(run_id, panel_event) {
1348 return;
1349 }
1350 if event_type == "run_started" {
1351 sync_workflow_history_card(app, run_id);
1352 }
1353 }
1354
1355 /// Apply a live workflow event only when its immutable owner is the active
1356 /// conversation. This check deliberately sits in the mutation helper so every
1357 /// caller fails closed before touching the run or transcript history.
1358 pub(super) fn apply_owned_workflow_ui_event(
1359 app: &mut App,
1360 owner_session_id: &str,
1361 run_id: &str,
1362 event: &serde_json::Value,
1363 ) -> bool {
1364 if app.current_session_id.as_deref() != Some(owner_session_id) {
1365 return false;
1366 }
1367 apply_workflow_ui_event(app, run_id, event);
1368 true
1369 }
1370
1371 /// A foreground `run` card has no output until the tool returns, but its
1372 /// start line names the run. Give the newest still-running `workflow` card —
1373 /// the one this run belongs to, or one that has no run yet — the run's
1374 /// snapshot. A card whose tool has returned keeps its own output.
1375 fn sync_workflow_history_card(app: &mut App, run_id: &str) {
1376 let Some(snapshot) = app
1377 .workflow_run(run_id)
1378 .map(|panel| panel.to_run_json().to_string())
1379 else {
1380 return;
1381 };
1382 let history_len = app.history.len();
1383 let total = history_len
1384 + app
1385 .active_cell
1386 .as_ref()
1387 .map(|a| a.entries().len())
1388 .unwrap_or(0);
1389 let mut target = None;
1390 for idx in (0..total).rev() {
1391 let Some(HistoryCell::Tool(ToolCell::Generic(generic))) = app.cell_at_virtual_index(idx)
1392 else {
1393 continue;
1394 };
1395 if generic.name != "workflow" || generic.status != ToolStatus::Running {
1396 continue;
1397 }
1398 let card_run = generic
1399 .output
1400 .as_deref()
1401 .and_then(|out| serde_json::from_str::<serde_json::Value>(out).ok())
1402 .and_then(|v| {
1403 v.get("run_id")
1404 .and_then(|id| id.as_str())
1405 .map(str::to_string)
1406 });
1407 match card_run.as_deref() {
1408 Some(id) if id == run_id => {
1409 target = Some(idx);
1410 break;
1411 }
1412 None if target.is_none() => target = Some(idx),
1413 _ => {}
1414 }
1415 }
1416 let Some(idx) = target else {
1417 return;
1418 };
1419 if let Some(HistoryCell::Tool(ToolCell::Generic(generic))) = app.cell_at_virtual_index_mut(idx)
1420 {
1421 generic.output = Some(snapshot);
1422 generic.output_summary = Some(format!("workflow {run_id}"));
1423 app.mark_history_updated();
1424 }
1425 }
1426
1427 fn refresh_active_tool_completion_timestamp(app: &mut App, cell_index: usize) {
1428 if cell_index < app.history.len() {
1429 return;
1430 }
1431 let entry_idx = cell_index - app.history.len();
1432 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
1433 app.active_tool_entry_completed_at.remove(&entry_idx);
1434 return;
1435 };
1436
1437 if history_cell_has_running_tool(cell) {
1438 app.active_tool_entry_completed_at.remove(&entry_idx);
1439 } else {
1440 app.active_tool_entry_completed_at
1441 .entry(entry_idx)
1442 .or_insert_with(Instant::now);
1443 }
1444 }
1445
1446 fn history_cell_has_running_tool(cell: &HistoryCell) -> bool {
1447 let HistoryCell::Tool(tool) = cell else {
1448 return false;
1449 };
1450 match tool {
1451 ToolCell::Exec(exec) => exec.status == ToolStatus::Running,
1452 ToolCell::Exploring(explore) => explore
1453 .entries
1454 .iter()
1455 .any(|entry| entry.status == ToolStatus::Running),
1456 ToolCell::PlanUpdate(plan) => plan.status == ToolStatus::Running,
1457 ToolCell::PatchSummary(patch) => patch.status == ToolStatus::Running,
1458 ToolCell::Review(review) => review.status == ToolStatus::Running,
1459 ToolCell::Mcp(mcp) => mcp.status == ToolStatus::Running,
1460 ToolCell::ViewImage(_) => false,
1461 ToolCell::WebSearch(search) => search.status == ToolStatus::Running,
1462 ToolCell::Generic(generic) => generic.status == ToolStatus::Running,
1463 }
1464 }
1465
1466 /// Build a finalized standalone history cell for a tool completion whose
1467 /// start was never registered (orphan). This preserves the contract that
1468 /// every tool result is visible somewhere; the alternative (silently
1469 /// dropping it) hides errors and breaks debuggability.
1470 ///
1471 /// Choice of cell type: success-only mutation metadata is sufficient to
1472 /// reconstruct a structured File receipt; other orphans stay generic because
1473 /// no input payload remains. The pager remains usable in both cases because
1474 /// `tool_details_by_cell` is populated with the result text.
1475 ///
1476 /// ## Index drift
1477 ///
1478 /// If an active cell is in flight when the orphan arrives, pushing the
1479 /// orphan into `app.history` shifts every active-cell virtual index forward
1480 /// by 1. We must rewrite `tool_cells` / `exploring_entries` accordingly so
1481 /// later completion lookups still find the right entries.
1482 fn push_orphan_tool_completion(
1483 app: &mut App,
1484 tool_id: &str,
1485 name: &str,
1486 result: &Result<ToolResult, ToolError>,
1487 ) {
1488 let status = tool_status_from_result(result);
1489 let output = match result.as_ref() {
1490 Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)),
1491 Err(err) => Some(err.to_string()),
1492 };
1493 let spillover_path = result
1494 .as_ref()
1495 .ok()
1496 .and_then(|r| r.metadata.as_ref())
1497 .and_then(|m| m.get("spillover_path"))
1498 .and_then(serde_json::Value::as_str)
1499 .map(std::path::PathBuf::from);
1500 let output_summary = output.as_deref().map(summarize_tool_output);
1501 let is_diff = output.as_deref().is_some_and(output_looks_like_diff);
1502 let mutation_receipt = result.as_ref().ok().and_then(|tool_result| {
1503 crate::tui::history::FileMutationReceipt::from_success(&app.workspace, tool_result)
1504 });
1505 let cell = if let Some(receipt) = mutation_receipt {
1506 let path = receipt
1507 .files
1508 .first()
1509 .map_or_else(|| "<file>".to_string(), |file| file.path.clone());
1510 let summary = receipt.semantic_summary();
1511 HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell {
1512 path,
1513 summary,
1514 status,
1515 error: None,
1516 receipt: Some(receipt),
1517 }))
1518 } else {
1519 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1520 name: name.to_string(),
1521 status,
1522 input_summary: None,
1523 output,
1524 prompts: None,
1525 spillover_path,
1526 output_summary,
1527 is_diff,
1528 }))
1529 };
1530 app.add_message(cell);
1531 let cell_index = app.history.len().saturating_sub(1);
1532 app.tool_details_by_cell.insert(
1533 cell_index,
1534 ToolDetailRecord {
1535 tool_id: tool_id.to_string(),
1536 tool_name: name.to_string(),
1537 input: serde_json::Value::Null,
1538 output: match result.as_ref() {
1539 Ok(tool_result) => Some(tool_result.content.clone()),
1540 Err(err) => Some(err.to_string()),
1541 },
1542 },
1543 );
1544
1545 // The virtual-index rebase this path used to do inline now lives in
1546 // `App::add_message`, so every mid-turn history insert gets it — not just
1547 // orphan completions. That gap was #5478: `/rename`'s note shifted the
1548 // indices with nothing to re-base them.
1549 }
1550
1551 fn tool_status_from_result(result: &Result<ToolResult, ToolError>) -> ToolStatus {
1552 match result.as_ref() {
1553 Ok(tool_result) if is_deferred_schema_hydration(tool_result) => ToolStatus::Hydrated,
1554 Ok(tool_result) => match tool_result.metadata.as_ref() {
1555 Some(meta)
1556 if meta
1557 .get("status")
1558 .and_then(|v| v.as_str())
1559 .is_some_and(|s| s == "Running") =>
1560 {
1561 ToolStatus::Running
1562 }
1563 _ => {
1564 if tool_result.success {
1565 ToolStatus::Success
1566 } else {
1567 ToolStatus::Failed
1568 }
1569 }
1570 },
1571 Err(_) => ToolStatus::Failed,
1572 }
1573 }
1574
1575 fn is_deferred_schema_hydration(tool_result: &ToolResult) -> bool {
1576 if !tool_result.success {
1577 return false;
1578 }
1579 let Some(metadata) = tool_result.metadata.as_ref() else {
1580 return false;
1581 };
1582 metadata
1583 .get("event")
1584 .and_then(serde_json::Value::as_str)
1585 .is_some_and(|event| event == "tool.schema_hydrated")
1586 && metadata
1587 .get("executed")
1588 .and_then(serde_json::Value::as_bool)
1589 .is_some_and(|executed| !executed)
1590 }
1591
1592 fn is_exploring_tool(name: &str) -> bool {
1593 matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files")
1594 }
1595
1596 fn is_exec_tool(name: &str) -> bool {
1597 matches!(
1598 name,
1599 "exec_shell"
1600 | "exec_shell_wait"
1601 | "exec_shell_interact"
1602 | "exec_shell_cancel"
1603 | "exec_wait"
1604 | "exec_interact"
1605 )
1606 }
1607
1608 pub(super) fn refreshes_workspace_context_on_completion(name: &str) -> bool {
1609 matches!(
1610 name,
1611 "exec_shell"
1612 | "exec_shell_wait"
1613 | "exec_shell_interact"
1614 | "exec_shell_cancel"
1615 | "exec_wait"
1616 | "exec_interact"
1617 | "task_shell_start"
1618 | "task_shell_wait"
1619 | "write_file"
1620 | "edit_file"
1621 | "apply_patch"
1622 )
1623 }
1624
1625 pub(super) fn exploring_label(name: &str, input: &serde_json::Value) -> String {
1626 let fallback = format!("{name} tool");
1627 let obj = input.as_object();
1628 match name {
1629 "read_file" => obj
1630 .and_then(|o| o.get("path"))
1631 .and_then(|v| v.as_str())
1632 .map_or(fallback, |path| format!("Reading {path}")),
1633 "list_dir" => obj
1634 .and_then(|o| o.get("path"))
1635 .and_then(|v| v.as_str())
1636 .map_or("Listing directory".to_string(), |path| {
1637 format!("Listing {path}")
1638 }),
1639 "grep_files" => {
1640 let pattern = obj
1641 .and_then(|o| o.get("pattern"))
1642 .and_then(|v| v.as_str())
1643 .unwrap_or("pattern");
1644 format!("Searching for `{pattern}`")
1645 }
1646 "list_files" => "Listing files".to_string(),
1647 _ => fallback,
1648 }
1649 }
1650
1651 fn is_mcp_tool(name: &str) -> bool {
1652 name.starts_with("mcp_")
1653 }
1654
1655 fn is_view_image_tool(name: &str) -> bool {
1656 matches!(name, "view_image" | "view_image_file" | "view_image_tool")
1657 }
1658
1659 fn is_web_search_tool(name: &str) -> bool {
1660 matches!(name, "web_search" | "search_web" | "search" | "web.run")
1661 || name.ends_with("_web_search")
1662 }
1663
1664 fn web_search_query(input: &serde_json::Value) -> String {
1665 if let Some(searches) = input.get("search_query").and_then(|v| v.as_array())
1666 && let Some(first) = searches.first()
1667 && let Some(q) = first.get("q").and_then(|v| v.as_str())
1668 {
1669 return q.to_string();
1670 }
1671
1672 input
1673 .get("query")
1674 .or_else(|| input.get("q"))
1675 .or_else(|| input.get("search"))
1676 .and_then(|v| v.as_str())
1677 .unwrap_or("Web search")
1678 .to_string()
1679 }
1680
1681 fn review_target_label(input: &serde_json::Value) -> String {
1682 let target = input
1683 .get("target")
1684 .and_then(|v| v.as_str())
1685 .unwrap_or("review")
1686 .trim();
1687 let kind = input
1688 .get("kind")
1689 .and_then(|v| v.as_str())
1690 .unwrap_or("")
1691 .trim()
1692 .to_ascii_lowercase();
1693 let staged = input
1694 .get("staged")
1695 .and_then(|v| v.as_bool())
1696 .unwrap_or(false);
1697 let target_lower = target.to_ascii_lowercase();
1698
1699 if kind == "diff"
1700 || target_lower == "diff"
1701 || target_lower == "git diff"
1702 || target_lower == "staged"
1703 || target_lower == "cached"
1704 {
1705 if staged || target_lower == "staged" || target_lower == "cached" {
1706 return "git diff --cached".to_string();
1707 }
1708 return "git diff".to_string();
1709 }
1710
1711 target.to_string()
1712 }
1713
1714 fn parse_plan_input(input: &serde_json::Value) -> PlanSnapshot {
1715 PlanSnapshot::from_tool_input(input)
1716 }
1717
1718 fn parse_file_mutation_summary(semantic_name: &str, input: &serde_json::Value) -> (String, String) {
1719 if semantic_name != "apply_patch" {
1720 let path = input
1721 .get("path")
1722 .and_then(serde_json::Value::as_str)
1723 .filter(|path| !path.trim().is_empty())
1724 .unwrap_or("<file>")
1725 .to_string();
1726 let summary = match semantic_name {
1727 "write_file" => "Writing file",
1728 "edit_file" => "Editing file",
1729 _ => "Changing file",
1730 }
1731 .to_string();
1732 return (path, summary);
1733 }
1734 let patch_text = match normalize_apply_patch_input(input) {
1735 Ok(NormalizedApplyPatchInput::Replacement {
1736 entries: changes, ..
1737 }) => {
1738 let count = changes.len();
1739 let path = changes
1740 .first()
1741 .and_then(|c| c.get("path"))
1742 .and_then(|v| v.as_str())
1743 .map(str::to_string)
1744 .unwrap_or_else(|| "<file>".to_string());
1745 let label = if count <= 1 {
1746 path
1747 } else {
1748 format!("{count} files")
1749 };
1750 let summary = format!("Changes: {count} file(s)");
1751 return (label, summary);
1752 }
1753 Ok(NormalizedApplyPatchInput::Patch(patch)) => patch,
1754 Err(_) => "",
1755 };
1756 let paths = extract_patch_paths(patch_text);
1757 let path = input
1758 .get("path")
1759 .and_then(|v| v.as_str())
1760 .map(str::to_string)
1761 .or_else(|| {
1762 if paths.len() == 1 {
1763 paths.first().cloned()
1764 } else if paths.is_empty() {
1765 None
1766 } else {
1767 Some(format!("{} files", paths.len()))
1768 }
1769 })
1770 .unwrap_or_else(|| "<file>".to_string());
1771
1772 let (adds, removes) = count_patch_changes(patch_text);
1773 let summary = if adds == 0 && removes == 0 {
1774 "Patch applied".to_string()
1775 } else {
1776 format!("Changes: +{adds} / -{removes}")
1777 };
1778 (path, summary)
1779 }
1780
1781 fn extract_patch_paths(patch: &str) -> Vec<String> {
1782 let mut paths = Vec::new();
1783 for line in patch.lines() {
1784 if let Some(rest) = line.strip_prefix("+++ ") {
1785 let raw = rest.trim();
1786 if raw == "/dev/null" || raw == "dev/null" {
1787 continue;
1788 }
1789 let raw = raw.strip_prefix("b/").unwrap_or(raw);
1790 if !paths.contains(&raw.to_string()) {
1791 paths.push(raw.to_string());
1792 }
1793 } else if let Some(rest) = line.strip_prefix("diff --git ") {
1794 let parts: Vec<&str> = rest.split_whitespace().collect();
1795 if let Some(path) = parts.get(1).or_else(|| parts.first()) {
1796 let raw = path.trim();
1797 let raw = raw
1798 .strip_prefix("b/")
1799 .or_else(|| raw.strip_prefix("a/"))
1800 .unwrap_or(raw);
1801 if !paths.contains(&raw.to_string()) {
1802 paths.push(raw.to_string());
1803 }
1804 }
1805 }
1806 }
1807 paths
1808 }
1809
1810 fn count_patch_changes(patch: &str) -> (usize, usize) {
1811 let mut adds = 0;
1812 let mut removes = 0;
1813 for line in patch.lines() {
1814 if line.starts_with("+++") || line.starts_with("---") {
1815 continue;
1816 }
1817 if line.starts_with('+') {
1818 adds += 1;
1819 } else if line.starts_with('-') {
1820 removes += 1;
1821 }
1822 }
1823 (adds, removes)
1824 }
1825
1826 fn exec_command_from_input(input: &serde_json::Value) -> Option<String> {
1827 input
1828 .get("command")
1829 .and_then(|v| v.as_str())
1830 .map(std::string::ToString::to_string)
1831 }
1832
1833 fn exec_target_from_input(input: &serde_json::Value) -> String {
1834 exec_command_from_input(input).unwrap_or_else(|| {
1835 input
1836 .get("task_id")
1837 .or_else(|| input.get("id"))
1838 .and_then(|v| v.as_str())
1839 .map(|task_id| format!("command {task_id}"))
1840 .unwrap_or_else(|| "command".to_string())
1841 })
1842 }
1843
1844 fn exec_source_from_input(input: &serde_json::Value) -> ExecSource {
1845 match input.get("source").and_then(|v| v.as_str()) {
1846 Some(source) if source.eq_ignore_ascii_case("user") => ExecSource::User,
1847 _ => ExecSource::Assistant,
1848 }
1849 }
1850
1851 fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(String, bool)> {
1852 let command = exec_target_from_input(input);
1853 let command_display = format!("\"{command}\"");
1854 let interaction_input = input
1855 .get("input")
1856 .or_else(|| input.get("stdin"))
1857 .or_else(|| input.get("data"))
1858 .and_then(|v| v.as_str());
1859
1860 let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait");
1861 let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact");
1862 let is_cancel_tool = name == "exec_shell_cancel";
1863
1864 if is_cancel_tool {
1865 let summary = if input.get("all").and_then(serde_json::Value::as_bool) == Some(true) {
1866 "Cancelled all background commands".to_string()
1867 } else if let Some(task_id) = input
1868 .get("task_id")
1869 .or_else(|| input.get("id"))
1870 .and_then(serde_json::Value::as_str)
1871 {
1872 format!("Cancelled command {task_id}")
1873 } else {
1874 "Cancelled background command".to_string()
1875 };
1876 return Some((summary, false));
1877 }
1878
1879 if is_interact_tool || interaction_input.is_some() {
1880 let preview = interaction_input.map(summarize_interaction_input);
1881 let summary = if let Some(preview) = preview {
1882 format!("Interacted with {command_display}, sent {preview}")
1883 } else {
1884 format!("Interacted with {command_display}")
1885 };
1886 return Some((summary, false));
1887 }
1888
1889 if is_wait_tool || input.get("wait").and_then(serde_json::Value::as_bool) == Some(true) {
1890 if exec_command_from_input(input).is_none()
1891 && let Some(task_id) = input
1892 .get("task_id")
1893 .or_else(|| input.get("id"))
1894 .and_then(|v| v.as_str())
1895 {
1896 return Some((format!("Waiting for command {task_id}"), true));
1897 }
1898 return Some((format!("Waited for {command_display}"), true));
1899 }
1900
1901 None
1902 }
1903
1904 fn summarize_interaction_input(input: &str) -> String {
1905 let mut single_line = input.replace('\r', "");
1906 single_line = single_line.replace('\n', "\\n");
1907 single_line = single_line.replace('\"', "'");
1908 let max_len = 80;
1909 if single_line.chars().count() <= max_len {
1910 return format!("\"{single_line}\"");
1911 }
1912 let mut out = String::new();
1913 for ch in single_line.chars().take(max_len.saturating_sub(3)) {
1914 out.push(ch);
1915 }
1916 out.push_str("...");
1917 format!("\"{out}\"")
1918 }
1919
1920 fn exec_is_background(input: &serde_json::Value) -> bool {
1921 input
1922 .get("background")
1923 .and_then(serde_json::Value::as_bool)
1924 .unwrap_or(false)
1925 }
1926
1927 #[cfg(test)]
1928 mod tests {
1929 use super::*;
1930 use crate::tools::plan::StepStatus;
1931 use serde_json::json;
1932
1933 #[test]
1934 fn concurrent_runs_keep_their_own_events() {
1935 let mut app = crate::test_support::test_app_with_options(
1936 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
1937 );
1938 apply_workflow_ui_event(
1939 &mut app,
1940 "run-a",
1941 &json!({
1942 "type": "run_started",
1943 "workflow_goal": "first run",
1944 "at_ms": 1_000,
1945 }),
1946 );
1947 apply_workflow_ui_event(
1948 &mut app,
1949 "run-b",
1950 &json!({
1951 "type": "run_started",
1952 "workflow_goal": "second run",
1953 "at_ms": 2_000,
1954 }),
1955 );
1956 apply_workflow_ui_event(
1957 &mut app,
1958 "run-b",
1959 &json!({"type": "phase_started", "title": "Build", "at_ms": 2_100}),
1960 );
1961 let before = app.workflow_run("run-b").expect("run B").to_run_json();
1962
1963 // Both runs are rows of their own; a delayed start for A touches A.
1964 apply_workflow_ui_event(
1965 &mut app,
1966 "run-a",
1967 &json!({
1968 "type": "run_started",
1969 "workflow_goal": "delayed first run",
1970 "at_ms": 1_500,
1971 }),
1972 );
1973 // The immutable envelope says A even if a malformed embedded field
1974 // claims B. Neither this failure nor A's terminal event belongs to B.
1975 apply_workflow_ui_event(
1976 &mut app,
1977 "run-a",
1978 &json!({
1979 "type": "task_dispatch_failed",
1980 "run_id": "run-b",
1981 "label": "late task",
1982 "message": "late A failure",
1983 "at_ms": 2_200,
1984 }),
1985 );
1986 apply_workflow_ui_event(
1987 &mut app,
1988 "run-a",
1989 &json!({
1990 "type": "run_completed",
1991 "status": "failed",
1992 "error": "late A completion",
1993 "at_ms": 2_300,
1994 }),
1995 );
1996
1997 assert_eq!(app.workflow_runs.len(), 2, "two runs, two rows");
1998 assert_eq!(
1999 app.workflow_run("run-b").expect("run B").to_run_json(),
2000 before
2001 );
2002 let run_a = app.workflow_run("run-a").expect("run A");
2003 assert_eq!(
2004 run_a.lifecycle,
2005 crate::tui::widgets::workflow_panel::WorkflowPanelLifecycle::Failed
2006 );
2007 assert_eq!(run_a.dispatch_failure_count, 1);
2008 }
2009
2010 #[test]
2011 fn a_completion_replay_for_another_run_never_touches_this_one() {
2012 let mut app = crate::test_support::test_app_with_options(
2013 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2014 );
2015 apply_workflow_ui_event(
2016 &mut app,
2017 "run-b",
2018 &json!({
2019 "type": "run_started",
2020 "workflow_goal": "active run",
2021 "at_ms": 2_000,
2022 }),
2023 );
2024 apply_workflow_ui_event(
2025 &mut app,
2026 "run-b",
2027 &json!({"type": "phase_started", "title": "Verify", "at_ms": 2_100}),
2028 );
2029 let before = app.workflow_run("run-b").expect("run B").to_run_json();
2030
2031 // A retained completion tail can contain run_started. The top-level
2032 // run identity keeps the whole replay on run A.
2033 apply_workflow_output_to_panel(
2034 &mut app,
2035 &json!({
2036 "run_id": "run-a",
2037 "workflow_goal": "prior run",
2038 "started_at_ms": 1_000,
2039 "completed_at_ms": 2_200,
2040 "status": "failed",
2041 "events": [
2042 {
2043 "type": "run_started",
2044 "run_id": "run-a",
2045 "workflow_goal": "prior run",
2046 "at_ms": 1_000,
2047 },
2048 {
2049 "type": "task_dispatch_failed",
2050 "run_id": "run-a",
2051 "message": "prior failure",
2052 "at_ms": 1_100,
2053 },
2054 {
2055 "type": "run_completed",
2056 "run_id": "run-a",
2057 "status": "failed",
2058 "at_ms": 2_200,
2059 }
2060 ],
2061 "dispatch_failure_count": 1,
2062 "dispatch_failures": [{
2063 "message": "prior failure",
2064 "at_ms": 1_100,
2065 }],
2066 })
2067 .to_string(),
2068 );
2069
2070 assert_eq!(
2071 app.workflow_run("run-b").expect("run B").to_run_json(),
2072 before
2073 );
2074 assert!(app.workflow_run("run-a").is_some());
2075 }
2076
2077 #[test]
2078 fn workflow_completion_replay_uses_authoritative_dispatch_failure_ledger() {
2079 let mut app = crate::test_support::test_app_with_options(
2080 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2081 );
2082 let failure = json!({
2083 "type": "task_dispatch_failed",
2084 "label": "review docs",
2085 "phase": "Analyze",
2086 "message": "profile unavailable",
2087 "at_ms": 1_250,
2088 });
2089 apply_workflow_ui_event(
2090 &mut app,
2091 "run-1",
2092 &json!({
2093 "type": "run_started",
2094 "workflow_goal": "audit",
2095 "at_ms": 1_000,
2096 }),
2097 );
2098 apply_workflow_ui_event(&mut app, "run-1", &failure);
2099 assert_eq!(
2100 app.workflow_run("run-1")
2101 .expect("live run")
2102 .dispatch_failure_count,
2103 1
2104 );
2105
2106 // A long run's retained tail may no longer include run_started, so
2107 // this event is a replay of the live failure rather than a new slot.
2108 apply_workflow_output_to_panel(
2109 &mut app,
2110 &json!({
2111 "run_id": "run-1",
2112 "workflow_goal": "audit",
2113 "started_at_ms": 1_000,
2114 "events": [failure],
2115 "dispatch_failure_count": 1,
2116 "dispatch_failures": [{
2117 "label": "review docs",
2118 "phase": "Analyze",
2119 "message": "profile unavailable",
2120 "at_ms": 1_250,
2121 }],
2122 })
2123 .to_string(),
2124 );
2125
2126 let panel = app.workflow_run("run-1").expect("completed run");
2127 assert_eq!(panel.dispatch_failure_count, 1);
2128 assert_eq!(panel.dispatch_failures.len(), 1);
2129 assert_eq!(panel.failure_cancel_counts(), (1, 0));
2130 }
2131
2132 #[test]
2133 fn degraded_run_writes_one_warning_finish_line_after_its_start_card() {
2134 let mut app = crate::test_support::test_app_with_options(
2135 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2136 );
2137 app.history
2138 .push(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2139 name: "workflow".to_string(),
2140 status: ToolStatus::Running,
2141 input_summary: Some("action: run".to_string()),
2142 output: None,
2143 prompts: None,
2144 spillover_path: None,
2145 output_summary: None,
2146 is_diff: false,
2147 })));
2148
2149 apply_workflow_output_to_panel(
2150 &mut app,
2151 &json!({
2152 "run_id": "run-partial",
2153 "workflow_goal": "audit",
2154 "status": "degraded",
2155 "started_at_ms": 1_000,
2156 "completed_at_ms": 2_000,
2157 "dispatch_failure_count": 1,
2158 "dispatch_failures": [{
2159 "label": "review docs",
2160 "message": "profile unavailable",
2161 "at_ms": 1_500,
2162 }],
2163 })
2164 .to_string(),
2165 );
2166
2167 let HistoryCell::Tool(ToolCell::Generic(receipt)) = app.history.last().expect("receipt")
2168 else {
2169 panic!("the finish line is a workflow card")
2170 };
2171 assert_eq!(receipt.status, ToolStatus::Warning);
2172 let output: serde_json::Value =
2173 serde_json::from_str(receipt.output.as_deref().expect("snapshot")).expect("json");
2174 assert_eq!(output["transcript_line"], "finished");
2175 assert_eq!(output["run_id"], "run-partial");
2176 assert!(!history_cell_has_running_tool(
2177 app.history.last().expect("receipt")
2178 ));
2179
2180 // The live stream delivering the same terminal event later does not
2181 // write the line again.
2182 let cells = app.history.len();
2183 apply_workflow_ui_event(
2184 &mut app,
2185 "run-partial",
2186 &json!({"type": "run_completed", "status": "degraded", "at_ms": 2_000}),
2187 );
2188 assert_eq!(app.history.len(), cells);
2189 }
2190
2191 #[test]
2192 fn a_foreground_run_card_carries_its_own_finish_line() {
2193 let mut app = crate::test_support::test_app_with_options(
2194 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2195 );
2196 let mut active = crate::tui::active_cell::ActiveCell::new();
2197 active.push_untracked(HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2198 name: "workflow".to_string(),
2199 status: ToolStatus::Running,
2200 input_summary: None,
2201 output: None,
2202 prompts: None,
2203 spillover_path: None,
2204 output_summary: None,
2205 is_diff: false,
2206 })));
2207 app.active_cell = Some(active);
2208 apply_workflow_ui_event(
2209 &mut app,
2210 "run-fg",
2211 &json!({"type": "run_started", "workflow_goal": "survey", "at_ms": 1_000}),
2212 );
2213 apply_workflow_ui_event(
2214 &mut app,
2215 "run-fg",
2216 &json!({"type": "run_completed", "status": "completed", "result_preview": "3 findings", "at_ms": 5_000}),
2217 );
2218 // The tool returns the settled record into its own card.
2219 if let Some(HistoryCell::Tool(ToolCell::Generic(card))) = app
2220 .active_cell
2221 .as_mut()
2222 .and_then(|active| active.entry_mut(0))
2223 {
2224 card.status = ToolStatus::Success;
2225 card.output = Some(
2226 json!({
2227 "run_id": "run-fg",
2228 "workflow_goal": "survey",
2229 "status": "completed",
2230 "started_at_ms": 1_000,
2231 "completed_at_ms": 5_000,
2232 "result": {"summary": "3 findings"},
2233 })
2234 .to_string(),
2235 );
2236 }
2237 app.flush_active_cell();
2238 assert_eq!(
2239 app.history.len(),
2240 1,
2241 "the card owns the finish; no second line"
2242 );
2243 let rendered = app.history[0]
2244 .lines(100)
2245 .iter()
2246 .map(|line| {
2247 line.spans
2248 .iter()
2249 .map(|s| s.content.as_ref())
2250 .collect::<String>()
2251 })
2252 .collect::<Vec<_>>()
2253 .join("\n");
2254 // The settled record replaces `started`: one row for the run.
2255 assert!(!rendered.contains("started"), "{rendered}");
2256 assert!(rendered.contains("finished"), "{rendered}");
2257 assert!(rendered.contains("3 findings"), "{rendered}");
2258 }
2259
2260 #[test]
2261 fn a_detached_finish_waits_for_its_start_card_to_leave_the_active_group() {
2262 let mut app = crate::test_support::test_app_with_options(
2263 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2264 );
2265 let start_card = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2266 name: "workflow".to_string(),
2267 status: ToolStatus::Success,
2268 input_summary: None,
2269 output: Some(
2270 json!({"run_id": "run-fast", "workflow_goal": "quick", "status": "running"})
2271 .to_string(),
2272 ),
2273 prompts: None,
2274 spillover_path: None,
2275 output_summary: None,
2276 is_diff: false,
2277 }));
2278 let mut active = crate::tui::active_cell::ActiveCell::new();
2279 active.push_untracked(start_card);
2280 app.active_cell = Some(active);
2281 for event in [
2282 json!({"type": "run_started", "workflow_goal": "quick", "at_ms": 1_000}),
2283 json!({"type": "run_completed", "status": "failed", "error": "script error, line 3", "at_ms": 1_400}),
2284 ] {
2285 apply_workflow_ui_event(&mut app, "run-fast", &event);
2286 }
2287 assert!(
2288 app.history.is_empty(),
2289 "the finish line must not jump above its start"
2290 );
2291
2292 app.flush_active_cell();
2293 // One row per run: the start card becomes the finish, in place.
2294 assert_eq!(app.history.len(), 1, "the start card is the finish line");
2295 let HistoryCell::Tool(ToolCell::Generic(finish)) = &app.history[0] else {
2296 panic!("finish line is a workflow card");
2297 };
2298 assert_eq!(finish.status, ToolStatus::Failed);
2299 let rendered = app.history[0]
2300 .lines(100)
2301 .iter()
2302 .map(|line| {
2303 line.spans
2304 .iter()
2305 .map(|s| s.content.as_ref())
2306 .collect::<String>()
2307 })
2308 .collect::<Vec<_>>()
2309 .join("\n");
2310 assert!(rendered.contains("failed"), "{rendered}");
2311 assert!(rendered.contains("quick"), "{rendered}");
2312 assert!(rendered.contains("script error, line 3"), "{rendered}");
2313 assert!(!rendered.contains("started"), "{rendered}");
2314
2315 // The live stream repeating the terminal event writes nothing more.
2316 apply_workflow_ui_event(
2317 &mut app,
2318 "run-fast",
2319 &json!({"type": "run_completed", "status": "failed", "error": "script error, line 3", "at_ms": 1_400}),
2320 );
2321 assert_eq!(app.history.len(), 1);
2322 }
2323
2324 fn workflow_card(record: serde_json::Value) -> HistoryCell {
2325 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
2326 name: "workflow".to_string(),
2327 status: ToolStatus::Success,
2328 input_summary: None,
2329 output: Some(record.to_string()),
2330 prompts: None,
2331 spillover_path: None,
2332 output_summary: None,
2333 is_diff: false,
2334 }))
2335 }
2336
2337 fn is_finish_card(cell: &HistoryCell) -> bool {
2338 let HistoryCell::Tool(ToolCell::Generic(tool)) = cell else {
2339 return false;
2340 };
2341 tool.output
2342 .as_deref()
2343 .and_then(|out| serde_json::from_str::<serde_json::Value>(out).ok())
2344 .is_some_and(|value| value.get("transcript_line").is_some())
2345 }
2346
2347 fn settle_run(app: &mut App, run_id: &str) {
2348 for event in [
2349 json!({"type": "run_started", "workflow_goal": "quick", "at_ms": 1_000}),
2350 json!({"type": "run_completed", "status": "failed", "error": "script error", "at_ms": 1_355}),
2351 ] {
2352 apply_workflow_ui_event(app, run_id, &event);
2353 }
2354 }
2355
2356 #[test]
2357 fn a_status_poll_that_returned_the_settled_record_is_the_only_finish() {
2358 let mut app = crate::test_support::test_app_with_options(
2359 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2360 );
2361 let mut active = crate::tui::active_cell::ActiveCell::new();
2362 active.push_untracked(workflow_card(
2363 json!({"run_id": "run-x", "workflow_goal": "quick", "status": "running"}),
2364 ));
2365 active.push_untracked(workflow_card(
2366 json!({"run_id": "run-x", "workflow_goal": "quick", "status": "failed"}),
2367 ));
2368 app.active_cell = Some(active);
2369 settle_run(&mut app, "run-x");
2370 app.flush_active_cell();
2371
2372 assert_eq!(app.history.len(), 2, "no extra finish appended");
2373 assert!(
2374 !app.history.iter().any(is_finish_card),
2375 "the poll already shows the finish; the start card must not repeat it"
2376 );
2377 }
2378
2379 #[test]
2380 fn the_finish_rewrites_the_start_card_not_a_later_status_poll() {
2381 let mut app = crate::test_support::test_app_with_options(
2382 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2383 );
2384 let running = json!({"run_id": "run-x", "workflow_goal": "quick", "status": "running"});
2385 app.add_message(workflow_card(running.clone()));
2386 app.add_message(workflow_card(running));
2387 settle_run(&mut app, "run-x");
2388
2389 assert_eq!(app.history.len(), 2);
2390 assert!(is_finish_card(&app.history[0]), "the start card settles");
2391 assert!(!is_finish_card(&app.history[1]), "the poll is left alone");
2392 }
2393
2394 #[test]
2395 fn a_run_that_settles_after_the_conversation_moved_on_finishes_at_the_tail() {
2396 let mut app = crate::test_support::test_app_with_options(
2397 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2398 );
2399 app.add_message(workflow_card(
2400 json!({"run_id": "run-x", "workflow_goal": "quick", "status": "running"}),
2401 ));
2402 app.add_message(HistoryCell::User {
2403 content: "meanwhile, something else".to_string(),
2404 });
2405 settle_run(&mut app, "run-x");
2406
2407 assert_eq!(app.history.len(), 3, "the finish is appended");
2408 assert!(!is_finish_card(&app.history[0]));
2409 assert!(
2410 is_finish_card(&app.history[2]),
2411 "the finish sits at the tail"
2412 );
2413 }
2414
2415 #[cfg(unix)]
2416 fn hook_log_lines_eventually(path: &std::path::Path, expected: usize) -> Vec<String> {
2417 for _ in 0..100 {
2418 let lines = std::fs::read_to_string(path)
2419 .unwrap_or_default()
2420 .lines()
2421 .map(str::to_string)
2422 .collect::<Vec<_>>();
2423 if lines.len() >= expected {
2424 return lines;
2425 }
2426 std::thread::sleep(std::time::Duration::from_millis(10));
2427 }
2428 std::fs::read_to_string(path)
2429 .unwrap_or_default()
2430 .lines()
2431 .map(str::to_string)
2432 .collect()
2433 }
2434
2435 /// A UI-ignored completion is still a completion. `tool_call_after` and
2436 /// `on_error` must fire for it — exactly once — or the documented "fires
2437 /// after each tool call" silently excludes repeated `wait` and background
2438 /// results, which is the class of call an observer most wants to record.
2439 #[cfg(unix)]
2440 #[test]
2441 fn ignored_tool_calls_still_fire_after_and_error_hooks_once() {
2442 use crate::hooks::{Hook, HookEvent, HookExecutor, HooksConfig};
2443
2444 let dir = tempfile::tempdir().expect("tempdir");
2445 let after_log = dir.path().join("after.log");
2446 let error_log = dir.path().join("error.log");
2447 let script = |path: &std::path::Path| {
2448 format!(
2449 "printf '%s\\n' \"$DEEPSEEK_TOOL_CALL_ID\" >> {}",
2450 path.display()
2451 )
2452 };
2453
2454 let mut app = crate::test_support::test_app_with_options(
2455 crate::test_support::test_tui_options(dir.path()),
2456 );
2457 app.workspace = dir.path().to_path_buf();
2458 app.hooks = HookExecutor::new(
2459 HooksConfig {
2460 enabled: true,
2461 hooks: vec![
2462 Hook::new(HookEvent::ToolCallAfter, &script(&after_log)).with_name("after"),
2463 Hook::new(HookEvent::OnError, &script(&error_log)).with_name("error"),
2464 ],
2465 ..HooksConfig::default()
2466 },
2467 dir.path().to_path_buf(),
2468 );
2469
2470 let id = "call_ignored_1";
2471 app.ignored_tool_calls.insert(id.to_string());
2472 let failed: Result<ToolResult, ToolError> = Ok(ToolResult::error("boom"));
2473
2474 handle_tool_call_complete(&mut app, id, "exec_shell", &failed);
2475
2476 // The presentation state still consumed the id...
2477 assert!(!app.ignored_tool_calls.contains(id));
2478 // ...and both observers saw the call, once each.
2479 let after = hook_log_lines_eventually(&after_log, 1);
2480 let errors = hook_log_lines_eventually(&error_log, 1);
2481 assert_eq!(after, vec![id]);
2482 assert_eq!(errors, vec![id]);
2483
2484 // A successful ignored completion fires `tool_call_after` only.
2485 let second = "call_ignored_2";
2486 app.ignored_tool_calls.insert(second.to_string());
2487 handle_tool_call_complete(
2488 &mut app,
2489 second,
2490 "exec_shell",
2491 &Ok(ToolResult::success("ok")),
2492 );
2493 let after = hook_log_lines_eventually(&after_log, 2);
2494 let errors = hook_log_lines_eventually(&error_log, 1);
2495 assert_eq!(after, vec![id, second]);
2496 assert_eq!(errors, vec![id]);
2497 }
2498
2499 /// #6582: hooks see a `bash` command's real exit code and status, for a
2500 /// failing command as well as a passing one. `bash` reports a nonzero
2501 /// exit or a timeout as a `ToolError`, and the hook used to read the code
2502 /// only from a successful result, so every failing command reached
2503 /// `tool_call_after` and `on_error` with no exit code.
2504 #[cfg(unix)]
2505 #[test]
2506 fn bash_completion_hooks_get_exit_code_and_status_for_failures() {
2507 use crate::hooks::{Hook, HookEvent, HookExecutor, HooksConfig};
2508 use crate::tools::spec::{ToolContext, ToolSpec};
2509
2510 let dir = tempfile::tempdir().expect("tempdir");
2511 let after_log = dir.path().join("after.log");
2512 let error_log = dir.path().join("error.log");
2513 let script = |path: &std::path::Path| {
2514 format!(
2515 "printf '%s %s %s %s\\n' \"$DEEPSEEK_TOOL_CALL_ID\" \"${{DEEPSEEK_TOOL_EXIT_CODE-unset}}\" \"${{DEEPSEEK_TOOL_STATUS-unset}}\" \"$DEEPSEEK_TOOL_SUCCESS\" >> {}",
2516 path.display()
2517 )
2518 };
2519 let mut app = crate::test_support::test_app_with_options(
2520 crate::test_support::test_tui_options(dir.path()),
2521 );
2522 app.workspace = dir.path().to_path_buf();
2523 app.hooks = HookExecutor::new(
2524 HooksConfig {
2525 enabled: true,
2526 hooks: vec![
2527 Hook::new(HookEvent::ToolCallAfter, &script(&after_log)),
2528 Hook::new(HookEvent::OnError, &script(&error_log)),
2529 ],
2530 ..HooksConfig::default()
2531 },
2532 dir.path().to_path_buf(),
2533 );
2534
2535 let runtime = tokio::runtime::Runtime::new().expect("runtime");
2536 let context = ToolContext::new(dir.path());
2537 let cases = [
2538 ("call-exit-0", json!({"command": "exit 0"})),
2539 ("call-exit-1", json!({"command": "exit 1"})),
2540 (
2541 "call-exit-127",
2542 json!({"command": "codewhale-no-such-command-6582"}),
2543 ),
2544 (
2545 "call-timeout",
2546 json!({"command": "sleep 5", "timeout": 0.2}),
2547 ),
2548 ];
2549 for (id, input) in cases {
2550 let result =
2551 runtime.block_on(crate::tools::shell::LowercaseBashTool.execute(input, &context));
2552 handle_tool_call_complete(&mut app, id, "bash", &result);
2553 }
2554
2555 let mut after = hook_log_lines_eventually(&after_log, 4);
2556 let mut errors = hook_log_lines_eventually(&error_log, 3);
2557 after.sort();
2558 errors.sort();
2559 assert_eq!(
2560 after,
2561 vec![
2562 "call-exit-0 0 completed true",
2563 "call-exit-1 1 failed false",
2564 "call-exit-127 127 failed false",
2565 "call-timeout unset timed_out false",
2566 ]
2567 );
2568 assert_eq!(
2569 errors,
2570 vec![
2571 "call-exit-1 1 failed false",
2572 "call-exit-127 127 failed false",
2573 "call-timeout unset timed_out false",
2574 ]
2575 );
2576 }
2577
2578 #[test]
2579 fn adaptive_evidence_late_foreign_and_duplicate_completions_are_ignored() {
2580 let result = Ok(ToolResult::success("bounded").with_metadata(json!({
2581 "artifact_session_id": "session-a",
2582 "artifact_id": "art_call-a"
2583 })));
2584 assert!(evidence_completion_identity_should_be_ignored(
2585 Some("session-b"),
2586 std::iter::empty(),
2587 "call-a",
2588 &result,
2589 ));
2590 assert!(evidence_completion_identity_should_be_ignored(
2591 Some("session-a"),
2592 [("art_call-a", "call-a")],
2593 "call-a",
2594 &result,
2595 ));
2596 assert!(!evidence_completion_identity_should_be_ignored(
2597 Some("session-a"),
2598 std::iter::empty(),
2599 "call-a",
2600 &result,
2601 ));
2602 }
2603
2604 #[test]
2605 fn web_search_presentation_reads_source_degradation_and_citation_count() {
2606 let presentation = web_search_presentation(
2607 &json!({
2608 "source": "provider-native/xai/grok-4.5",
2609 "results": [
2610 {"ref_id": "web_a", "url": "https://example.com/a"},
2611 {"ref_id": "web_b", "url": "https://example.com/b"}
2612 ],
2613 "receipt": {
2614 "degraded": [
2615 {"kind": "backend_unavailable", "backend": "provider_native"},
2616 {"kind": "backend_fallback", "from": "provider_native", "to": "tavily"}
2617 ]
2618 }
2619 })
2620 .to_string(),
2621 );
2622
2623 assert_eq!(
2624 presentation.source.as_deref(),
2625 Some("provider-native/xai/grok-4.5")
2626 );
2627 assert_eq!(
2628 presentation.degraded.as_deref(),
2629 Some("provider_native unavailable; provider_native -> tavily")
2630 );
2631 assert_eq!(presentation.ref_count, 2);
2632 }
2633
2634 #[test]
2635 fn web_run_presentation_reads_nested_search_receipts() {
2636 let presentation = web_search_presentation(
2637 &json!({
2638 "search_query": [{
2639 "source": "duckduckgo",
2640 "results": [{"ref_id": "web_a"}],
2641 "receipt": {
2642 "degraded": [{"kind": "knob_ignored", "knob": "recency"}]
2643 }
2644 }]
2645 })
2646 .to_string(),
2647 );
2648
2649 assert_eq!(presentation.source.as_deref(), Some("duckduckgo"));
2650 assert_eq!(presentation.degraded.as_deref(), Some("recency ignored"));
2651 assert_eq!(presentation.ref_count, 1);
2652 }
2653
2654 #[test]
2655 fn parse_plan_input_accepts_legacy_payload() {
2656 let snapshot = parse_plan_input(&json!({
2657 "explanation": "Legacy explanation",
2658 "plan": [
2659 { "step": "inspect", "status": "completed" },
2660 { "step": "patch", "status": "in_progress" }
2661 ]
2662 }));
2663
2664 assert_eq!(snapshot.explanation.as_deref(), Some("Legacy explanation"));
2665 assert_eq!(snapshot.items.len(), 2);
2666 assert_eq!(snapshot.items[0].status, StepStatus::Completed);
2667 assert_eq!(snapshot.items[1].status, StepStatus::InProgress);
2668 }
2669
2670 #[test]
2671 fn parse_plan_input_extracts_rich_artifact_fields() {
2672 let snapshot = parse_plan_input(&json!({
2673 "title": " PlanArtifact ",
2674 "objective": "Make Plan mode reviewable",
2675 "context_summary": "Grounded in issue #2691",
2676 "sources_used": [" gh issue view 2691 ", ""],
2677 "critical_files": ["crates/tui/src/tools/plan.rs"],
2678 "constraints": ["No secrets"],
2679 "recommended_approach": "Enrich update_plan",
2680 "verification_plan": "Run focused tests",
2681 "risks_and_unknowns": "Replay may drift",
2682 "handoff_packet": "Continue with session replay",
2683 "plan": [
2684 { "step": " ", "status": "completed" },
2685 { "step": "render all fields", "status": "weird" }
2686 ]
2687 }));
2688
2689 assert_eq!(snapshot.title.as_deref(), Some("PlanArtifact"));
2690 assert_eq!(snapshot.sources_used, vec!["gh issue view 2691"]);
2691 assert_eq!(
2692 snapshot.critical_files,
2693 vec!["crates/tui/src/tools/plan.rs"]
2694 );
2695 assert_eq!(snapshot.constraints, vec!["No secrets"]);
2696 assert_eq!(
2697 snapshot.verification_plan.as_deref(),
2698 Some("Run focused tests")
2699 );
2700 assert_eq!(snapshot.items.len(), 1);
2701 assert_eq!(snapshot.items[0].step, "render all fields");
2702 assert_eq!(snapshot.items[0].status, StepStatus::Pending);
2703 }
2704
2705 #[test]
2706 fn parse_patch_summary_treats_replace_and_legacy_changes_equally() {
2707 let replacements = json!([{
2708 "path": "src/lib.rs",
2709 "content": "fn replacement() {}\n"
2710 }]);
2711
2712 let canonical =
2713 parse_file_mutation_summary("apply_patch", &json!({"replace": replacements.clone()}));
2714 let legacy = parse_file_mutation_summary("apply_patch", &json!({"changes": replacements}));
2715
2716 assert_eq!(canonical, legacy);
2717 }
2718
2719 // ── #3031: "(no output)" placeholder must not defeat compact rendering ─
2720
2721 #[test]
2722 fn visible_tool_output_maps_no_output_placeholder_to_none() {
2723 assert_eq!(visible_tool_output("(no output)"), None);
2724 assert_eq!(visible_tool_output(" (no output)\n"), None);
2725 }
2726
2727 #[test]
2728 fn visible_tool_output_preserves_real_content() {
2729 assert_eq!(
2730 visible_tool_output("compiled 3 crates").as_deref(),
2731 Some("compiled 3 crates")
2732 );
2733 // Output that merely CONTAINS the placeholder is real output.
2734 assert_eq!(
2735 visible_tool_output("step 1: (no output) — continuing").as_deref(),
2736 Some("step 1: (no output) — continuing")
2737 );
2738 assert_eq!(visible_tool_output("").as_deref(), Some(""));
2739 }
2740
2741 #[test]
2742 fn exec_cell_without_output_suppresses_placeholder_in_live_mode() {
2743 use crate::tui::history::{ExecCell, ExecSource, ToolCell, ToolStatus};
2744
2745 let cell = ToolCell::Exec(ExecCell {
2746 command: "true".to_string(),
2747 status: ToolStatus::Success,
2748 output: None,
2749 live_output: None,
2750 shell_task_id: None,
2751 owner_agent_id: None,
2752 owner_agent_name: None,
2753 started_at: None,
2754 duration_ms: Some(120),
2755 stale_elapsed_since_output_ms: None,
2756 source: ExecSource::Assistant,
2757 interaction: None,
2758 output_summary: None,
2759 });
2760
2761 let live: String = cell
2762 .lines(80)
2763 .iter()
2764 .flat_map(|line| line.spans.iter().map(|s| s.content.to_string()))
2765 .collect();
2766 assert!(
2767 !live.contains("(no output)"),
2768 "Live mode must suppress the placeholder: {live:?}"
2769 );
2770
2771 let transcript: String = cell
2772 .transcript_lines(80)
2773 .iter()
2774 .flat_map(|line| line.spans.iter().map(|s| s.content.to_string()))
2775 .collect();
2776 assert!(
2777 transcript.contains("(no output)"),
2778 "Transcript mode still records the placeholder: {transcript:?}"
2779 );
2780 }
2781
2782 // === #5472 finding 3: retained tool outputs are bounded ===
2783
2784 #[test]
2785 fn tool_detail_output_is_capped_and_says_what_it_dropped() {
2786 let small = "short output".to_string();
2787 assert_eq!(super::bounded_tool_detail_output(small.clone()), small);
2788
2789 let huge = "x".repeat(super::TOOL_DETAIL_OUTPUT_MAX_BYTES * 3);
2790 let bounded = super::bounded_tool_detail_output(huge);
2791 assert!(
2792 bounded.len() < super::TOOL_DETAIL_OUTPUT_MAX_BYTES + 200,
2793 "retained {} bytes",
2794 bounded.len()
2795 );
2796 assert!(bounded.contains("the transcript keeps an excerpt"));
2797 }
2798
2799 #[test]
2800 fn tool_detail_cap_never_splits_a_character() {
2801 // Every char is 3 bytes, so a byte-exact cut lands mid-character.
2802 let wide = "宽".repeat(super::TOOL_DETAIL_OUTPUT_MAX_BYTES);
2803 let bounded = super::bounded_tool_detail_output(wide);
2804 assert!(bounded.starts_with('宽'));
2805 assert!(bounded.contains("excerpt"));
2806 }
2807
2808 #[test]
2809 fn oldest_tool_outputs_are_released_once_the_budget_is_exceeded() {
2810 let mut app = crate::tui::app::App::new(
2811 crate::test_support::test_tui_options(std::path::PathBuf::from(".")),
2812 &crate::config::Config::default(),
2813 );
2814 // 200 records x 64 KiB = 12.8 MiB, well past the 8 MiB budget.
2815 let record_count = 200usize;
2816 for index in 0..record_count {
2817 app.tool_details_by_cell.insert(
2818 index,
2819 ToolDetailRecord {
2820 tool_id: format!("tool-{index}"),
2821 tool_name: "Bash".to_string(),
2822 input: serde_json::Value::Null,
2823 output: Some("y".repeat(super::TOOL_DETAIL_OUTPUT_MAX_BYTES)),
2824 },
2825 );
2826 }
2827 super::release_oldest_tool_detail_outputs(&mut app);
2828
2829 let retained: usize = app
2830 .tool_details_by_cell
2831 .values()
2832 .map(|detail| detail.output.as_ref().map_or(0, String::len))
2833 .sum();
2834 assert!(
2835 retained <= super::TOOL_DETAIL_TOTAL_BUDGET_BYTES,
2836 "retained {retained} bytes over the {} budget",
2837 super::TOOL_DETAIL_TOTAL_BUDGET_BYTES
2838 );
2839 assert_eq!(
2840 app.tool_details_by_cell.len(),
2841 record_count,
2842 "records stay listed; only their outputs are released"
2843 );
2844 assert!(
2845 app.tool_details_by_cell[&(record_count - 1)]
2846 .output
2847 .is_some(),
2848 "the newest output must survive — it is the one the user can still expand"
2849 );
2850 assert!(
2851 app.tool_details_by_cell[&0].output.is_none(),
2852 "the oldest output is the first to go"
2853 );
2854 }
2855 }
2856
2856 lines RUST