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