返回 CodeWhale
agent_details.rs
根目录 / crates / tui / src / tui / agent_details.rs
1 //! Safe, bounded Agent Details projection (#2889).
2 //!
3 //! Since the v0.9.7 "one agent, one destination" inversion, activating an
4 //! agent row opens the agent's transcript surface directly
5 //! (`crate::tui::agent_transcript`); this bounded projection is the secondary
6 //! action, reached from the transcript via the same Alt+V chord that opens
7 //! the transcript from here.
8
9 use std::path::{Component, Path};
10
11 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
12 use ratatui::{buffer::Buffer, layout::Rect};
13
14 use crate::tools::subagent::{SubAgentResult, SubAgentStatus, localized_whale_display_names};
15 use crate::tui::app::{
16 AgentCurrentActivityStatus, AgentProgressMeta, App, bound_agent_activity_text,
17 };
18 use crate::tui::pager::PagerView;
19 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
20
21 pub(crate) struct AgentDetailsProjection {
22 pub(crate) title: String,
23 pub(crate) body: String,
24 pub(crate) transcript_available: bool,
25 }
26
27 /// Pager-backed details view with a distinct close receipt and an explicit
28 /// exact-transcript action.
29 pub(crate) struct AgentDetailsView {
30 pager: PagerView,
31 agent_id: String,
32 transcript_available: bool,
33 }
34
35 impl AgentDetailsView {
36 fn new(projection: AgentDetailsProjection, agent_id: impl Into<String>, width: u16) -> Self {
37 let agent_id = agent_id.into();
38 let pager =
39 PagerView::from_text(projection.title, &projection.body, width.saturating_sub(2))
40 .with_copy_text(projection.body);
41 Self {
42 pager,
43 agent_id,
44 transcript_available: projection.transcript_available,
45 }
46 }
47
48 #[cfg(test)]
49 fn body_text(&self) -> String {
50 self.pager.body_text()
51 }
52
53 #[cfg(test)]
54 fn title(&self) -> &str {
55 self.pager.title()
56 }
57 }
58
59 impl ModalView for AgentDetailsView {
60 fn kind(&self) -> ModalKind {
61 ModalKind::Pager
62 }
63
64 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
65 if matches!(key.code, KeyCode::Char('v' | 'V'))
66 && key.modifiers.contains(KeyModifiers::ALT)
67 && self.transcript_available
68 {
69 return ViewAction::Emit(ViewEvent::OpenAgentTranscript {
70 agent_id: self.agent_id.clone(),
71 });
72 }
73 if matches!(key.code, KeyCode::Esc | KeyCode::Left)
74 || (key.code == KeyCode::Char('q') && key.modifiers.is_empty())
75 {
76 return ViewAction::EmitAndClose(ViewEvent::AgentDetailsClosed {
77 agent_id: self.agent_id.clone(),
78 });
79 }
80 self.pager.handle_key(key)
81 }
82
83 fn handle_paste(&mut self, text: &str) -> bool {
84 self.pager.handle_paste(text)
85 }
86
87 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
88 self.pager.handle_mouse(mouse)
89 }
90
91 fn render(&self, area: Rect, buf: &mut Buffer) {
92 self.pager.render(area, buf);
93 }
94
95 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
96 self
97 }
98 }
99
100 pub(crate) fn open_agent_details(app: &mut App, agent_id: &str) -> bool {
101 let Some(projection) = project_agent_details(app, agent_id) else {
102 return false;
103 };
104 let width = app
105 .viewport
106 .last_transcript_area
107 .map(|area| area.width)
108 .unwrap_or(80);
109 app.view_stack
110 .push(AgentDetailsView::new(projection, agent_id, width));
111 true
112 }
113
114 pub(crate) fn safe_agent_display_name(app: &App, agent_id: &str) -> String {
115 let generated = localized_whale_display_names(
116 app.subagent_cache
117 .iter()
118 .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())),
119 app.ui_locale.tag(),
120 );
121 app.subagent_cache
122 .iter()
123 .find(|agent| agent.agent_id == agent_id)
124 .and_then(crate::tui::sidebar::dispatched_agent_name)
125 .map(str::to_string)
126 .or_else(|| generated.get(agent_id).cloned())
127 .or_else(|| app.agent_label_map.get(agent_id).cloned())
128 .and_then(|name| safe_child_value(app, &name))
129 .unwrap_or_else(|| "Agent".to_string())
130 }
131
132 pub(crate) fn project_agent_details(app: &App, agent_id: &str) -> Option<AgentDetailsProjection> {
133 // The Agents sidebar is the primary worker surface. Consume its exact row
134 // projection so status precedence, model, elapsed time, and steps cannot
135 // diverge between the row and the popup opened from it.
136 let surface_row = crate::tui::sidebar::sidebar_agent_rows(app)
137 .into_iter()
138 .find(|row| row.id == agent_id)?;
139 let agent = app
140 .subagent_cache
141 .iter()
142 .find(|agent| agent.agent_id == agent_id);
143 let meta = app.agent_progress_meta.get(agent_id);
144
145 let display_name = safe_child_value(app, &surface_row.name).unwrap_or_else(|| "Agent".into());
146 let mut lines = Vec::new();
147
148 if let Some(agent) = agent {
149 push_safe_line(app, &mut lines, "Assignment", &agent.assignment.objective);
150
151 // The child's own route receipt is the authoritative resolved truth
152 // for a fleet-dispatched child; `assignment.role` is only the caller's
153 // advisory token and may be absent on a `type`-only dispatch.
154 let route = agent.child_route.as_ref();
155 let role = route
156 .map(|route| route.canonical_role.trim())
157 .filter(|role| !role.is_empty())
158 .or(agent.assignment.role.as_deref())
159 .map(str::trim)
160 .filter(|role| !role.is_empty());
161 if let Some(role) = role {
162 push_safe_line(app, &mut lines, "Role", role);
163 }
164 // Profile is the member that actually resolved. A caller alias/model
165 // label is useful evidence but must not be presented as who ran.
166 let profile = route
167 .and_then(|route| route.resolved_profile_id.as_deref())
168 .map(str::trim)
169 .filter(|profile| !profile.is_empty())
170 .or_else(|| {
171 route
172 .and_then(|route| route.requested_profile.as_deref())
173 .map(str::trim)
174 .filter(|profile| !profile.is_empty())
175 })
176 .unwrap_or_else(|| agent.agent_type.as_str());
177 push_safe_line(app, &mut lines, "Profile", profile);
178 if let Some(requested) = route
179 .and_then(|route| route.requested_profile.as_deref())
180 .map(str::trim)
181 .filter(|requested| !requested.is_empty() && !requested.eq_ignore_ascii_case(profile))
182 {
183 push_safe_line(app, &mut lines, "Requested as", requested);
184 }
185 push_safe_line(app, &mut lines, "Type", agent.agent_type.as_str());
186 lines.push(format!("Parent: {}", safe_parent_name(app, agent)));
187 } else {
188 lines.push(format!("Parent: {}", safe_parent_from_meta(app, meta)));
189 }
190
191 let mut state = vec![surface_row.status.clone()];
192 if let Some(duration_ms) = surface_row.duration_ms {
193 state.push(format!(
194 "elapsed {}",
195 crate::elapsed::format_elapsed_ms(duration_ms)
196 ));
197 }
198 state.push(format!(
199 "{} {}",
200 surface_row.steps_taken,
201 if surface_row.steps_taken == 1 {
202 "step"
203 } else {
204 "steps"
205 }
206 ));
207 lines.push(format!("State: {}", state.join(" · ")));
208
209 if let Some(meta) = meta
210 && let Some(provider) = meta.resolved_provider.as_deref()
211 {
212 push_safe_line(app, &mut lines, "Provider", provider);
213 } else if let Some(route) = agent.and_then(|agent| agent.child_route.as_ref())
214 && !route.provider_id.trim().is_empty()
215 {
216 push_safe_line(app, &mut lines, "Provider", &route.provider_id);
217 }
218 // Model truth: the resolved route model wins, then the Work-row receipt
219 // (which itself carries `agent.model` or usage-envelope route evidence),
220 // then the child's spawn model.
221 let model = agent
222 .and_then(|agent| agent.child_route.as_ref())
223 .map(|route| route.model_id.clone())
224 .filter(|model| !model.trim().is_empty())
225 .or_else(|| surface_row.model.clone())
226 .or_else(|| {
227 agent
228 .map(|agent| agent.model.clone())
229 .filter(|model| !model.trim().is_empty())
230 })
231 .or_else(|| meta.and_then(|meta| meta.resolved_model.clone()));
232 if let Some(model) = model {
233 push_safe_line(app, &mut lines, "Model", &model);
234 }
235
236 if let Some(agent) = agent {
237 if let Some(workspace) = agent.workspace.as_deref()
238 && let Some(workspace) = safe_workspace(app, workspace)
239 {
240 lines.push(format!("Workspace: {workspace}"));
241 }
242 if let Some(branch) = agent.git_branch.as_deref() {
243 push_safe_line(app, &mut lines, "Branch", branch);
244 }
245 }
246
247 if let Some(activity) = meta.and_then(|meta| meta.current_activity.as_ref())
248 && !matches!(
249 activity.status,
250 AgentCurrentActivityStatus::Done
251 | AgentCurrentActivityStatus::Failed
252 | AgentCurrentActivityStatus::Canceled
253 | AgentCurrentActivityStatus::Interrupted
254 | AgentCurrentActivityStatus::Waiting
255 )
256 {
257 let mut current = vec![activity_status_label(activity.status, app.ui_locale).into_owned()];
258 if let Some(tool) = activity
259 .current_tool
260 .as_deref()
261 .and_then(|tool| safe_child_value(app, tool))
262 {
263 current.push(tool);
264 }
265 if let Some(step) = activity.step {
266 current.push(format!("step {step}"));
267 }
268 if let Some(detail) = activity
269 .detail
270 .as_deref()
271 .and_then(|detail| safe_child_value(app, detail))
272 && !detail.starts_with("started ")
273 && !current.iter().any(|part| part == &detail)
274 {
275 current.push(detail);
276 }
277 lines.push(format!("Current: {}", current.join(" · ")));
278 }
279
280 if let Some(meta) = meta {
281 for action in meta.recent_actions.iter().rev().take(3).rev() {
282 if let Some(tool) = safe_child_value(app, &action.tool) {
283 lines.push(format!(
284 "Recent: {} {tool} · step {}",
285 if action.ok { "✓" } else { "!" },
286 action.step
287 ));
288 }
289 }
290 }
291
292 if let Some(question) = pending_question(agent, meta)
293 && let Some(question) = safe_child_value(app, question)
294 {
295 lines.push(format!("Pending question: {question}"));
296 }
297 if let Some(blocker) = blocker(agent, meta)
298 && let Some(blocker) = safe_child_value(app, blocker)
299 {
300 lines.push(format!("Blocker: {blocker}"));
301 }
302 if let Some(summary) = terminal_summary(agent, meta)
303 && let Some(summary) = safe_child_value(app, summary)
304 {
305 lines.push(format!("Summary: {summary}"));
306 }
307
308 let transcript_available =
309 crate::tui::mouse_ui::agent_transcript_evidence_available(app, agent_id);
310 if transcript_available {
311 // Platform glyph via display_chord (⌥V on macOS, Alt+V elsewhere) —
312 // never the dual "Alt/⌥V" spelling, and cap:verb not a sentence.
313 let chord = crate::tui::shell_key_routing::tool_details_chord();
314 lines.push(format!("Exact evidence: available · {chord}:transcript"));
315 } else {
316 lines.push("Exact evidence: unavailable".to_string());
317 }
318
319 Some(AgentDetailsProjection {
320 title: format!("Agent Details — {display_name}"),
321 body: lines.join("\n"),
322 transcript_available,
323 })
324 }
325
326 fn push_safe_line(app: &App, lines: &mut Vec<String>, label: &str, value: &str) {
327 if let Some(value) = safe_child_value(app, value) {
328 lines.push(format!("{label}: {value}"));
329 }
330 }
331
332 fn safe_child_value(app: &App, value: &str) -> Option<String> {
333 let bounded = bound_agent_activity_text(value);
334 let scrubbed = scrub_raw_agent_ids(app, &bounded);
335 let trimmed = scrubbed.trim();
336 if trimmed.is_empty()
337 || matches!(
338 trimmed.to_ascii_lowercase().as_str(),
339 "none" | "(none)" | "n/a" | "unknown" | "not set" | "not available" | "-"
340 )
341 {
342 None
343 } else {
344 Some(trimmed.to_string())
345 }
346 }
347
348 fn scrub_raw_agent_ids(app: &App, value: &str) -> String {
349 let mut scrubbed = value.to_string();
350 let mut ids: Vec<&str> = app
351 .subagent_cache
352 .iter()
353 .map(|agent| agent.agent_id.as_str())
354 .chain(
355 app.subagent_cache
356 .iter()
357 .filter_map(|agent| agent.parent_run_id.as_deref()),
358 )
359 .chain(app.agent_progress_meta.keys().map(String::as_str))
360 .chain(
361 app.agent_progress_meta
362 .values()
363 .filter_map(|meta| meta.parent_run_id.as_deref()),
364 )
365 .chain(app.agent_progress.keys().map(String::as_str))
366 .chain(app.agent_label_map.keys().map(String::as_str))
367 .collect();
368 ids.sort_unstable_by_key(|id| std::cmp::Reverse(id.len()));
369 ids.dedup();
370 for id in ids {
371 if !id.is_empty() {
372 scrubbed = scrubbed.replace(id, "agent");
373 }
374 }
375
376 let mut output = String::with_capacity(scrubbed.len());
377 let mut token = String::new();
378 let flush = |token: &mut String, output: &mut String| {
379 if token.starts_with("agent_")
380 || token.starts_with("agent-")
381 || token.starts_with("worker:agent_")
382 || token.starts_with("worker:agent-")
383 {
384 output.push_str("agent");
385 } else {
386 output.push_str(token);
387 }
388 token.clear();
389 };
390 for ch in scrubbed.chars() {
391 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':') {
392 token.push(ch);
393 } else {
394 flush(&mut token, &mut output);
395 output.push(ch);
396 }
397 }
398 flush(&mut token, &mut output);
399 output
400 }
401
402 fn safe_parent_name(app: &App, agent: &SubAgentResult) -> String {
403 match agent.parent_run_id.as_deref() {
404 Some(parent_id)
405 if app
406 .subagent_cache
407 .iter()
408 .any(|candidate| candidate.agent_id == parent_id) =>
409 {
410 safe_agent_display_name(app, parent_id)
411 }
412 Some(_) if agent.spawn_depth > 1 => "parent agent".to_string(),
413 _ => "primary session".to_string(),
414 }
415 }
416
417 fn safe_parent_from_meta(app: &App, meta: Option<&AgentProgressMeta>) -> String {
418 match meta.and_then(|meta| meta.parent_run_id.as_deref()) {
419 Some(parent_id)
420 if app
421 .subagent_cache
422 .iter()
423 .any(|candidate| candidate.agent_id == parent_id) =>
424 {
425 safe_agent_display_name(app, parent_id)
426 }
427 Some(_) => "parent agent".to_string(),
428 None => "primary session".to_string(),
429 }
430 }
431
432 fn activity_status_label(
433 status: AgentCurrentActivityStatus,
434 locale: codewhale_localization::Locale,
435 ) -> std::borrow::Cow<'static, str> {
436 if status == AgentCurrentActivityStatus::Parked {
437 return codewhale_localization::tr(
438 locale,
439 codewhale_localization::MessageId::AgentStatusParked,
440 );
441 }
442 std::borrow::Cow::Borrowed(match status {
443 AgentCurrentActivityStatus::Queued => "queued",
444 AgentCurrentActivityStatus::Starting => "starting",
445 AgentCurrentActivityStatus::Running => "running",
446 AgentCurrentActivityStatus::ModelWait => "waiting for model",
447 AgentCurrentActivityStatus::RunningTool => "running tool",
448 AgentCurrentActivityStatus::Waiting => "waiting for input",
449 AgentCurrentActivityStatus::Done => "completed",
450 AgentCurrentActivityStatus::Failed => "failed",
451 AgentCurrentActivityStatus::Canceled => "canceled",
452 AgentCurrentActivityStatus::Interrupted => "interrupted",
453 AgentCurrentActivityStatus::Parked => unreachable!("handled above"),
454 })
455 }
456
457 fn pending_question<'a>(
458 agent: Option<&'a SubAgentResult>,
459 meta: Option<&'a AgentProgressMeta>,
460 ) -> Option<&'a str> {
461 // A parked child carries a `needs_input` note that is phrased as a
462 // question but asks the *operator* nothing — it is the resume recipe.
463 // Reporting it as a pending question is the #5906 complaint verbatim, so
464 // this surface reports the parked state and its recovery instead.
465 if agent.is_some_and(crate::tui::subagent_routing::subagent_is_parked) {
466 return None;
467 }
468 agent
469 .and_then(|agent| agent.needs_input.as_ref())
470 .map(|needs_input| needs_input.question.as_str())
471 .or_else(|| {
472 meta.and_then(|meta| meta.current_activity.as_ref())
473 .filter(|activity| activity.status == AgentCurrentActivityStatus::Waiting)
474 .and_then(|activity| activity.detail.as_deref())
475 })
476 .or_else(|| match agent.map(|agent| &agent.status) {
477 Some(SubAgentStatus::Interrupted(reason)) => Some(reason.as_str()),
478 _ => None,
479 })
480 }
481
482 fn blocker<'a>(
483 agent: Option<&'a SubAgentResult>,
484 meta: Option<&'a AgentProgressMeta>,
485 ) -> Option<&'a str> {
486 match agent.map(|agent| &agent.status) {
487 Some(SubAgentStatus::Failed(error) | SubAgentStatus::Interrupted(error)) => {
488 Some(error.as_str())
489 }
490 Some(SubAgentStatus::BudgetExhausted) => Some("worker budget exhausted"),
491 _ => meta
492 .and_then(|meta| meta.current_activity.as_ref())
493 .filter(|activity| activity.status == AgentCurrentActivityStatus::Failed)
494 .and_then(|activity| activity.detail.as_deref()),
495 }
496 }
497
498 fn terminal_summary<'a>(
499 agent: Option<&'a SubAgentResult>,
500 meta: Option<&'a AgentProgressMeta>,
501 ) -> Option<&'a str> {
502 let agent = agent?;
503 if !matches!(
504 agent.status,
505 SubAgentStatus::Completed | SubAgentStatus::Cancelled
506 ) {
507 return None;
508 }
509 agent.result.as_deref().or_else(|| {
510 meta.and_then(|meta| meta.current_activity.as_ref())
511 .and_then(|activity| activity.detail.as_deref())
512 })
513 }
514
515 fn safe_workspace(app: &App, workspace: &Path) -> Option<String> {
516 if workspace.as_os_str().is_empty() {
517 return None;
518 }
519 if workspace == app.workspace {
520 return Some(".".to_string());
521 }
522 if let Ok(relative) = workspace.strip_prefix(&app.workspace) {
523 let parts: Vec<String> = relative
524 .components()
525 .filter_map(|component| match component {
526 Component::Normal(part) => part.to_str().map(ToString::to_string),
527 _ => None,
528 })
529 .collect();
530 if !parts.is_empty() {
531 return safe_child_value(app, &parts.join("/"));
532 }
533 }
534 workspace
535 .file_name()
536 .and_then(|name| name.to_str())
537 .and_then(|name| safe_child_value(app, name))
538 }
539
540 #[cfg(test)]
541 mod tests {
542 use super::*;
543 use std::path::PathBuf;
544
545 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
546 use ratatui::{buffer::Buffer, layout::Rect};
547 use serde_json::json;
548 use tempfile::tempdir;
549
550 use crate::config::Config;
551 use crate::tools::subagent::{
552 AgentWorkerStatus, ChildRouteReceipt, FleetRole, SubAgentAssignment, SubAgentNeedsInput,
553 };
554 use crate::tui::app::{
555 AgentCurrentActivity, AgentRecentAction, MAX_AGENT_RECENT_ACTIONS, TuiOptions,
556 };
557
558 fn test_app(workspace: PathBuf) -> App {
559 App::new(
560 TuiOptions {
561 model: "test-model".to_string(),
562 use_mouse_capture: true,
563 max_subagents: 4,
564 ..crate::test_support::test_tui_options(workspace)
565 },
566 &Config::default(),
567 )
568 }
569
570 fn agent(agent_id: &str, status: SubAgentStatus) -> SubAgentResult {
571 SubAgentResult {
572 usage: None,
573 name: agent_id.to_string(),
574 agent_id: agent_id.to_string(),
575 context_mode: "isolated".to_string(),
576 fork_context: false,
577 workspace: None,
578 git_branch: None,
579 agent_type: FleetRole::Builder,
580 assignment: SubAgentAssignment {
581 objective: "Implement the bounded details route".to_string(),
582 role: Some("worker".to_string()),
583 },
584 model: "deepseek-v4-pro".to_string(),
585 nickname: Some("Blue Whale".to_string()),
586 status,
587 worker_status: None,
588 runtime_permissions: None,
589 parent_run_id: None,
590 spawn_depth: 1,
591 child_route: None,
592 result: None,
593 steps_taken: 2,
594 checkpoint: None,
595 needs_input: None,
596 duration_ms: 2_500,
597 started_at: None,
598 from_prior_session: false,
599 }
600 }
601
602 fn body_for(
603 status: SubAgentStatus,
604 worker_status: AgentWorkerStatus,
605 detail: Option<&str>,
606 ) -> String {
607 let tmp = tempdir().expect("tempdir");
608 let mut app = test_app(tmp.path().to_path_buf());
609 let agent_id = "agent_matrix_subject";
610 let mut child = agent(agent_id, status);
611 child.worker_status = Some(worker_status);
612 match worker_status {
613 AgentWorkerStatus::WaitingForUser => {
614 child.needs_input = Some(SubAgentNeedsInput {
615 question: detail.unwrap_or("Which path should I use?").to_string(),
616 });
617 }
618 AgentWorkerStatus::Completed => child.result = detail.map(str::to_string),
619 _ => {}
620 }
621 app.subagent_cache.push(child);
622 app.agent_progress_meta.insert(
623 agent_id.to_string(),
624 AgentProgressMeta {
625 current_activity: Some(AgentCurrentActivity::bounded(
626 worker_status.into(),
627 detail.map(str::to_string),
628 (worker_status == AgentWorkerStatus::RunningTool)
629 .then(|| "read_file".to_string()),
630 Some(2),
631 )),
632 ..AgentProgressMeta::default()
633 },
634 );
635 project_agent_details(&app, agent_id)
636 .expect("projection")
637 .body
638 }
639
640 #[test]
641 fn provider_free_status_matrix_is_typed_and_bounded() {
642 let running = body_for(
643 SubAgentStatus::Running,
644 AgentWorkerStatus::RunningTool,
645 None,
646 );
647 assert!(running.contains("State: tool · elapsed 2s · 2 steps"));
648 assert!(running.contains("Current: running tool · read_file · step 2"));
649 assert!(!running.contains("Provider:"));
650
651 let waiting = body_for(
652 SubAgentStatus::Running,
653 AgentWorkerStatus::WaitingForUser,
654 Some("Which path should I use?"),
655 );
656 assert!(waiting.contains("State: waiting"));
657 assert!(waiting.contains("Pending question: Which path should I use?"));
658
659 let failed = body_for(
660 SubAgentStatus::Failed("verification failed".to_string()),
661 AgentWorkerStatus::Failed,
662 Some("verification failed"),
663 );
664 assert!(failed.contains("State: failed"));
665 assert!(failed.contains("Blocker: verification failed"));
666
667 let completed = body_for(
668 SubAgentStatus::Completed,
669 AgentWorkerStatus::Completed,
670 Some("all checks passed"),
671 );
672 assert!(completed.contains("State: done"));
673 assert!(completed.contains("Summary: all checks passed"));
674 }
675
676 #[test]
677 fn details_projection_matches_primary_agents_row() {
678 let tmp = tempdir().expect("tempdir");
679 let mut app = test_app(tmp.path().to_path_buf());
680 let agent_id = "agent_row_agreement";
681 let mut child = agent(
682 agent_id,
683 SubAgentStatus::Failed("stale failure".to_string()),
684 );
685 child.model = "kimi-k3".to_string();
686 child.steps_taken = 7;
687 child.duration_ms = 61_000;
688 app.subagent_cache.push(child);
689 app.agent_progress_meta.insert(
690 agent_id.to_string(),
691 AgentProgressMeta {
692 current_activity: Some(AgentCurrentActivity::bounded(
693 AgentCurrentActivityStatus::RunningTool,
694 Some("checking tests".to_string()),
695 Some("cargo test".to_string()),
696 Some(7),
697 )),
698 resolved_model: Some("stale-meta-model".to_string()),
699 ..AgentProgressMeta::default()
700 },
701 );
702
703 let row = crate::tui::sidebar::sidebar_agent_rows(&app)
704 .into_iter()
705 .find(|row| row.id == agent_id)
706 .expect("primary agents row");
707 let details = project_agent_details(&app, agent_id).expect("details projection");
708
709 assert_eq!(row.status, "tool");
710 assert_eq!(row.steps_taken, 7);
711 assert_eq!(row.model.as_deref(), Some("kimi-k3"));
712 assert_eq!(row.duration_ms, Some(61_000));
713 assert!(details.body.contains(&format!(
714 "State: {} · elapsed {} · {} steps",
715 row.status,
716 crate::elapsed::format_elapsed_ms(row.duration_ms.expect("duration")),
717 row.steps_taken
718 )));
719 assert!(details.body.contains("Model: kimi-k3"));
720 assert!(!details.body.contains("stale-meta-model"));
721 }
722
723 #[test]
724 fn details_projection_shows_resolved_route_truth_for_fleet_child() {
725 let tmp = tempdir().expect("tempdir");
726 let mut app = test_app(tmp.path().to_path_buf());
727 let agent_id = "agent_fleet_child";
728 let mut child = agent(agent_id, SubAgentStatus::Running);
729 // A `type`-only fleet dispatch leaves the advisory role empty and the
730 // spawn model is a requested placeholder; the child's own route receipt
731 // is the authoritative resolved truth.
732 child.assignment.role = None;
733 child.model = "stale-requested-model".to_string();
734 child.child_route = Some(ChildRouteReceipt {
735 requested_type: "custom".to_string(),
736 requested_profile: Some("release-lead".to_string()),
737 resolved_profile_id: Some("roster-release-lead".to_string()),
738 profile_origin: Some("roster".to_string()),
739 canonical_role: "release-lead".to_string(),
740 provider_id: "deepseek".to_string(),
741 model_id: "deepseek-v4-pro".to_string(),
742 route_source: "roster".to_string(),
743 fallback_note: None,
744 requested_reasoning: "inherit".to_string(),
745 effective_reasoning: Some("high".to_string()),
746 runtime_version: "test".to_string(),
747 runtime_build_sha: "unknown".to_string(),
748 });
749 app.subagent_cache.push(child);
750
751 let body = project_agent_details(&app, agent_id)
752 .expect("projection")
753 .body;
754 assert!(body.contains("Role: release-lead"), "{body}");
755 assert!(body.contains("Profile: roster-release-lead"), "{body}");
756 assert!(body.contains("Requested as: release-lead"), "{body}");
757 assert!(body.contains("Type: implement"), "{body}");
758 assert!(body.contains("Model: deepseek-v4-pro"), "{body}");
759 assert!(body.contains("Provider: deepseek"), "{body}");
760 assert!(!body.contains("stale-requested-model"), "{body}");
761 }
762
763 #[test]
764 fn display_name_prefers_the_dispatch_name_over_the_whale() {
765 // #5287: the lane was dispatched as `branch-triage`; that is the
766 // identity the operator glances for. An unnamed sibling keeps its
767 // generated whale rather than showing a bare id.
768 let tmp = tempdir().expect("tempdir");
769 let mut app = test_app(tmp.path().to_path_buf());
770 let mut named = agent("agent_named_lane", SubAgentStatus::Running);
771 named.name = "branch-triage".to_string();
772 app.subagent_cache.push(named);
773 let mut unnamed = agent("agent_plain_lane", SubAgentStatus::Running);
774 unnamed.nickname = None;
775 app.subagent_cache.push(unnamed);
776
777 assert_eq!(
778 safe_agent_display_name(&app, "agent_named_lane"),
779 "branch-triage"
780 );
781 assert_eq!(
782 safe_agent_display_name(&app, "agent_plain_lane"),
783 crate::tools::subagent::whale_name_for_id_in_locale(
784 "agent_plain_lane",
785 app.ui_locale.tag()
786 )
787 );
788 }
789
790 #[test]
791 fn projection_redacts_child_strings_and_never_exposes_raw_ids_or_none() {
792 let tmp = tempdir().expect("tempdir");
793 let mut app = test_app(tmp.path().to_path_buf());
794 let agent_id = "agent_secret_child";
795 let parent_id = "agent_raw_parent";
796 let mut parent = agent(parent_id, SubAgentStatus::Running);
797 parent.nickname = Some("Parent Whale".to_string());
798 let mut child = agent(agent_id, SubAgentStatus::Running);
799 child.parent_run_id = Some(parent_id.to_string());
800 child.spawn_depth = 2;
801 child.assignment.objective = format!(
802 "\u{1b}[31minspect {agent_id}\u{1b}[0m with api_key=sk-agent-details-secret-1234567890"
803 );
804 child.git_branch = Some(format!("work/{parent_id}"));
805 child.model.clear();
806 child.nickname = Some(format!("\u{1b}[35m{agent_id}\u{1b}[0m"));
807 app.subagent_cache.extend([parent, child]);
808
809 let projection = project_agent_details(&app, agent_id).expect("projection");
810 let all = format!("{}\n{}", projection.title, projection.body);
811 assert!(!all.contains(agent_id), "{all}");
812 assert!(!all.contains(parent_id), "{all}");
813 assert!(!all.contains("sk-agent-details-secret"), "{all}");
814 assert!(!all.contains('\u{1b}'), "{all:?}");
815 assert!(!all.contains("None"), "{all}");
816 assert!(all.contains("[redacted]"), "{all}");
817 }
818
819 #[test]
820 fn external_workspace_is_basename_safe_and_branch_is_bounded() {
821 let mut app = test_app(PathBuf::from("/repo/main"));
822 let agent_id = "agent_external_workspace";
823 let mut child = agent(agent_id, SubAgentStatus::Running);
824 child.workspace = Some(PathBuf::from("/private/customer/secret/repo-child"));
825 child.git_branch = Some("codex/details".to_string());
826 app.subagent_cache.push(child);
827
828 let body = project_agent_details(&app, agent_id)
829 .expect("projection")
830 .body;
831 assert!(body.contains("Workspace: repo-child"), "{body}");
832 assert!(!body.contains("/private/customer/secret"), "{body}");
833 assert!(body.contains("Branch: codex/details"), "{body}");
834 }
835
836 #[test]
837 fn recent_actions_are_bounded_and_render_only_structured_outcomes() {
838 let tmp = tempdir().expect("tempdir");
839 let mut app = test_app(tmp.path().to_path_buf());
840 let agent_id = "agent_recent_actions";
841 app.subagent_cache
842 .push(agent(agent_id, SubAgentStatus::Running));
843 let mut meta = AgentProgressMeta::default();
844 for step in 1..=MAX_AGENT_RECENT_ACTIONS as u32 {
845 meta.recent_actions.push_back(AgentRecentAction::bounded(
846 if step == 2 {
847 "apply_patch"
848 } else {
849 "read_file"
850 },
851 step,
852 step != 2,
853 ));
854 }
855 app.agent_progress_meta.insert(agent_id.to_string(), meta);
856
857 let body = project_agent_details(&app, agent_id)
858 .expect("projection")
859 .body;
860 assert_eq!(body.matches("Recent:").count(), 3, "{body}");
861 assert!(body.contains("Recent: ! apply_patch · step 2"), "{body}");
862 }
863
864 #[test]
865 fn alt_v_is_truthful_for_present_and_absent_evidence() {
866 let tmp = tempdir().expect("tempdir");
867 let agent_id = "agent_evidence";
868 let mut app = test_app(tmp.path().to_path_buf());
869 app.subagent_cache
870 .push(agent(agent_id, SubAgentStatus::Running));
871 let absent = project_agent_details(&app, agent_id).expect("projection");
872 assert!(!absent.transcript_available);
873 let details_chord = crate::tui::shell_key_routing::tool_details_chord();
874 let transcript_hint = format!("{details_chord}:transcript");
875 assert!(!absent.body.contains(&transcript_hint));
876 let mut absent_view = AgentDetailsView::new(absent, agent_id, 80);
877 assert!(matches!(
878 absent_view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)),
879 ViewAction::None
880 ));
881
882 {
883 let mut store = app
884 .runtime_services
885 .handle_store
886 .try_lock()
887 .expect("handle store");
888 let _ = store.insert_json(
889 format!("agent:{agent_id}"),
890 "full_transcript",
891 json!({
892 "message_count": 1,
893 "messages": [{
894 "role": "assistant",
895 "content": [{
896 "type": "text",
897 "text": "exact evidence",
898 "cache_control": null
899 }]
900 }]
901 }),
902 );
903 }
904 let present = project_agent_details(&app, agent_id).expect("projection");
905 assert!(present.transcript_available);
906 assert!(
907 present.body.contains(&transcript_hint),
908 "expected {transcript_hint:?} in {}",
909 present.body
910 );
911 assert!(
912 !present.body.contains("Alt/⌥V"),
913 "dual Alt/⌥ spelling must not appear: {}",
914 present.body
915 );
916 let mut present_view = AgentDetailsView::new(present, agent_id, 80);
917 assert!(matches!(
918 present_view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)),
919 ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id: ref id }) if id == agent_id
920 ));
921
922 assert!(open_agent_details(&mut app, agent_id));
923 let events = app
924 .view_stack
925 .handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
926 assert!(matches!(
927 events.as_slice(),
928 [ViewEvent::OpenAgentTranscript { agent_id: id }] if id == agent_id
929 ));
930 // The transcript destination is the in-place focus; the details view
931 // survives underneath and its chord still points at the transcript.
932 crate::tui::agent_focus::focus_agent(&mut app, agent_id);
933 assert!(
934 app.agent_focus
935 .as_ref()
936 .is_some_and(|focus| focus.is(agent_id))
937 );
938 assert!(matches!(
939 app.view_stack
940 .handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT))
941 .as_slice(),
942 [ViewEvent::OpenAgentTranscript { agent_id: id }] if id == agent_id
943 ));
944 }
945
946 #[test]
947 fn close_keys_emit_receipt_and_80x24_render_stays_safe() {
948 let tmp = tempdir().expect("tempdir");
949 let agent_id = "agent_render_80x24";
950 let mut app = test_app(tmp.path().to_path_buf());
951 app.subagent_cache
952 .push(agent(agent_id, SubAgentStatus::Running));
953 let projection = project_agent_details(&app, agent_id).expect("projection");
954 let mut view = AgentDetailsView::new(projection, agent_id, 80);
955 assert!(view.title().starts_with("Agent Details — "));
956 assert!(view.body_text().contains("Exact evidence: unavailable"));
957
958 let area = Rect::new(0, 0, 80, 24);
959 let mut buffer = Buffer::empty(area);
960 view.render(area, &mut buffer);
961 let rendered = (0..area.height)
962 .flat_map(|y| (0..area.width).map(move |x| (x, y)))
963 .map(|point| buffer[point].symbol())
964 .collect::<String>();
965 assert!(rendered.contains("Agent Details"), "{rendered}");
966 assert!(rendered.contains("Assignment"), "{rendered}");
967 assert!(!rendered.contains(agent_id), "{rendered}");
968
969 for code in [KeyCode::Esc, KeyCode::Left] {
970 assert!(matches!(
971 view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)),
972 ViewAction::EmitAndClose(ViewEvent::AgentDetailsClosed { agent_id: ref id })
973 if id == agent_id
974 ));
975 }
976 }
977 // === #5906: a parked husk is not a pending question ==================
978
979 /// The runtime hands a parked child a `needs_input` note that reads like a
980 /// question, so Agent Details used to print `Pending question: Resume this
981 /// parked child with ...` — a question no operator ever asked and none can
982 /// answer. Build one exactly as the runtime does and assert the view names
983 /// the state and both ways out instead.
984 fn parked_child(agent_id: &str) -> SubAgentResult {
985 let mut child = agent(
986 agent_id,
987 SubAgentStatus::Interrupted(
988 "Parent turn ended before this turn-owned child settled.".to_string(),
989 ),
990 );
991 child.worker_status = Some(AgentWorkerStatus::WaitingForUser);
992 child.needs_input = Some(SubAgentNeedsInput {
993 question: format!(
994 "Resume this parked child with agent(action=\"start\", resume_from=\"{agent_id}\")."
995 ),
996 });
997 child.checkpoint = Some(crate::tools::subagent::SubAgentCheckpoint {
998 checkpoint_id: format!("{agent_id}:step:2"),
999 agent_id: agent_id.to_string(),
1000 continuation_handle: format!("agent:{agent_id}:checkpoint"),
1001 reason: "Parent turn ended before this turn-owned child settled.".to_string(),
1002 continuable: true,
1003 steps_taken: 2,
1004 message_count: 4,
1005 created_at_ms: 1_000,
1006 messages: Vec::new(),
1007 omitted_messages: 0,
1008 parked_at_turn_end: true,
1009 });
1010 child
1011 }
1012
1013 #[test]
1014 fn parked_details_name_the_state_and_the_recovery_not_a_pending_question() {
1015 let tmp = tempdir().expect("tempdir");
1016 let mut app = test_app(tmp.path().to_path_buf());
1017 let agent_id = "agent_parked_details";
1018 app.subagent_cache.push(parked_child(agent_id));
1019 crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app);
1020
1021 let body = project_agent_details(&app, agent_id)
1022 .expect("projection")
1023 .body;
1024
1025 assert!(body.contains("State: parked"), "{body}");
1026 assert!(
1027 !body.contains("waiting for input") && !body.contains("State: waiting"),
1028 "a parked husk must not wear the answerable label: {body}"
1029 );
1030 assert!(
1031 !body.contains("Pending question"),
1032 "nothing asked the operator anything: {body}"
1033 );
1034 // The recovery is quoted with the verbs the runtime actually exposes.
1035 assert!(body.contains("resume_from"), "{body}");
1036 assert!(body.contains("cancel"), "{body}");
1037 }
1038
1039 #[test]
1040 fn a_child_that_really_asked_still_reports_waiting_for_input() {
1041 let tmp = tempdir().expect("tempdir");
1042 let mut app = test_app(tmp.path().to_path_buf());
1043 let agent_id = "agent_really_asked";
1044 let mut child = agent(agent_id, SubAgentStatus::Running);
1045 child.worker_status = Some(AgentWorkerStatus::WaitingForUser);
1046 child.needs_input = Some(SubAgentNeedsInput {
1047 question: "Which path should I use?".to_string(),
1048 });
1049 app.subagent_cache.push(child);
1050 crate::tui::subagent_routing::reconcile_subagent_activity_state(&mut app);
1051
1052 let body = project_agent_details(&app, agent_id)
1053 .expect("projection")
1054 .body;
1055 assert!(body.contains("State: waiting"), "{body}");
1056 assert!(
1057 body.contains("Pending question: Which path should I use?"),
1058 "{body}"
1059 );
1060 assert!(!body.contains("parked"), "{body}");
1061 }
1062 }
1063
1063 lines RUST