返回 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::handle_deepseek_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 fn store_tool_detail_output(
350 app: &mut App,
351 tool_id: &str,
352 cell_index: usize,
353 result: &Result<ToolResult, ToolError>,
354 ) {
355 let payload = Some(match result {
356 Ok(tool_result) => tool_result.content.clone(),
357 Err(err) => err.to_string(),
358 });
359 if cell_index < app.history.len()
360 && let Some(detail) = app.tool_details_by_cell.get_mut(&cell_index)
361 {
362 detail.output = payload.clone();
363 }
364 // Also write to the active table while the entry might still live there;
365 // some callsites pre-rewrite cell_index but the active_tool_details map is
366 // the canonical source for in-flight outputs.
367 if let Some(detail) = app.active_tool_details.get_mut(tool_id) {
368 detail.output = payload;
369 }
370 }
371
372 #[allow(clippy::too_many_lines)]
373 /// Inspect a tool's success metadata for the `child_*` token-usage
374 /// fields that tools spawning their own LLM calls populate (e.g.
375 /// `rlm`). Roll any reported child-token cost into the session's
376 /// running sub-agent cost counter so the footer total reflects all
377 /// tokens the user is actually billed for, not just the parent turn's
378 /// tokens.
379 ///
380 /// Without this hook, an RLM-heavy session shows a fraction of the
381 /// real spend because the parent turn's `Usage` only counts the
382 /// orchestrator's tokens, not the dozens of `deepseek-v4-flash` child
383 /// rounds RLM fans out under the hood (#524).
384 fn accrue_child_token_cost_if_any(app: &mut App, result: &Result<ToolResult, ToolError>) {
385 let Ok(tool_result) = result else { return };
386 let Some(metadata) = tool_result.metadata.as_ref() else {
387 return;
388 };
389 let Some(route) = crate::cost_status::child_route_envelope_from_metadata(metadata) else {
390 return;
391 };
392 // Use the same parser as the runtime host. It deliberately returns a
393 // zero-valued usage record when the producer emitted the canonical child
394 // fields: a model-backed call is still an auditable/priced-zero call, and
395 // replay/server-tool telemetry must not disappear in the TUI projection.
396 let Some(usage) = crate::cost_status::child_usage_from_metadata(metadata) else {
397 return;
398 };
399 // `route` is the child's own dispatch receipt, rehydrated from the
400 // complete `child_*` metadata `attach_child_usage_metadata` emits at the
401 // child's wire boundary (review/verify/rlm are the three producers). An
402 // incomplete or legacy payload rehydrates as `RouteBillingMode::Unknown`,
403 // so a child never inherits the live `app.billing_presentation` chip and a
404 // `/provider` switch between dispatch and arrival cannot retro-bill it.
405 //
406 // Sub-agent spend lands in the same displayed total as parent turns, so it
407 // has to feed the same completeness counters — otherwise `/cost` would call
408 // a total complete while an unpriced child turn is missing from it.
409 let audit = route.audit(&usage);
410 app.record_turn_cost_audit(&audit);
411 app.record_turn_cost_route_receipt(route.receipt(&audit));
412 if let Some(cost) = audit.estimate {
413 app.accrue_subagent_cost_estimate(cost);
414 }
415 }
416
417 fn record_spillover_artifact_if_any(
418 app: &mut App,
419 id: &str,
420 name: &str,
421 result: &Result<ToolResult, ToolError>,
422 ) {
423 let Ok(tool_result) = result else { return };
424 let Some(path) = tool_result
425 .metadata
426 .as_ref()
427 .and_then(|metadata| metadata.get("spillover_path"))
428 .and_then(serde_json::Value::as_str)
429 .map(PathBuf::from)
430 else {
431 return;
432 };
433 let metadata = tool_result.metadata.as_ref();
434 let session_id = metadata
435 .and_then(|metadata| metadata.get("artifact_session_id"))
436 .and_then(serde_json::Value::as_str)
437 .or(app.current_session_id.as_deref())
438 .unwrap_or("");
439 let storage_path = metadata
440 .and_then(|metadata| metadata.get("artifact_relative_path"))
441 .and_then(serde_json::Value::as_str)
442 .map(PathBuf::from)
443 .unwrap_or_else(|| path.clone());
444 let content_for_preview = metadata
445 .and_then(|metadata| metadata.get("artifact_preview"))
446 .and_then(serde_json::Value::as_str)
447 .unwrap_or(&tool_result.content);
448 let byte_size = metadata
449 .and_then(|metadata| metadata.get("artifact_byte_size"))
450 .and_then(serde_json::Value::as_u64)
451 .unwrap_or_else(|| {
452 std::fs::metadata(&storage_path)
453 .map(|metadata| metadata.len())
454 .unwrap_or(tool_result.content.len() as u64)
455 });
456 if app
457 .session_artifacts
458 .iter()
459 .any(|artifact| artifact.tool_call_id == id && artifact.storage_path == storage_path)
460 {
461 return;
462 }
463 app.session_artifacts
464 .push(crate::artifacts::record_tool_output_artifact_with_size(
465 session_id,
466 id,
467 name,
468 storage_path,
469 byte_size,
470 content_for_preview,
471 ));
472 }
473
474 pub(super) fn evidence_completion_should_be_ignored(
475 app: &App,
476 id: &str,
477 result: &Result<ToolResult, ToolError>,
478 ) -> bool {
479 evidence_completion_identity_should_be_ignored(
480 app.current_session_id.as_deref(),
481 app.session_artifacts
482 .iter()
483 .map(|artifact| (artifact.id.as_str(), artifact.tool_call_id.as_str())),
484 id,
485 result,
486 )
487 }
488
489 fn evidence_completion_identity_should_be_ignored<'a>(
490 current_session: Option<&str>,
491 known_artifacts: impl IntoIterator<Item = (&'a str, &'a str)>,
492 id: &str,
493 result: &Result<ToolResult, ToolError>,
494 ) -> bool {
495 let Some(metadata) = result
496 .as_ref()
497 .ok()
498 .and_then(|result| result.metadata.as_ref())
499 else {
500 return false;
501 };
502 let origin = metadata
503 .get("artifact_session_id")
504 .and_then(serde_json::Value::as_str);
505 if let (Some(origin), Some(current)) = (origin, current_session)
506 && origin != current
507 {
508 return true;
509 }
510 metadata
511 .get("artifact_id")
512 .and_then(serde_json::Value::as_str)
513 .is_some_and(|artifact_id| {
514 known_artifacts
515 .into_iter()
516 .any(|(known_id, known_call)| known_id == artifact_id && known_call == id)
517 })
518 }
519
520 /// #3031: shell/tasks tools embed the literal `"(no output)"` into successful
521 /// `ToolResult` content (the model-facing transcript needs a non-empty tool
522 /// result). Treat it as no output on the TUI side so the compact-mode
523 /// suppression gate in `history.rs` actually fires; the raw content remains
524 /// available through the tool-detail store.
525 fn visible_tool_output(content: &str) -> Option<String> {
526 if content.trim() == "(no output)" {
527 None
528 } else {
529 Some(content.to_string())
530 }
531 }
532
533 /// Read the process exit code a tool reported, when it reported one.
534 ///
535 /// Only process-backed tools (`exec_shell`, task runners) carry one, and only
536 /// a real, integer-valued `exit_code` counts. Everything else stays `None` so
537 /// an `exit_code` condition never matches on a fabricated value.
538 /// Reported as `i64`, not `i32`: a Windows crash code such as `3221225477`
539 /// (`0xC0000005`) is a real value the shell tool records in its metadata, and
540 /// narrowing it dropped exactly those codes — the hook saw no exit code at all
541 /// for the crashes it most wanted to catch.
542 pub(crate) fn reported_tool_exit_code(result: &Result<ToolResult, ToolError>) -> Option<i64> {
543 let metadata = result.as_ref().ok()?.metadata.as_ref()?;
544 let code = metadata.get("exit_code")?;
545 if code.is_null() {
546 return None;
547 }
548 code.as_i64()
549 }
550
551 /// Fire `tool_call_after` for every settled tool call, plus `on_error` when
552 /// the call failed.
553 ///
554 /// `on_error` is documented as covering tool failures, not just transport and
555 /// auth failures, so the tool path has to raise it too — the engine-error path
556 /// in `apply_engine_error_to_app` never sees a tool that returned
557 /// `success: false`.
558 ///
559 /// Both are observer events: their stdout is ignored and neither can change
560 /// the result that goes back to the model. That is a statement about
561 /// Codewhale's control flow only — the commands themselves are arbitrary
562 /// shells and may have any external side effect.
563 fn fire_tool_completion_hooks(
564 app: &mut App,
565 id: &str,
566 name: &str,
567 result: &Result<ToolResult, ToolError>,
568 ) {
569 let wants_after = app.hooks.has_hooks_for_event(HookEvent::ToolCallAfter);
570 let wants_error = app
571 .hooks
572 .has_hooks_for_event(crate::hooks::HookEvent::OnError);
573 if !wants_after && !wants_error {
574 // Fast path: skip the result clone and HookContext allocation when
575 // the user has configured neither event.
576 return;
577 }
578
579 let (result_text, success): (String, bool) = match result.as_ref() {
580 Ok(tool_result) => (tool_result.content.clone(), tool_result.success),
581 Err(err) => (err.to_string(), false),
582 };
583 let exit_code = reported_tool_exit_code(result);
584
585 if wants_after {
586 let context = app
587 .base_hook_context()
588 .with_tool_name(name)
589 .with_tool_call_id(id)
590 .with_tool_result(&result_text, success, exit_code);
591 if let Err(error) = app.submit_hooks(HookEvent::ToolCallAfter, context) {
592 app.surface_observer_hook_submission_failure(error);
593 }
594 }
595
596 if wants_error && !success {
597 let context = app
598 .base_hook_context()
599 .with_tool_name(name)
600 .with_tool_call_id(id)
601 .with_tool_result(&result_text, success, exit_code)
602 .with_error(&format!("tool `{name}` failed: {result_text}"));
603 if let Err(error) = app.submit_hooks(crate::hooks::HookEvent::OnError, context) {
604 app.surface_observer_hook_submission_failure(error);
605 }
606 }
607 }
608
609 pub(super) fn handle_tool_call_complete(
610 app: &mut App,
611 id: &str,
612 name: &str,
613 result: &Result<ToolResult, ToolError>,
614 ) {
615 if app.ignored_tool_calls.remove(id) {
616 // "Ignored" is a *presentation* decision: these are real settled
617 // results — repeated `wait` polls, background-shell status reads —
618 // that the transcript deliberately does not redraw. Observers still
619 // have to see them, or `tool_call_after` silently skips a whole class
620 // of completions while claiming to fire after each tool call. Fired
621 // here and returned immediately, so each id emits exactly once.
622 fire_tool_completion_hooks(app, id, name, result);
623 return;
624 }
625 // Preserve the execution/audit name while recovering the action-qualified
626 // semantic name from the registered call input. Active entries and
627 // already-flushed history use separate detail stores.
628 let semantic_name = app
629 .active_tool_details
630 .get(id)
631 .or_else(|| {
632 app.tool_cells
633 .get(id)
634 .and_then(|cell_index| app.tool_details_by_cell.get(cell_index))
635 })
636 .map_or(name, |detail| canonical_action_alias(name, &detail.input))
637 .to_string();
638
639 // Roll any child-LLM token usage the tool reports into the
640 // session-cost counter. Runs unconditionally so future tools that
641 // spawn their own LLM calls (RLM, summarizers, retrieval helpers)
642 // get accrued without needing a per-tool hook (#524).
643 accrue_child_token_cost_if_any(app, result);
644 record_spillover_artifact_if_any(app, id, name, result);
645
646 // #455: fire `tool_call_after` (and `on_error` for failures) here, before
647 // any of the presentation early-returns below. Firing it further down meant
648 // exploring-tool completions and orphaned completions never emitted the
649 // event at all, so "fires after each tool call" was not true.
650 fire_tool_completion_hooks(app, id, name, result);
651
652 // Exploring entries land in the per-tool map regardless of whether they
653 // live in the active cell or in finalized history; the path is the same.
654 if let Some((cell_index, entry_index)) = app.exploring_entries.remove(id) {
655 app.tool_cells.remove(id);
656 store_tool_detail_output(app, id, cell_index, result);
657 if let Some(HistoryCell::Tool(ToolCell::Exploring(cell))) =
658 app.cell_at_virtual_index_mut(cell_index)
659 && let Some(entry) = cell.entries.get_mut(entry_index)
660 {
661 entry.status = tool_status_from_result(result);
662 app.mark_history_updated();
663 // Mutating the in-flight exploring cell needs an active-cell
664 // revision bump so the transcript cache invalidates the synthetic
665 // tail row.
666 if cell_index >= app.history.len() {
667 app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
668 if let Some(active) = app.active_cell.as_mut() {
669 active.bump_revision();
670 }
671 }
672 }
673 refresh_active_tool_completion_timestamp(app, cell_index);
674 return;
675 }
676
677 // Look up the cell by tool id. If the id isn't registered, that's an
678 // orphan completion (race condition where the started event was lost or
679 // a tool result arrived after the active cell was already flushed). Build
680 // a finalized standalone cell from the result so the user can still see
681 // the output, but DO NOT touch the active cell.
682 let Some(cell_index) = app.tool_cells.remove(id) else {
683 push_orphan_tool_completion(app, id, name, result);
684 return;
685 };
686
687 store_tool_detail_output(app, id, cell_index, result);
688 let in_active = cell_index >= app.history.len();
689
690 let status = tool_status_from_result(result);
691 let mutation_receipt = matches!(
692 semantic_name.as_str(),
693 "write_file" | "edit_file" | "apply_patch"
694 )
695 .then(|| {
696 result.as_ref().ok().and_then(|tool_result| {
697 crate::tui::history::FileMutationReceipt::from_success(&app.workspace, tool_result)
698 })
699 })
700 .flatten();
701 let mut workflow_panel_output: Option<String> = None;
702
703 if let Some(cell) = app.cell_at_virtual_index_mut(cell_index) {
704 match cell {
705 HistoryCell::Tool(ToolCell::Exec(exec)) => {
706 exec.status = status;
707 if let Ok(tool_result) = result.as_ref() {
708 let shell_task_id = tool_result
709 .metadata
710 .as_ref()
711 .and_then(|m| m.get("task_id"))
712 .and_then(serde_json::Value::as_str)
713 .filter(|task_id| !task_id.trim().is_empty())
714 .map(str::to_string);
715 if shell_task_id.is_some() {
716 exec.shell_task_id = shell_task_id;
717 }
718 exec.owner_agent_id = tool_result
719 .metadata
720 .as_ref()
721 .and_then(|m| m.get("owner_agent_id"))
722 .and_then(serde_json::Value::as_str)
723 .filter(|agent_id| !agent_id.trim().is_empty())
724 .map(str::to_string);
725 exec.owner_agent_name = tool_result
726 .metadata
727 .as_ref()
728 .and_then(|m| m.get("owner_agent_name"))
729 .and_then(serde_json::Value::as_str)
730 .filter(|agent_name| !agent_name.trim().is_empty())
731 .map(str::to_string);
732 if let Some(meta_command) = tool_result
733 .metadata
734 .as_ref()
735 .and_then(|m| m.get("command"))
736 .and_then(serde_json::Value::as_str)
737 && !meta_command.trim().is_empty()
738 && (exec.command == "command" || exec.command.starts_with("command "))
739 {
740 exec.command = meta_command.to_string();
741 if exec.interaction.as_deref().is_some_and(|interaction| {
742 interaction.starts_with("Waiting for command")
743 }) {
744 let task_suffix = tool_result
745 .metadata
746 .as_ref()
747 .and_then(|m| m.get("task_id"))
748 .and_then(serde_json::Value::as_str)
749 .map(|task_id| format!(" ({task_id})"))
750 .unwrap_or_default();
751 exec.interaction =
752 Some(format!("Waiting for \"{meta_command}\"{task_suffix}"));
753 }
754 }
755 exec.duration_ms = tool_result
756 .metadata
757 .as_ref()
758 .and_then(|m| m.get("duration_ms"))
759 .and_then(serde_json::Value::as_u64);
760 if status != ToolStatus::Running && exec.interaction.is_none() {
761 exec.output = visible_tool_output(&tool_result.content);
762 exec.output_summary = exec
763 .output
764 .as_deref()
765 .map(super::history::summarize_tool_output);
766 exec.live_output = None;
767 } else if status == ToolStatus::Running
768 && exec.interaction.is_none()
769 && !tool_result.content.is_empty()
770 {
771 exec.live_output = Some(tool_result.content.clone());
772 }
773 } else if let Err(err) = result.as_ref()
774 && exec.interaction.is_none()
775 {
776 exec.output = Some(err.to_string());
777 exec.output_summary =
778 Some(super::history::summarize_tool_output(&err.to_string()));
779 }
780 app.mark_history_updated();
781 }
782 HistoryCell::Tool(ToolCell::PlanUpdate(plan)) => {
783 plan.status = status;
784 app.mark_history_updated();
785 }
786 HistoryCell::Tool(ToolCell::PatchSummary(patch)) => {
787 patch.status = status;
788 patch.receipt = mutation_receipt;
789 match result.as_ref() {
790 Ok(tool_result) if tool_result.success => {
791 if let Ok(json) =
792 serde_json::from_str::<serde_json::Value>(&tool_result.content)
793 && let Some(message) = json.get("message").and_then(|v| v.as_str())
794 {
795 patch.summary = message.to_string();
796 }
797 }
798 Ok(tool_result) => {
799 patch.error = Some(tool_result.content.clone());
800 }
801 Err(err) => {
802 patch.error = Some(err.to_string());
803 }
804 }
805 app.mark_history_updated();
806 }
807 HistoryCell::Tool(ToolCell::Review(review)) => {
808 review.status = status;
809 match result.as_ref() {
810 Ok(tool_result) => {
811 if tool_result.success {
812 review.output = Some(ReviewOutput::from_str(&tool_result.content));
813 } else {
814 review.error = Some(tool_result.content.clone());
815 }
816 }
817 Err(err) => {
818 review.error = Some(err.to_string());
819 }
820 }
821 app.mark_history_updated();
822 }
823 HistoryCell::Tool(ToolCell::Mcp(mcp)) => {
824 match result.as_ref() {
825 Ok(tool_result) => {
826 let summary = summarize_mcp_output(&tool_result.content);
827 if status == ToolStatus::Hydrated {
828 mcp.status = status;
829 } else if summary.is_error == Some(true) {
830 mcp.status = ToolStatus::Failed;
831 } else {
832 mcp.status = status;
833 }
834 mcp.is_image = summary.is_image;
835 mcp.content = summary.content;
836 }
837 Err(err) => {
838 mcp.status = status;
839 mcp.content = Some(err.to_string());
840 }
841 }
842 app.mark_history_updated();
843 }
844 HistoryCell::Tool(ToolCell::WebSearch(search)) => {
845 search.status = status;
846 match result.as_ref() {
847 Ok(tool_result) => {
848 search.summary = Some(summarize_tool_output(&tool_result.content));
849 let presentation = web_search_presentation(&tool_result.content);
850 search.source = presentation.source;
851 search.degraded = presentation.degraded;
852 search.ref_count = presentation.ref_count;
853 }
854 Err(err) => {
855 search.summary = Some(err.to_string());
856 }
857 }
858 app.mark_history_updated();
859 }
860 HistoryCell::Tool(ToolCell::Generic(generic)) => {
861 generic.status = status;
862 match result.as_ref() {
863 Ok(tool_result) => {
864 generic.output = visible_tool_output(&tool_result.content);
865 generic.output_summary =
866 generic.output.as_deref().map(summarize_tool_output);
867 generic.is_diff = output_looks_like_diff(&tool_result.content);
868 }
869 Err(err) => {
870 generic.output = Some(err.to_string());
871 generic.output_summary = Some(summarize_tool_output(&err.to_string()));
872 generic.is_diff = false;
873 }
874 }
875 // #4121: capture workflow JSON before releasing the cell borrow
876 // so we can hydrate the panel without overlapping borrows.
877 if generic.name == "workflow" {
878 workflow_panel_output = generic.output.clone();
879 }
880 app.mark_history_updated();
881 }
882 _ => {}
883 }
884 }
885
886 // #4121 / #4122: feed typed workflow events into the panel *and* keep the
887 // history card snapshot in sync. Live streaming also arrives via
888 // `Event::WorkflowUi`; this path covers tool-complete hydration.
889 if let Some(output) = workflow_panel_output.as_deref() {
890 apply_workflow_output_to_panel(app, output);
891 }
892
893 // If the mutated cell lived inside the active group, bump the active-cell
894 // revision so the transcript cache re-renders the synthetic tail row.
895 if in_active {
896 app.active_cell_revision = app.active_cell_revision.wrapping_add(1);
897 if let Some(active) = app.active_cell.as_mut() {
898 active.bump_revision();
899 }
900 refresh_active_tool_completion_timestamp(app, cell_index);
901 }
902
903 if refreshes_workspace_context_on_completion(&semantic_name) && status != ToolStatus::Running {
904 workspace_context::refresh_now(app, Instant::now());
905 }
906
907 // Collect evidence for the post-turn receipt.
908 let evidence_summary = match result.as_ref() {
909 Ok(tool_result) => {
910 if tool_result.success {
911 summarize_tool_output(&tool_result.content)
912 } else {
913 format!("failed: {}", summarize_tool_output(&tool_result.content))
914 }
915 }
916 Err(err) => format!("error: {err}"),
917 };
918 app.tool_evidence.push(ToolEvidence {
919 tool_name: name.to_string(),
920 summary: evidence_summary,
921 });
922 }
923
924 #[derive(Debug, Default, PartialEq, Eq)]
925 struct WebSearchPresentation {
926 source: Option<String>,
927 degraded: Option<String>,
928 ref_count: usize,
929 }
930
931 fn web_search_presentation(content: &str) -> WebSearchPresentation {
932 let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
933 return WebSearchPresentation::default();
934 };
935 let surfaces = if value.get("receipt").is_some() {
936 vec![&value]
937 } else {
938 value
939 .get("search_query")
940 .and_then(serde_json::Value::as_array)
941 .map(|items| items.iter().collect())
942 .unwrap_or_default()
943 };
944 let source = surfaces
945 .iter()
946 .filter_map(|surface| surface.get("source").and_then(serde_json::Value::as_str))
947 .map(str::to_string)
948 .next();
949 let mut degraded = Vec::new();
950 let mut ref_count = 0usize;
951 for surface in surfaces {
952 if let Some(results) = surface.get("results").and_then(serde_json::Value::as_array) {
953 ref_count = ref_count.saturating_add(
954 results
955 .iter()
956 .filter(|result| {
957 result
958 .get("ref_id")
959 .and_then(serde_json::Value::as_str)
960 .is_some_and(|ref_id| !ref_id.is_empty())
961 })
962 .count(),
963 );
964 }
965 if let Some(reasons) = surface
966 .pointer("/receipt/degraded")
967 .and_then(serde_json::Value::as_array)
968 {
969 for reason in reasons {
970 if let Some(label) = degraded_reason_label(reason)
971 && !degraded.contains(&label)
972 {
973 degraded.push(label);
974 }
975 }
976 }
977 }
978 WebSearchPresentation {
979 source,
980 degraded: (!degraded.is_empty()).then(|| degraded.join("; ")),
981 ref_count,
982 }
983 }
984
985 fn degraded_reason_label(reason: &serde_json::Value) -> Option<String> {
986 let kind = reason.get("kind")?.as_str()?;
987 let backend = |field: &str| {
988 reason
989 .get(field)
990 .and_then(serde_json::Value::as_str)
991 .unwrap_or("unknown")
992 };
993 Some(match kind {
994 "backend_unavailable" => format!("{} unavailable", backend("backend")),
995 "no_usable_results" => format!("{} returned no usable results", backend("backend")),
996 "backend_fallback" => format!("{} -> {}", backend("from"), backend("to")),
997 "challenge_detected" => format!("{} challenge", backend("backend")),
998 "scrape_fallback" => format!("{} -> {} scrape", backend("from"), backend("to")),
999 "knob_ignored" => format!(
1000 "{} ignored",
1001 reason
1002 .get("knob")
1003 .and_then(serde_json::Value::as_str)
1004 .unwrap_or("filter")
1005 ),
1006 "post_filtered" => format!(
1007 "{} post-filtered",
1008 reason
1009 .get("knob")
1010 .and_then(serde_json::Value::as_str)
1011 .unwrap_or("results")
1012 ),
1013 "synthesized_results" => "synthesized results".to_string(),
1014 other => other.replace('_', " "),
1015 })
1016 }
1017
1018 /// Hydrate or advance the WorkflowPanel from a workflow tool JSON payload.
1019 /// Accepts a single run record (with optional `events` array) or a status
1020 /// list. Log-only events are filtered by the panel itself so the transcript
1021 /// stays free of progress spam (#4121). Also keeps the matching history card
1022 /// snapshot aligned (#4122).
1023 fn apply_workflow_output_to_panel(app: &mut App, output: &str) {
1024 let Ok(value) = serde_json::from_str::<serde_json::Value>(output) else {
1025 return;
1026 };
1027
1028 // Prefer the typed event stream when present.
1029 if let Some(events) = value.get("events").and_then(|e| e.as_array()) {
1030 // Ensure a panel exists before applying — seed from run_id/goal if needed.
1031 if app.workflow_panel.is_none() {
1032 let run_id = value
1033 .get("run_id")
1034 .and_then(|v| v.as_str())
1035 .unwrap_or("workflow")
1036 .to_string();
1037 let label = value
1038 .get("workflow_goal")
1039 .and_then(|v| v.as_str())
1040 .or_else(|| value.get("workflow_id").and_then(|v| v.as_str()))
1041 .unwrap_or("workflow")
1042 .to_string();
1043 let at_ms = value
1044 .get("started_at_ms")
1045 .and_then(|v| v.as_u64())
1046 .unwrap_or(0);
1047 let mut panel =
1048 crate::tui::widgets::workflow_panel::WorkflowPanel::new(run_id, label, at_ms);
1049 panel.locale = app.ui_locale;
1050 app.workflow_panel = Some(panel);
1051 }
1052 if let Some(panel) = app.workflow_panel.as_mut() {
1053 let run_id = value
1054 .get("run_id")
1055 .and_then(|v| v.as_str())
1056 .unwrap_or(&panel.run_id)
1057 .to_string();
1058 let mut injected = Vec::with_capacity(events.len());
1059 for event in events {
1060 let mut event = event.clone();
1061 if let Some(obj) = event.as_object_mut() {
1062 obj.entry("run_id".to_string())
1063 .or_insert_with(|| serde_json::Value::String(run_id.clone()));
1064 }
1065 injected.push(event);
1066 }
1067 panel.apply_json_events(&injected);
1068 // Carry final result / source into panel for expanded history card.
1069 if let Some(summary) = value
1070 .get("result")
1071 .map(|v| v.to_string())
1072 .filter(|s| s != "null")
1073 {
1074 panel.result_summary = Some(summary);
1075 }
1076 if let Some(path) = value.get("source_path").and_then(|v| v.as_str()) {
1077 panel.source_path = Some(PathBuf::from(path));
1078 }
1079 app.needs_redraw = true;
1080 }
1081 sync_workflow_history_card_from_panel(app);
1082 return;
1083 }
1084
1085 // Fallback: status list — show the most recent run as a shell panel.
1086 if value.get("action").and_then(|v| v.as_str()) == Some("status") {
1087 if let Some(runs) = value.get("runs").and_then(|r| r.as_array())
1088 && let Some(run) = runs.last()
1089 {
1090 apply_workflow_output_to_panel(app, &run.to_string());
1091 }
1092 return;
1093 }
1094
1095 // Prefer full panel hydration from summary/phases snapshot when present.
1096 if let Some(mut panel) =
1097 crate::tui::widgets::workflow_panel::WorkflowPanel::from_run_json(&value)
1098 {
1099 panel.locale = app.ui_locale;
1100 app.workflow_panel = Some(panel);
1101 app.needs_redraw = true;
1102 sync_workflow_history_card_from_panel(app);
1103 return;
1104 }
1105
1106 // Fallback: bare run record without events — at least surface header state.
1107 if let Some(run_id) = value.get("run_id").and_then(|v| v.as_str()) {
1108 use crate::tui::widgets::workflow_panel::{WorkflowPanelEvent, WorkflowPanelLifecycle};
1109 let label = value
1110 .get("workflow_goal")
1111 .and_then(|v| v.as_str())
1112 .or_else(|| value.get("workflow_id").and_then(|v| v.as_str()))
1113 .unwrap_or(run_id)
1114 .to_string();
1115 let at_ms = value
1116 .get("started_at_ms")
1117 .and_then(|v| v.as_u64())
1118 .unwrap_or(0);
1119 let status = value
1120 .get("status")
1121 .and_then(|v| v.as_str())
1122 .unwrap_or("running");
1123 app.apply_workflow_panel_event(WorkflowPanelEvent::RunStarted {
1124 run_id: run_id.to_string(),
1125 workflow_id: value
1126 .get("workflow_id")
1127 .and_then(|v| v.as_str())
1128 .map(str::to_string),
1129 workflow_goal: Some(label),
1130 source_path: value
1131 .get("source_path")
1132 .and_then(|v| v.as_str())
1133 .map(PathBuf::from),
1134 token_budget: value.get("token_budget").and_then(|v| v.as_u64()),
1135 at_ms,
1136 });
1137 if status != "running" {
1138 let life = match status {
1139 "completed" | "succeeded" => WorkflowPanelLifecycle::Succeeded,
1140 "failed" => WorkflowPanelLifecycle::Failed,
1141 "cancelled" | "canceled" => WorkflowPanelLifecycle::Cancelled,
1142 _ => WorkflowPanelLifecycle::Running,
1143 };
1144 if life != WorkflowPanelLifecycle::Running {
1145 app.apply_workflow_panel_event(WorkflowPanelEvent::RunCompleted {
1146 status: life,
1147 error: value
1148 .get("error")
1149 .and_then(|v| v.as_str())
1150 .map(str::to_string),
1151 at_ms: value
1152 .get("completed_at_ms")
1153 .and_then(|v| v.as_u64())
1154 .unwrap_or(at_ms),
1155 });
1156 }
1157 }
1158 sync_workflow_history_card_from_panel(app);
1159 }
1160 }
1161
1162 /// Apply one live `WorkflowUi` engine event to the panel and history card.
1163 pub(super) fn apply_workflow_ui_event(app: &mut App, run_id: &str, event: &serde_json::Value) {
1164 use crate::tui::widgets::workflow_panel::WorkflowPanelEvent;
1165
1166 let mut event = event.clone();
1167 if let Some(obj) = event.as_object_mut() {
1168 obj.entry("run_id".to_string())
1169 .or_insert_with(|| serde_json::Value::String(run_id.to_string()));
1170 }
1171 if let Some(panel_event) = WorkflowPanelEvent::from_json_value(&event) {
1172 app.apply_workflow_panel_event(panel_event);
1173 }
1174 sync_workflow_history_card_from_panel(app);
1175 }
1176
1177 /// Mirror the live WorkflowPanel snapshot into the in-flight (or most recent)
1178 /// workflow history tool cell so compact/expanded cards stay current.
1179 fn sync_workflow_history_card_from_panel(app: &mut App) {
1180 let Some(panel) = app.workflow_panel.as_ref() else {
1181 return;
1182 };
1183 let run_id = panel.run_id.clone();
1184 let snapshot = panel.to_run_json().to_string();
1185
1186 // Prefer an in-flight Generic(workflow) cell whose output already carries
1187 // this run_id, else the newest running workflow cell, else any workflow
1188 // cell (tool-complete path already wrote the final output).
1189 let mut target: Option<usize> = None;
1190 let history_len = app.history.len();
1191 let total = history_len
1192 + app
1193 .active_cell
1194 .as_ref()
1195 .map(|a| a.entries().len())
1196 .unwrap_or(0);
1197
1198 for idx in (0..total).rev() {
1199 let Some(cell) = app.cell_at_virtual_index(idx) else {
1200 continue;
1201 };
1202 let HistoryCell::Tool(ToolCell::Generic(generic)) = cell else {
1203 continue;
1204 };
1205 if generic.name != "workflow" {
1206 continue;
1207 }
1208 let matches_run = generic
1209 .output
1210 .as_deref()
1211 .and_then(|out| serde_json::from_str::<serde_json::Value>(out).ok())
1212 .and_then(|v| {
1213 v.get("run_id")
1214 .and_then(|id| id.as_str())
1215 .map(|id| id == run_id)
1216 })
1217 .unwrap_or(false);
1218 let is_running = generic.status == ToolStatus::Running;
1219 if matches_run || (is_running && target.is_none()) {
1220 target = Some(idx);
1221 if matches_run {
1222 break;
1223 }
1224 }
1225 }
1226
1227 let Some(idx) = target else {
1228 return;
1229 };
1230 if let Some(HistoryCell::Tool(ToolCell::Generic(generic))) = app.cell_at_virtual_index_mut(idx)
1231 {
1232 // Preserve a richer final output if the tool completion already wrote
1233 // a full run record with an events array longer than the snapshot.
1234 let replace = match generic.output.as_deref() {
1235 None => true,
1236 Some(existing) => {
1237 let Ok(value) = serde_json::from_str::<serde_json::Value>(existing) else {
1238 return;
1239 };
1240 let existing_run = value.get("run_id").and_then(|v| v.as_str()).unwrap_or("");
1241 if !existing_run.is_empty() && existing_run != run_id {
1242 return;
1243 }
1244 // Prefer full event-bearing records when the tool has completed.
1245 if generic.status == ToolStatus::Running {
1246 true
1247 } else {
1248 value
1249 .get("events")
1250 .and_then(|e| e.as_array())
1251 .is_none_or(|e| e.is_empty())
1252 }
1253 }
1254 };
1255 if replace {
1256 generic.output = Some(snapshot);
1257 generic.output_summary = Some(format!("workflow {}", run_id));
1258 app.mark_history_updated();
1259 }
1260 }
1261 }
1262
1263 fn refresh_active_tool_completion_timestamp(app: &mut App, cell_index: usize) {
1264 if cell_index < app.history.len() {
1265 return;
1266 }
1267 let entry_idx = cell_index - app.history.len();
1268 let Some(cell) = app.cell_at_virtual_index(cell_index) else {
1269 app.active_tool_entry_completed_at.remove(&entry_idx);
1270 return;
1271 };
1272
1273 if history_cell_has_running_tool(cell) {
1274 app.active_tool_entry_completed_at.remove(&entry_idx);
1275 } else {
1276 app.active_tool_entry_completed_at
1277 .entry(entry_idx)
1278 .or_insert_with(Instant::now);
1279 }
1280 }
1281
1282 fn history_cell_has_running_tool(cell: &HistoryCell) -> bool {
1283 let HistoryCell::Tool(tool) = cell else {
1284 return false;
1285 };
1286 match tool {
1287 ToolCell::Exec(exec) => exec.status == ToolStatus::Running,
1288 ToolCell::Exploring(explore) => explore
1289 .entries
1290 .iter()
1291 .any(|entry| entry.status == ToolStatus::Running),
1292 ToolCell::PlanUpdate(plan) => plan.status == ToolStatus::Running,
1293 ToolCell::PatchSummary(patch) => patch.status == ToolStatus::Running,
1294 ToolCell::Review(review) => review.status == ToolStatus::Running,
1295 ToolCell::DiffPreview(_) => false,
1296 ToolCell::Mcp(mcp) => mcp.status == ToolStatus::Running,
1297 ToolCell::ViewImage(_) => false,
1298 ToolCell::WebSearch(search) => search.status == ToolStatus::Running,
1299 ToolCell::Generic(generic) => generic.status == ToolStatus::Running,
1300 }
1301 }
1302
1303 /// Build a finalized standalone history cell for a tool completion whose
1304 /// start was never registered (orphan). This preserves the contract that
1305 /// every tool result is visible somewhere; the alternative (silently
1306 /// dropping it) hides errors and breaks debuggability.
1307 ///
1308 /// Choice of cell type: success-only mutation metadata is sufficient to
1309 /// reconstruct a structured File receipt; other orphans stay generic because
1310 /// no input payload remains. The pager remains usable in both cases because
1311 /// `tool_details_by_cell` is populated with the result text.
1312 ///
1313 /// ## Index drift
1314 ///
1315 /// If an active cell is in flight when the orphan arrives, pushing the
1316 /// orphan into `app.history` shifts every active-cell virtual index forward
1317 /// by 1. We must rewrite `tool_cells` / `exploring_entries` accordingly so
1318 /// later completion lookups still find the right entries.
1319 fn push_orphan_tool_completion(
1320 app: &mut App,
1321 tool_id: &str,
1322 name: &str,
1323 result: &Result<ToolResult, ToolError>,
1324 ) {
1325 let status = tool_status_from_result(result);
1326 let output = match result.as_ref() {
1327 Ok(tool_result) => Some(summarize_tool_output(&tool_result.content)),
1328 Err(err) => Some(err.to_string()),
1329 };
1330 let history_threshold_before_push = app.history.len();
1331 let active_in_flight = app.active_cell.is_some();
1332 let spillover_path = result
1333 .as_ref()
1334 .ok()
1335 .and_then(|r| r.metadata.as_ref())
1336 .and_then(|m| m.get("spillover_path"))
1337 .and_then(serde_json::Value::as_str)
1338 .map(std::path::PathBuf::from);
1339 let output_summary = output.as_deref().map(summarize_tool_output);
1340 let is_diff = output.as_deref().is_some_and(output_looks_like_diff);
1341 let mutation_receipt = result.as_ref().ok().and_then(|tool_result| {
1342 crate::tui::history::FileMutationReceipt::from_success(&app.workspace, tool_result)
1343 });
1344 let cell = if let Some(receipt) = mutation_receipt {
1345 let path = receipt
1346 .files
1347 .first()
1348 .map_or_else(|| "<file>".to_string(), |file| file.path.clone());
1349 let summary = receipt.semantic_summary();
1350 HistoryCell::Tool(ToolCell::PatchSummary(PatchSummaryCell {
1351 path,
1352 summary,
1353 status,
1354 error: None,
1355 receipt: Some(receipt),
1356 }))
1357 } else {
1358 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
1359 name: name.to_string(),
1360 status,
1361 input_summary: None,
1362 output,
1363 prompts: None,
1364 spillover_path,
1365 output_summary,
1366 is_diff,
1367 }))
1368 };
1369 app.add_message(cell);
1370 let cell_index = app.history.len().saturating_sub(1);
1371 app.tool_details_by_cell.insert(
1372 cell_index,
1373 ToolDetailRecord {
1374 tool_id: tool_id.to_string(),
1375 tool_name: name.to_string(),
1376 input: serde_json::Value::Null,
1377 output: match result.as_ref() {
1378 Ok(tool_result) => Some(tool_result.content.clone()),
1379 Err(err) => Some(err.to_string()),
1380 },
1381 },
1382 );
1383
1384 // Shift active-cell virtual indices forward by 1 to absorb the new
1385 // history cell. Without this, the next completion would address the
1386 // wrong entry.
1387 if active_in_flight {
1388 let threshold = history_threshold_before_push;
1389 for idx in app.tool_cells.values_mut() {
1390 if *idx >= threshold {
1391 *idx = idx.wrapping_add(1);
1392 }
1393 }
1394 for (cell_idx, _) in app.exploring_entries.values_mut() {
1395 if *cell_idx >= threshold {
1396 *cell_idx = cell_idx.wrapping_add(1);
1397 }
1398 }
1399 if let Some(idx) = app.exploring_cell.as_mut()
1400 && *idx >= threshold
1401 {
1402 *idx = idx.wrapping_add(1);
1403 }
1404 }
1405 }
1406
1407 fn tool_status_from_result(result: &Result<ToolResult, ToolError>) -> ToolStatus {
1408 match result.as_ref() {
1409 Ok(tool_result) if is_deferred_schema_hydration(tool_result) => ToolStatus::Hydrated,
1410 Ok(tool_result) => match tool_result.metadata.as_ref() {
1411 Some(meta)
1412 if meta
1413 .get("status")
1414 .and_then(|v| v.as_str())
1415 .is_some_and(|s| s == "Running") =>
1416 {
1417 ToolStatus::Running
1418 }
1419 _ => {
1420 if tool_result.success {
1421 ToolStatus::Success
1422 } else {
1423 ToolStatus::Failed
1424 }
1425 }
1426 },
1427 Err(_) => ToolStatus::Failed,
1428 }
1429 }
1430
1431 fn is_deferred_schema_hydration(tool_result: &ToolResult) -> bool {
1432 if !tool_result.success {
1433 return false;
1434 }
1435 let Some(metadata) = tool_result.metadata.as_ref() else {
1436 return false;
1437 };
1438 metadata
1439 .get("event")
1440 .and_then(serde_json::Value::as_str)
1441 .is_some_and(|event| event == "tool.schema_hydrated")
1442 && metadata
1443 .get("executed")
1444 .and_then(serde_json::Value::as_bool)
1445 .is_some_and(|executed| !executed)
1446 }
1447
1448 fn is_exploring_tool(name: &str) -> bool {
1449 matches!(name, "read_file" | "list_dir" | "grep_files" | "list_files")
1450 }
1451
1452 fn is_exec_tool(name: &str) -> bool {
1453 matches!(
1454 name,
1455 "exec_shell"
1456 | "exec_shell_wait"
1457 | "exec_shell_interact"
1458 | "exec_shell_cancel"
1459 | "exec_wait"
1460 | "exec_interact"
1461 )
1462 }
1463
1464 pub(super) fn refreshes_workspace_context_on_completion(name: &str) -> bool {
1465 matches!(
1466 name,
1467 "exec_shell"
1468 | "exec_shell_wait"
1469 | "exec_shell_interact"
1470 | "exec_shell_cancel"
1471 | "exec_wait"
1472 | "exec_interact"
1473 | "task_shell_start"
1474 | "task_shell_wait"
1475 | "write_file"
1476 | "edit_file"
1477 | "apply_patch"
1478 )
1479 }
1480
1481 pub(super) fn exploring_label(name: &str, input: &serde_json::Value) -> String {
1482 let fallback = format!("{name} tool");
1483 let obj = input.as_object();
1484 match name {
1485 "read_file" => obj
1486 .and_then(|o| o.get("path"))
1487 .and_then(|v| v.as_str())
1488 .map_or(fallback, |path| format!("Reading {path}")),
1489 "list_dir" => obj
1490 .and_then(|o| o.get("path"))
1491 .and_then(|v| v.as_str())
1492 .map_or("Listing directory".to_string(), |path| {
1493 format!("Listing {path}")
1494 }),
1495 "grep_files" => {
1496 let pattern = obj
1497 .and_then(|o| o.get("pattern"))
1498 .and_then(|v| v.as_str())
1499 .unwrap_or("pattern");
1500 format!("Searching for `{pattern}`")
1501 }
1502 "list_files" => "Listing files".to_string(),
1503 _ => fallback,
1504 }
1505 }
1506
1507 fn is_mcp_tool(name: &str) -> bool {
1508 name.starts_with("mcp_")
1509 }
1510
1511 fn is_view_image_tool(name: &str) -> bool {
1512 matches!(name, "view_image" | "view_image_file" | "view_image_tool")
1513 }
1514
1515 fn is_web_search_tool(name: &str) -> bool {
1516 matches!(name, "web_search" | "search_web" | "search" | "web.run")
1517 || name.ends_with("_web_search")
1518 }
1519
1520 fn web_search_query(input: &serde_json::Value) -> String {
1521 if let Some(searches) = input.get("search_query").and_then(|v| v.as_array())
1522 && let Some(first) = searches.first()
1523 && let Some(q) = first.get("q").and_then(|v| v.as_str())
1524 {
1525 return q.to_string();
1526 }
1527
1528 input
1529 .get("query")
1530 .or_else(|| input.get("q"))
1531 .or_else(|| input.get("search"))
1532 .and_then(|v| v.as_str())
1533 .unwrap_or("Web search")
1534 .to_string()
1535 }
1536
1537 fn review_target_label(input: &serde_json::Value) -> String {
1538 let target = input
1539 .get("target")
1540 .and_then(|v| v.as_str())
1541 .unwrap_or("review")
1542 .trim();
1543 let kind = input
1544 .get("kind")
1545 .and_then(|v| v.as_str())
1546 .unwrap_or("")
1547 .trim()
1548 .to_ascii_lowercase();
1549 let staged = input
1550 .get("staged")
1551 .and_then(|v| v.as_bool())
1552 .unwrap_or(false);
1553 let target_lower = target.to_ascii_lowercase();
1554
1555 if kind == "diff"
1556 || target_lower == "diff"
1557 || target_lower == "git diff"
1558 || target_lower == "staged"
1559 || target_lower == "cached"
1560 {
1561 if staged || target_lower == "staged" || target_lower == "cached" {
1562 return "git diff --cached".to_string();
1563 }
1564 return "git diff".to_string();
1565 }
1566
1567 target.to_string()
1568 }
1569
1570 fn parse_plan_input(input: &serde_json::Value) -> PlanSnapshot {
1571 PlanSnapshot::from_tool_input(input)
1572 }
1573
1574 fn parse_file_mutation_summary(semantic_name: &str, input: &serde_json::Value) -> (String, String) {
1575 if semantic_name != "apply_patch" {
1576 let path = input
1577 .get("path")
1578 .and_then(serde_json::Value::as_str)
1579 .filter(|path| !path.trim().is_empty())
1580 .unwrap_or("<file>")
1581 .to_string();
1582 let summary = match semantic_name {
1583 "write_file" => "Writing file",
1584 "edit_file" => "Editing file",
1585 _ => "Changing file",
1586 }
1587 .to_string();
1588 return (path, summary);
1589 }
1590 let patch_text = match normalize_apply_patch_input(input) {
1591 Ok(NormalizedApplyPatchInput::Replacement {
1592 entries: changes, ..
1593 }) => {
1594 let count = changes.len();
1595 let path = changes
1596 .first()
1597 .and_then(|c| c.get("path"))
1598 .and_then(|v| v.as_str())
1599 .map(str::to_string)
1600 .unwrap_or_else(|| "<file>".to_string());
1601 let label = if count <= 1 {
1602 path
1603 } else {
1604 format!("{count} files")
1605 };
1606 let summary = format!("Changes: {count} file(s)");
1607 return (label, summary);
1608 }
1609 Ok(NormalizedApplyPatchInput::Patch(patch)) => patch,
1610 Err(_) => "",
1611 };
1612 let paths = extract_patch_paths(patch_text);
1613 let path = input
1614 .get("path")
1615 .and_then(|v| v.as_str())
1616 .map(str::to_string)
1617 .or_else(|| {
1618 if paths.len() == 1 {
1619 paths.first().cloned()
1620 } else if paths.is_empty() {
1621 None
1622 } else {
1623 Some(format!("{} files", paths.len()))
1624 }
1625 })
1626 .unwrap_or_else(|| "<file>".to_string());
1627
1628 let (adds, removes) = count_patch_changes(patch_text);
1629 let summary = if adds == 0 && removes == 0 {
1630 "Patch applied".to_string()
1631 } else {
1632 format!("Changes: +{adds} / -{removes}")
1633 };
1634 (path, summary)
1635 }
1636
1637 fn extract_patch_paths(patch: &str) -> Vec<String> {
1638 let mut paths = Vec::new();
1639 for line in patch.lines() {
1640 if let Some(rest) = line.strip_prefix("+++ ") {
1641 let raw = rest.trim();
1642 if raw == "/dev/null" || raw == "dev/null" {
1643 continue;
1644 }
1645 let raw = raw.strip_prefix("b/").unwrap_or(raw);
1646 if !paths.contains(&raw.to_string()) {
1647 paths.push(raw.to_string());
1648 }
1649 } else if let Some(rest) = line.strip_prefix("diff --git ") {
1650 let parts: Vec<&str> = rest.split_whitespace().collect();
1651 if let Some(path) = parts.get(1).or_else(|| parts.first()) {
1652 let raw = path.trim();
1653 let raw = raw
1654 .strip_prefix("b/")
1655 .or_else(|| raw.strip_prefix("a/"))
1656 .unwrap_or(raw);
1657 if !paths.contains(&raw.to_string()) {
1658 paths.push(raw.to_string());
1659 }
1660 }
1661 }
1662 }
1663 paths
1664 }
1665
1666 fn count_patch_changes(patch: &str) -> (usize, usize) {
1667 let mut adds = 0;
1668 let mut removes = 0;
1669 for line in patch.lines() {
1670 if line.starts_with("+++") || line.starts_with("---") {
1671 continue;
1672 }
1673 if line.starts_with('+') {
1674 adds += 1;
1675 } else if line.starts_with('-') {
1676 removes += 1;
1677 }
1678 }
1679 (adds, removes)
1680 }
1681
1682 fn exec_command_from_input(input: &serde_json::Value) -> Option<String> {
1683 input
1684 .get("command")
1685 .and_then(|v| v.as_str())
1686 .map(std::string::ToString::to_string)
1687 }
1688
1689 fn exec_target_from_input(input: &serde_json::Value) -> String {
1690 exec_command_from_input(input).unwrap_or_else(|| {
1691 input
1692 .get("task_id")
1693 .or_else(|| input.get("id"))
1694 .and_then(|v| v.as_str())
1695 .map(|task_id| format!("command {task_id}"))
1696 .unwrap_or_else(|| "command".to_string())
1697 })
1698 }
1699
1700 fn exec_source_from_input(input: &serde_json::Value) -> ExecSource {
1701 match input.get("source").and_then(|v| v.as_str()) {
1702 Some(source) if source.eq_ignore_ascii_case("user") => ExecSource::User,
1703 _ => ExecSource::Assistant,
1704 }
1705 }
1706
1707 fn exec_interaction_summary(name: &str, input: &serde_json::Value) -> Option<(String, bool)> {
1708 let command = exec_target_from_input(input);
1709 let command_display = format!("\"{command}\"");
1710 let interaction_input = input
1711 .get("input")
1712 .or_else(|| input.get("stdin"))
1713 .or_else(|| input.get("data"))
1714 .and_then(|v| v.as_str());
1715
1716 let is_wait_tool = matches!(name, "exec_shell_wait" | "exec_wait");
1717 let is_interact_tool = matches!(name, "exec_shell_interact" | "exec_interact");
1718 let is_cancel_tool = name == "exec_shell_cancel";
1719
1720 if is_cancel_tool {
1721 let summary = if input.get("all").and_then(serde_json::Value::as_bool) == Some(true) {
1722 "Cancelled all background commands".to_string()
1723 } else if let Some(task_id) = input
1724 .get("task_id")
1725 .or_else(|| input.get("id"))
1726 .and_then(serde_json::Value::as_str)
1727 {
1728 format!("Cancelled command {task_id}")
1729 } else {
1730 "Cancelled background command".to_string()
1731 };
1732 return Some((summary, false));
1733 }
1734
1735 if is_interact_tool || interaction_input.is_some() {
1736 let preview = interaction_input.map(summarize_interaction_input);
1737 let summary = if let Some(preview) = preview {
1738 format!("Interacted with {command_display}, sent {preview}")
1739 } else {
1740 format!("Interacted with {command_display}")
1741 };
1742 return Some((summary, false));
1743 }
1744
1745 if is_wait_tool || input.get("wait").and_then(serde_json::Value::as_bool) == Some(true) {
1746 if exec_command_from_input(input).is_none()
1747 && let Some(task_id) = input
1748 .get("task_id")
1749 .or_else(|| input.get("id"))
1750 .and_then(|v| v.as_str())
1751 {
1752 return Some((format!("Waiting for command {task_id}"), true));
1753 }
1754 return Some((format!("Waited for {command_display}"), true));
1755 }
1756
1757 None
1758 }
1759
1760 fn summarize_interaction_input(input: &str) -> String {
1761 let mut single_line = input.replace('\r', "");
1762 single_line = single_line.replace('\n', "\\n");
1763 single_line = single_line.replace('\"', "'");
1764 let max_len = 80;
1765 if single_line.chars().count() <= max_len {
1766 return format!("\"{single_line}\"");
1767 }
1768 let mut out = String::new();
1769 for ch in single_line.chars().take(max_len.saturating_sub(3)) {
1770 out.push(ch);
1771 }
1772 out.push_str("...");
1773 format!("\"{out}\"")
1774 }
1775
1776 fn exec_is_background(input: &serde_json::Value) -> bool {
1777 input
1778 .get("background")
1779 .and_then(serde_json::Value::as_bool)
1780 .unwrap_or(false)
1781 }
1782
1783 #[cfg(test)]
1784 mod tests {
1785 use super::*;
1786 use crate::tools::plan::StepStatus;
1787 use serde_json::json;
1788
1789 #[cfg(unix)]
1790 fn hook_log_lines_eventually(path: &std::path::Path, expected: usize) -> Vec<String> {
1791 for _ in 0..100 {
1792 let lines = std::fs::read_to_string(path)
1793 .unwrap_or_default()
1794 .lines()
1795 .map(str::to_string)
1796 .collect::<Vec<_>>();
1797 if lines.len() >= expected {
1798 return lines;
1799 }
1800 std::thread::sleep(std::time::Duration::from_millis(10));
1801 }
1802 std::fs::read_to_string(path)
1803 .unwrap_or_default()
1804 .lines()
1805 .map(str::to_string)
1806 .collect()
1807 }
1808
1809 /// A UI-ignored completion is still a completion. `tool_call_after` and
1810 /// `on_error` must fire for it — exactly once — or the documented "fires
1811 /// after each tool call" silently excludes repeated `wait` and background
1812 /// results, which is the class of call an observer most wants to record.
1813 #[cfg(unix)]
1814 #[test]
1815 fn ignored_tool_calls_still_fire_after_and_error_hooks_once() {
1816 use crate::hooks::{Hook, HookEvent, HookExecutor, HooksConfig};
1817
1818 let dir = tempfile::tempdir().expect("tempdir");
1819 let after_log = dir.path().join("after.log");
1820 let error_log = dir.path().join("error.log");
1821 let script = |path: &std::path::Path| {
1822 format!(
1823 "printf '%s\\n' \"$DEEPSEEK_TOOL_CALL_ID\" >> {}",
1824 path.display()
1825 )
1826 };
1827
1828 let mut app = crate::test_support::test_app_with_options(
1829 crate::test_support::test_tui_options(dir.path()),
1830 );
1831 app.workspace = dir.path().to_path_buf();
1832 app.hooks = HookExecutor::new(
1833 HooksConfig {
1834 enabled: true,
1835 hooks: vec![
1836 Hook::new(HookEvent::ToolCallAfter, &script(&after_log)).with_name("after"),
1837 Hook::new(HookEvent::OnError, &script(&error_log)).with_name("error"),
1838 ],
1839 ..HooksConfig::default()
1840 },
1841 dir.path().to_path_buf(),
1842 );
1843
1844 let id = "call_ignored_1";
1845 app.ignored_tool_calls.insert(id.to_string());
1846 let failed: Result<ToolResult, ToolError> = Ok(ToolResult::error("boom"));
1847
1848 handle_tool_call_complete(&mut app, id, "exec_shell", &failed);
1849
1850 // The presentation state still consumed the id...
1851 assert!(!app.ignored_tool_calls.contains(id));
1852 // ...and both observers saw the call, once each.
1853 let after = hook_log_lines_eventually(&after_log, 1);
1854 let errors = hook_log_lines_eventually(&error_log, 1);
1855 assert_eq!(after, vec![id]);
1856 assert_eq!(errors, vec![id]);
1857
1858 // A successful ignored completion fires `tool_call_after` only.
1859 let second = "call_ignored_2";
1860 app.ignored_tool_calls.insert(second.to_string());
1861 handle_tool_call_complete(
1862 &mut app,
1863 second,
1864 "exec_shell",
1865 &Ok(ToolResult::success("ok")),
1866 );
1867 let after = hook_log_lines_eventually(&after_log, 2);
1868 let errors = hook_log_lines_eventually(&error_log, 1);
1869 assert_eq!(after, vec![id, second]);
1870 assert_eq!(errors, vec![id]);
1871 }
1872
1873 #[test]
1874 fn adaptive_evidence_late_foreign_and_duplicate_completions_are_ignored() {
1875 let result = Ok(ToolResult::success("bounded").with_metadata(json!({
1876 "artifact_session_id": "session-a",
1877 "artifact_id": "art_call-a"
1878 })));
1879 assert!(evidence_completion_identity_should_be_ignored(
1880 Some("session-b"),
1881 std::iter::empty(),
1882 "call-a",
1883 &result,
1884 ));
1885 assert!(evidence_completion_identity_should_be_ignored(
1886 Some("session-a"),
1887 [("art_call-a", "call-a")],
1888 "call-a",
1889 &result,
1890 ));
1891 assert!(!evidence_completion_identity_should_be_ignored(
1892 Some("session-a"),
1893 std::iter::empty(),
1894 "call-a",
1895 &result,
1896 ));
1897 }
1898
1899 #[test]
1900 fn web_search_presentation_reads_source_degradation_and_citation_count() {
1901 let presentation = web_search_presentation(
1902 &json!({
1903 "source": "provider-native/xai/grok-4.5",
1904 "results": [
1905 {"ref_id": "web_a", "url": "https://example.com/a"},
1906 {"ref_id": "web_b", "url": "https://example.com/b"}
1907 ],
1908 "receipt": {
1909 "degraded": [
1910 {"kind": "backend_unavailable", "backend": "provider_native"},
1911 {"kind": "backend_fallback", "from": "provider_native", "to": "tavily"}
1912 ]
1913 }
1914 })
1915 .to_string(),
1916 );
1917
1918 assert_eq!(
1919 presentation.source.as_deref(),
1920 Some("provider-native/xai/grok-4.5")
1921 );
1922 assert_eq!(
1923 presentation.degraded.as_deref(),
1924 Some("provider_native unavailable; provider_native -> tavily")
1925 );
1926 assert_eq!(presentation.ref_count, 2);
1927 }
1928
1929 #[test]
1930 fn web_run_presentation_reads_nested_search_receipts() {
1931 let presentation = web_search_presentation(
1932 &json!({
1933 "search_query": [{
1934 "source": "duckduckgo",
1935 "results": [{"ref_id": "web_a"}],
1936 "receipt": {
1937 "degraded": [{"kind": "knob_ignored", "knob": "recency"}]
1938 }
1939 }]
1940 })
1941 .to_string(),
1942 );
1943
1944 assert_eq!(presentation.source.as_deref(), Some("duckduckgo"));
1945 assert_eq!(presentation.degraded.as_deref(), Some("recency ignored"));
1946 assert_eq!(presentation.ref_count, 1);
1947 }
1948
1949 #[test]
1950 fn parse_plan_input_accepts_legacy_payload() {
1951 let snapshot = parse_plan_input(&json!({
1952 "explanation": "Legacy explanation",
1953 "plan": [
1954 { "step": "inspect", "status": "completed" },
1955 { "step": "patch", "status": "in_progress" }
1956 ]
1957 }));
1958
1959 assert_eq!(snapshot.explanation.as_deref(), Some("Legacy explanation"));
1960 assert_eq!(snapshot.items.len(), 2);
1961 assert_eq!(snapshot.items[0].status, StepStatus::Completed);
1962 assert_eq!(snapshot.items[1].status, StepStatus::InProgress);
1963 }
1964
1965 #[test]
1966 fn parse_plan_input_extracts_rich_artifact_fields() {
1967 let snapshot = parse_plan_input(&json!({
1968 "title": " PlanArtifact ",
1969 "objective": "Make Plan mode reviewable",
1970 "context_summary": "Grounded in issue #2691",
1971 "sources_used": [" gh issue view 2691 ", ""],
1972 "critical_files": ["crates/tui/src/tools/plan.rs"],
1973 "constraints": ["No secrets"],
1974 "recommended_approach": "Enrich update_plan",
1975 "verification_plan": "Run focused tests",
1976 "risks_and_unknowns": "Replay may drift",
1977 "handoff_packet": "Continue with session replay",
1978 "plan": [
1979 { "step": " ", "status": "completed" },
1980 { "step": "render all fields", "status": "weird" }
1981 ]
1982 }));
1983
1984 assert_eq!(snapshot.title.as_deref(), Some("PlanArtifact"));
1985 assert_eq!(snapshot.sources_used, vec!["gh issue view 2691"]);
1986 assert_eq!(
1987 snapshot.critical_files,
1988 vec!["crates/tui/src/tools/plan.rs"]
1989 );
1990 assert_eq!(snapshot.constraints, vec!["No secrets"]);
1991 assert_eq!(
1992 snapshot.verification_plan.as_deref(),
1993 Some("Run focused tests")
1994 );
1995 assert_eq!(snapshot.items.len(), 1);
1996 assert_eq!(snapshot.items[0].step, "render all fields");
1997 assert_eq!(snapshot.items[0].status, StepStatus::Pending);
1998 }
1999
2000 #[test]
2001 fn parse_patch_summary_treats_replace_and_legacy_changes_equally() {
2002 let replacements = json!([{
2003 "path": "src/lib.rs",
2004 "content": "fn replacement() {}\n"
2005 }]);
2006
2007 let canonical =
2008 parse_file_mutation_summary("apply_patch", &json!({"replace": replacements.clone()}));
2009 let legacy = parse_file_mutation_summary("apply_patch", &json!({"changes": replacements}));
2010
2011 assert_eq!(canonical, legacy);
2012 }
2013
2014 // ── #3031: "(no output)" placeholder must not defeat compact rendering ─
2015
2016 #[test]
2017 fn visible_tool_output_maps_no_output_placeholder_to_none() {
2018 assert_eq!(visible_tool_output("(no output)"), None);
2019 assert_eq!(visible_tool_output(" (no output)\n"), None);
2020 }
2021
2022 #[test]
2023 fn visible_tool_output_preserves_real_content() {
2024 assert_eq!(
2025 visible_tool_output("compiled 3 crates").as_deref(),
2026 Some("compiled 3 crates")
2027 );
2028 // Output that merely CONTAINS the placeholder is real output.
2029 assert_eq!(
2030 visible_tool_output("step 1: (no output) — continuing").as_deref(),
2031 Some("step 1: (no output) — continuing")
2032 );
2033 assert_eq!(visible_tool_output("").as_deref(), Some(""));
2034 }
2035
2036 #[test]
2037 fn exec_cell_without_output_suppresses_placeholder_in_live_mode() {
2038 use crate::tui::history::{ExecCell, ExecSource, ToolCell, ToolStatus};
2039
2040 let cell = ToolCell::Exec(ExecCell {
2041 command: "true".to_string(),
2042 status: ToolStatus::Success,
2043 output: None,
2044 live_output: None,
2045 shell_task_id: None,
2046 owner_agent_id: None,
2047 owner_agent_name: None,
2048 started_at: None,
2049 duration_ms: Some(120),
2050 stale_elapsed_since_output_ms: None,
2051 source: ExecSource::Assistant,
2052 interaction: None,
2053 output_summary: None,
2054 });
2055
2056 let live: String = cell
2057 .lines(80)
2058 .iter()
2059 .flat_map(|line| line.spans.iter().map(|s| s.content.to_string()))
2060 .collect();
2061 assert!(
2062 !live.contains("(no output)"),
2063 "Live mode must suppress the placeholder: {live:?}"
2064 );
2065
2066 let transcript: String = cell
2067 .transcript_lines(80)
2068 .iter()
2069 .flat_map(|line| line.spans.iter().map(|s| s.content.to_string()))
2070 .collect();
2071 assert!(
2072 transcript.contains("(no output)"),
2073 "Transcript mode still records the placeholder: {transcript:?}"
2074 );
2075 }
2076
2077 /// #455 — `exit_code` conditions must only ever see a real, reported exit
2078 /// code. `tool_call_after` used to hard-code `None`, which made every
2079 /// `{ type = "exit_code" }` condition permanently unmatchable.
2080 #[test]
2081 fn reported_tool_exit_code_reads_only_real_metadata_codes() {
2082 let with_code = Ok(ToolResult {
2083 content: "boom".to_string(),
2084 success: false,
2085 metadata: Some(serde_json::json!({ "exit_code": 127 })),
2086 });
2087 assert_eq!(super::reported_tool_exit_code(&with_code), Some(127));
2088
2089 // Zero is a real code, not a missing one.
2090 let zero = Ok(ToolResult {
2091 content: "ok".to_string(),
2092 success: true,
2093 metadata: Some(serde_json::json!({ "exit_code": 0 })),
2094 });
2095 assert_eq!(super::reported_tool_exit_code(&zero), Some(0));
2096
2097 // Tools that report no exit code stay `None` — never synthesized from
2098 // the success flag.
2099 let no_metadata = Ok(ToolResult::error("failed"));
2100 assert_eq!(super::reported_tool_exit_code(&no_metadata), None);
2101
2102 let null_code = Ok(ToolResult {
2103 content: String::new(),
2104 success: true,
2105 metadata: Some(serde_json::json!({ "exit_code": serde_json::Value::Null })),
2106 });
2107 assert_eq!(super::reported_tool_exit_code(&null_code), None);
2108
2109 let wrong_type = Ok(ToolResult {
2110 content: String::new(),
2111 success: false,
2112 metadata: Some(serde_json::json!({ "exit_code": "127" })),
2113 });
2114 assert_eq!(super::reported_tool_exit_code(&wrong_type), None);
2115
2116 // A Windows crash code does not fit in an `i32`, but it is a real code
2117 // and a hook scoped to it must be able to see it.
2118 let windows_crash = Ok(ToolResult {
2119 content: String::new(),
2120 success: false,
2121 metadata: Some(serde_json::json!({ "exit_code": 3_221_225_477_i64 })),
2122 });
2123 assert_eq!(
2124 super::reported_tool_exit_code(&windows_crash),
2125 Some(3_221_225_477)
2126 );
2127
2128 // A transport-level tool error has no metadata at all.
2129 let errored: Result<ToolResult, ToolError> =
2130 Err(ToolError::execution_failed("no such tool"));
2131 assert_eq!(super::reported_tool_exit_code(&errored), None);
2132 }
2133 }
2134
2134 lines RUST