| 1 | //! Owner report (2026-08-04): "the sub agents still aren't showing up in the |
| 2 | //! top bar so they aren't inspectable." |
| 3 | //! |
| 4 | //! Static reading of `work_surface/model.rs` says the rows are built and are |
| 5 | //! durable, so this probe refuses to reason about it: every assertion below is |
| 6 | //! made against a real pseudo-terminal frame produced by the real event loop, |
| 7 | //! with a loopback provider that dispatches genuine `agent` tool calls. |
| 8 | //! |
| 9 | //! The contract under test (`crates/tui/AGENTS.md`, "rows are objects"): every |
| 10 | //! work-bar row is a door — click it and the world behind it opens — and |
| 11 | //! keyboard Enter opens the same door a click does. |
| 12 | |
| 13 | #![cfg(unix)] |
| 14 | |
| 15 | #[path = "support/qa_harness/mod.rs"] |
| 16 | mod qa_harness; |
| 17 | |
| 18 | use std::sync::Arc; |
| 19 | use std::sync::atomic::{AtomicUsize, Ordering}; |
| 20 | use std::time::{Duration, Instant}; |
| 21 | |
| 22 | use anyhow::{Result, anyhow}; |
| 23 | use qa_harness::harness::{Harness, SealedWorkspace, make_sealed_workspace}; |
| 24 | use qa_harness::keys; |
| 25 | use serde_json::{Value, json}; |
| 26 | use wiremock::matchers::{method, path}; |
| 27 | use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; |
| 28 | |
| 29 | const BOOT_TIMEOUT: Duration = Duration::from_secs(20); |
| 30 | const INTERACTION_TIMEOUT: Duration = Duration::from_secs(20); |
| 31 | const PASTE_GUARD_SETTLE: Duration = Duration::from_millis(180); |
| 32 | const COMPOSER_READY_TEXT: &str = "Write a task"; |
| 33 | const MODEL: &str = "deepseek-v4-pro"; |
| 34 | |
| 35 | /// The user prompt that triggers the fan-out. Only ever present in a *parent* |
| 36 | /// request, so the responder can tell parent from child without guessing. |
| 37 | const PARENT_PROMPT: &str = "spawn the work-bar probe workers now"; |
| 38 | /// The objective handed to each child. Also the text the work-bar row shows. |
| 39 | const CHILD_MARKER: &str = "workbarprobe"; |
| 40 | |
| 41 | static WORK_BAR_PTY_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); |
| 42 | |
| 43 | fn sse_chunk(value: Value) -> String { |
| 44 | format!( |
| 45 | "data: {}\n\n", |
| 46 | serde_json::to_string(&value).expect("SSE JSON") |
| 47 | ) |
| 48 | } |
| 49 | |
| 50 | fn text_sse(text: &str) -> String { |
| 51 | [ |
| 52 | sse_chunk(json!({ |
| 53 | "id": "chatcmpl-workbar", |
| 54 | "object": "chat.completion.chunk", |
| 55 | "model": MODEL, |
| 56 | "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": null}] |
| 57 | })), |
| 58 | sse_chunk(json!({ |
| 59 | "id": "chatcmpl-workbar", |
| 60 | "object": "chat.completion.chunk", |
| 61 | "model": MODEL, |
| 62 | "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], |
| 63 | "usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16} |
| 64 | })), |
| 65 | "data: [DONE]\n\n".to_string(), |
| 66 | ] |
| 67 | .join("") |
| 68 | } |
| 69 | |
| 70 | fn agent_tool_call_sse(count: usize) -> String { |
| 71 | let tool_calls = (1..=count) |
| 72 | .map(|worker| { |
| 73 | json!({ |
| 74 | "index": worker - 1, |
| 75 | "id": format!("call_workbar_{worker}"), |
| 76 | "type": "function", |
| 77 | "function": { |
| 78 | "name": "agent", |
| 79 | "arguments": serde_json::to_string(&json!({ |
| 80 | "message": format!("{CHILD_MARKER}{worker} keep working"), |
| 81 | "agent_type": "explorer", |
| 82 | // Explicit fresh context: a forked child would carry the |
| 83 | // parent prompt into its own requests and defeat the |
| 84 | // parent/child discrimination in the responder. |
| 85 | "fork_context": false, |
| 86 | "session_name": format!("workbar-{worker}") |
| 87 | })) |
| 88 | .expect("agent arguments") |
| 89 | } |
| 90 | }) |
| 91 | }) |
| 92 | .collect::<Vec<_>>(); |
| 93 | |
| 94 | [ |
| 95 | sse_chunk(json!({ |
| 96 | "id": "chatcmpl-workbar-fanout", |
| 97 | "object": "chat.completion.chunk", |
| 98 | "model": MODEL, |
| 99 | "choices": [{"index": 0, "delta": {"tool_calls": tool_calls}, "finish_reason": null}] |
| 100 | })), |
| 101 | sse_chunk(json!({ |
| 102 | "id": "chatcmpl-workbar-fanout", |
| 103 | "object": "chat.completion.chunk", |
| 104 | "model": MODEL, |
| 105 | "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}], |
| 106 | "usage": {"prompt_tokens": 20, "completion_tokens": 12, "total_tokens": 32} |
| 107 | })), |
| 108 | "data: [DONE]\n\n".to_string(), |
| 109 | ] |
| 110 | .join("") |
| 111 | } |
| 112 | |
| 113 | fn sse_response(body: String) -> ResponseTemplate { |
| 114 | ResponseTemplate::new(200) |
| 115 | .insert_header("content-type", "text/event-stream") |
| 116 | .insert_header("cache-control", "no-cache") |
| 117 | .set_body_string(body) |
| 118 | } |
| 119 | |
| 120 | fn json_response(value: Value) -> ResponseTemplate { |
| 121 | ResponseTemplate::new(200) |
| 122 | .insert_header("content-type", "application/json") |
| 123 | .set_body_json(value) |
| 124 | } |
| 125 | |
| 126 | /// Sub-agents intentionally use the non-streaming Chat Completions boundary: |
| 127 | /// their result must be complete before the worker can decide whether to make |
| 128 | /// another tool call. The parent TUI turn remains an SSE request. Keep the |
| 129 | /// probe faithful to both real wire shapes instead of making a valid worker |
| 130 | /// reject its fixture as malformed JSON. |
| 131 | fn chat_message_response(text: &str) -> ResponseTemplate { |
| 132 | json_response(json!({ |
| 133 | "id": "chatcmpl-workbar-child", |
| 134 | "object": "chat.completion", |
| 135 | "model": MODEL, |
| 136 | "choices": [{ |
| 137 | "index": 0, |
| 138 | "message": {"role": "assistant", "content": text}, |
| 139 | "finish_reason": "stop" |
| 140 | }], |
| 141 | "usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16} |
| 142 | })) |
| 143 | } |
| 144 | |
| 145 | async fn mount_models(server: &MockServer) { |
| 146 | Mock::given(method("GET")) |
| 147 | .and(path("/v1/models")) |
| 148 | .respond_with(json_response(json!({ |
| 149 | "object": "list", |
| 150 | "data": [{"id": MODEL, "object": "model"}] |
| 151 | }))) |
| 152 | .mount(server) |
| 153 | .await; |
| 154 | } |
| 155 | |
| 156 | /// Dispatches one fan-out on the first parent turn, then answers the parent |
| 157 | /// plainly. `child_hold` decides whether the workers stay running (a long |
| 158 | /// delay) or finish immediately. |
| 159 | struct ProbeResponder { |
| 160 | child_requests: Arc<AtomicUsize>, |
| 161 | parent_turns: Arc<AtomicUsize>, |
| 162 | workers: usize, |
| 163 | child_hold: Duration, |
| 164 | } |
| 165 | |
| 166 | impl Respond for ProbeResponder { |
| 167 | fn respond(&self, request: &Request) -> ResponseTemplate { |
| 168 | let raw = request |
| 169 | .body_json::<Value>() |
| 170 | .unwrap_or(Value::Null) |
| 171 | .to_string(); |
| 172 | |
| 173 | if raw.contains(CHILD_MARKER) && !raw.contains(PARENT_PROMPT) { |
| 174 | self.child_requests.fetch_add(1, Ordering::SeqCst); |
| 175 | return chat_message_response("workbar child receipt").set_delay(self.child_hold); |
| 176 | } |
| 177 | if raw.contains(PARENT_PROMPT) { |
| 178 | if self.parent_turns.fetch_add(1, Ordering::SeqCst) == 0 { |
| 179 | return sse_response(agent_tool_call_sse(self.workers)); |
| 180 | } |
| 181 | return sse_response(text_sse("workbar parent wrapped up")); |
| 182 | } |
| 183 | sse_response(text_sse("unexpected-request")) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | fn tui_builder(ws: &SealedWorkspace, server_uri: &str) -> qa_harness::harness::HarnessBuilder { |
| 188 | Harness::builder(Harness::cargo_bin("codewhale-tui")) |
| 189 | .cwd(ws.workspace()) |
| 190 | .clear_env() |
| 191 | .seal_home(ws.home()) |
| 192 | .env("RUST_LOG", "warn") |
| 193 | .env("NO_ANIMATIONS", "1") |
| 194 | .env("CODEWHALE_PROVIDER", "deepseek") |
| 195 | .env("DEEPSEEK_API_KEY", "deepseek-local-test-key") |
| 196 | .env("DEEPSEEK_BASE_URL", server_uri.to_string()) |
| 197 | .env("DEEPSEEK_MODEL", MODEL) |
| 198 | .args([ |
| 199 | "--workspace", |
| 200 | ws.workspace().to_str().expect("utf-8 workspace path"), |
| 201 | "--no-project-config", |
| 202 | "--skip-onboarding", |
| 203 | "--mouse-capture", |
| 204 | "--yolo", |
| 205 | "--max-subagents", |
| 206 | "2", |
| 207 | ]) |
| 208 | .size(42, 150) |
| 209 | } |
| 210 | |
| 211 | fn wait_for_counter( |
| 212 | harness: &mut Harness, |
| 213 | counter: &AtomicUsize, |
| 214 | expected: usize, |
| 215 | timeout: Duration, |
| 216 | ) -> Result<()> { |
| 217 | let deadline = Instant::now() + timeout; |
| 218 | loop { |
| 219 | harness.pump(); |
| 220 | if counter.load(Ordering::SeqCst) >= expected { |
| 221 | return Ok(()); |
| 222 | } |
| 223 | if let Some(code) = harness.wait_for_exit(Duration::from_millis(0)) { |
| 224 | return Err(anyhow!( |
| 225 | "codewhale-tui exited with {code} before the counter reached {expected}\n{}", |
| 226 | harness.debug_dump() |
| 227 | )); |
| 228 | } |
| 229 | if Instant::now() >= deadline { |
| 230 | return Err(anyhow!( |
| 231 | "counter did not reach {expected} within {timeout:?}; observed {}\n{}", |
| 232 | counter.load(Ordering::SeqCst), |
| 233 | harness.debug_dump() |
| 234 | )); |
| 235 | } |
| 236 | std::thread::sleep(Duration::from_millis(40)); |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | fn type_and_submit(harness: &mut Harness, text: &str) -> Result<()> { |
| 241 | harness.send(keys::key::text(text))?; |
| 242 | harness.wait_for_text(text, Duration::from_secs(5))?; |
| 243 | std::thread::sleep(PASTE_GUARD_SETTLE); |
| 244 | harness.pump(); |
| 245 | harness.send(keys::key::enter())?; |
| 246 | Ok(()) |
| 247 | } |
| 248 | |
| 249 | fn is_divider_row(frame: &qa_harness::Frame, y: u16) -> bool { |
| 250 | frame |
| 251 | .row(y) |
| 252 | .chars() |
| 253 | .filter(|&c| c == '─' || c == '━') |
| 254 | .count() |
| 255 | >= 40 |
| 256 | } |
| 257 | |
| 258 | /// The `▾ Subagents N` group header the Top strip paints above its worker |
| 259 | /// rows. Its absence *is* the owner-reported bug, so it is the anchor every |
| 260 | /// other strip probe hangs off rather than a screen-wide text search. |
| 261 | fn subagents_header_row(harness: &mut Harness) -> Option<u16> { |
| 262 | let frame = harness.frame(); |
| 263 | (0..frame.rows()).find(|&y| frame.row(y).contains("Subagents") && !is_divider_row(frame, y)) |
| 264 | } |
| 265 | |
| 266 | /// Every row painted in the work bar, header included. |
| 267 | fn work_bar_text(harness: &mut Harness) -> String { |
| 268 | let frame = harness.frame(); |
| 269 | let rows = frame.rows(); |
| 270 | // The strip sits between the ocean header rule and the transcript rule. |
| 271 | let dividers: Vec<u16> = (0..rows).filter(|&y| is_divider_row(frame, y)).collect(); |
| 272 | let (start, end) = match dividers.as_slice() { |
| 273 | [first, second, ..] => (first.saturating_add(1), *second), |
| 274 | _ => (0, rows), |
| 275 | }; |
| 276 | (start..end) |
| 277 | .map(|y| frame.row(y)) |
| 278 | .collect::<Vec<_>>() |
| 279 | .join("\n") |
| 280 | } |
| 281 | |
| 282 | /// A worker row inside the strip: the rows the `Subagents` header owns, up to |
| 283 | /// the strip's closing rule. Returns `(row, column)` for a real SGR click. |
| 284 | fn work_bar_worker_row(harness: &mut Harness) -> Option<(u16, u16)> { |
| 285 | let header = subagents_header_row(harness)?; |
| 286 | let frame = harness.frame(); |
| 287 | let rows = frame.rows(); |
| 288 | (header.saturating_add(1)..rows) |
| 289 | .take_while(|&y| !is_divider_row(frame, y)) |
| 290 | .find_map(|y| { |
| 291 | let text = frame.row(y); |
| 292 | let trimmed = text.trim_start(); |
| 293 | if trimmed.is_empty() { |
| 294 | return None; |
| 295 | } |
| 296 | let col = u16::try_from(text.len() - trimmed.len()).ok()?; |
| 297 | Some((y, col.saturating_add(2))) |
| 298 | }) |
| 299 | } |
| 300 | |
| 301 | fn session_with_todos(ws: &SealedWorkspace, count: usize) -> Result<std::path::PathBuf> { |
| 302 | let session_path = ws.workspace().join("workbar-session.json"); |
| 303 | let todos = (0..count) |
| 304 | .map(|index| { |
| 305 | json!({ |
| 306 | "id": index + 1, |
| 307 | "content": format!("todo-workbar-{index:02}"), |
| 308 | "status": if index == 0 { "in_progress" } else { "pending" } |
| 309 | }) |
| 310 | }) |
| 311 | .collect::<Vec<_>>(); |
| 312 | std::fs::write( |
| 313 | &session_path, |
| 314 | serde_json::to_vec_pretty(&json!({ |
| 315 | "schema_version": 1, |
| 316 | "metadata": { |
| 317 | "id": "pty-workbar", |
| 318 | "title": "Work bar sub-agent probe", |
| 319 | "created_at": "2026-08-04T00:00:00Z", |
| 320 | "updated_at": "2026-08-04T00:00:00Z", |
| 321 | "message_count": 0, |
| 322 | "total_tokens": 0, |
| 323 | "model": MODEL, |
| 324 | "model_provider": "deepseek", |
| 325 | "workspace": ws.workspace(), |
| 326 | "mode": "agent", |
| 327 | "cost": {}, |
| 328 | "cumulative_turn_secs": 0 |
| 329 | }, |
| 330 | "messages": [], |
| 331 | "system_prompt": null, |
| 332 | "work_state": { |
| 333 | "todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1}, |
| 334 | "plan": {"objective": "", "items": []} |
| 335 | } |
| 336 | }))?, |
| 337 | )?; |
| 338 | Ok(session_path) |
| 339 | } |
| 340 | |
| 341 | /// Baseline: with nothing competing for strip rows, a running sub-agent must |
| 342 | /// appear in the top bar, a real SGR click must open its detail, and keyboard |
| 343 | /// Enter must open the same door. |
| 344 | #[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
| 345 | async fn work_bar_lists_a_running_subagent_and_opens_it_by_click_and_enter() -> Result<()> { |
| 346 | let _guard = WORK_BAR_PTY_LOCK.lock().await; |
| 347 | let server = MockServer::start().await; |
| 348 | mount_models(&server).await; |
| 349 | let child_requests = Arc::new(AtomicUsize::new(0)); |
| 350 | Mock::given(method("POST")) |
| 351 | .and(path("/v1/chat/completions")) |
| 352 | .respond_with(ProbeResponder { |
| 353 | child_requests: Arc::clone(&child_requests), |
| 354 | parent_turns: Arc::new(AtomicUsize::new(0)), |
| 355 | workers: 2, |
| 356 | child_hold: Duration::from_secs(25), |
| 357 | }) |
| 358 | .mount(&server) |
| 359 | .await; |
| 360 | |
| 361 | let ws = make_sealed_workspace()?; |
| 362 | std::fs::write( |
| 363 | ws.home().join(".codewhale").join("config.toml"), |
| 364 | "[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n", |
| 365 | )?; |
| 366 | let mut tui = tui_builder(&ws, &server.uri()).spawn()?; |
| 367 | tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?; |
| 368 | type_and_submit(&mut tui, PARENT_PROMPT)?; |
| 369 | wait_for_counter(&mut tui, &child_requests, 2, INTERACTION_TIMEOUT)?; |
| 370 | |
| 371 | tui.wait_for( |
| 372 | |frame| frame.text().contains("Subagents"), |
| 373 | Duration::from_secs(10), |
| 374 | )?; |
| 375 | |
| 376 | let strip = work_bar_text(&mut tui); |
| 377 | assert!( |
| 378 | strip.contains("Subagents"), |
| 379 | "the `Subagents` header is not inside the top work bar:\n{strip}\n---full---\n{}", |
| 380 | tui.debug_dump() |
| 381 | ); |
| 382 | let (row, col) = work_bar_worker_row(&mut tui).ok_or_else(|| { |
| 383 | anyhow!( |
| 384 | "no running sub-agent row rendered in the top work bar\n{}", |
| 385 | tui.debug_dump() |
| 386 | ) |
| 387 | })?; |
| 388 | |
| 389 | // Click the row: the door must open. |
| 390 | tui.send(keys::mouse::click(row, col))?; |
| 391 | tui.wait_for_text("Agent Details", Duration::from_secs(5)) |
| 392 | .map_err(|_| { |
| 393 | anyhow!( |
| 394 | "clicking the sub-agent row did not open its detail\n{}", |
| 395 | tui.debug_dump() |
| 396 | ) |
| 397 | })?; |
| 398 | |
| 399 | // Close, then prove keyboard parity: Alt+W focuses the strip, End selects |
| 400 | // the last selectable row (a worker), Enter opens the same detail. |
| 401 | tui.send(keys::key::esc())?; |
| 402 | tui.wait_for( |
| 403 | |frame| !frame.text().contains("Agent Details"), |
| 404 | Duration::from_secs(5), |
| 405 | )?; |
| 406 | tui.send(keys::key::alt('w'))?; |
| 407 | tui.send(b"\x1b[F")?; // End |
| 408 | tui.send(keys::key::enter())?; |
| 409 | tui.wait_for_text("Agent Details", Duration::from_secs(5)) |
| 410 | .map_err(|_| { |
| 411 | anyhow!( |
| 412 | "Enter on the selected work-bar row did not open the detail a click opens\n{}", |
| 413 | tui.debug_dump() |
| 414 | ) |
| 415 | })?; |
| 416 | |
| 417 | let _ = tui.shutdown(); |
| 418 | Ok(()) |
| 419 | } |
| 420 | |
| 421 | /// The dogfood shape: a session already carrying a to-do list, then a fan-out. |
| 422 | /// Sub-agents must remain visible and clickable in the top bar — a strip that |
| 423 | /// spends every row it has on to-dos and pushes the workers off the bottom is |
| 424 | /// exactly the owner-reported failure. |
| 425 | #[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
| 426 | async fn work_bar_still_shows_subagents_when_todos_are_present() -> Result<()> { |
| 427 | let _guard = WORK_BAR_PTY_LOCK.lock().await; |
| 428 | let server = MockServer::start().await; |
| 429 | mount_models(&server).await; |
| 430 | let child_requests = Arc::new(AtomicUsize::new(0)); |
| 431 | Mock::given(method("POST")) |
| 432 | .and(path("/v1/chat/completions")) |
| 433 | .respond_with(ProbeResponder { |
| 434 | child_requests: Arc::clone(&child_requests), |
| 435 | parent_turns: Arc::new(AtomicUsize::new(0)), |
| 436 | workers: 2, |
| 437 | child_hold: Duration::from_secs(25), |
| 438 | }) |
| 439 | .mount(&server) |
| 440 | .await; |
| 441 | |
| 442 | let ws = make_sealed_workspace()?; |
| 443 | std::fs::write( |
| 444 | ws.home().join(".codewhale").join("config.toml"), |
| 445 | "[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n", |
| 446 | )?; |
| 447 | let session_path = session_with_todos(&ws, 8)?; |
| 448 | let mut tui = tui_builder(&ws, &server.uri()).spawn()?; |
| 449 | tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?; |
| 450 | tui.send(keys::key::text(&format!( |
| 451 | "/load {}", |
| 452 | session_path.to_string_lossy() |
| 453 | )))?; |
| 454 | tui.wait_for_idle(Duration::from_millis(150), Duration::from_secs(3))?; |
| 455 | tui.send(keys::key::enter())?; |
| 456 | tui.wait_for_text("todo-workbar-00", Duration::from_secs(10))?; |
| 457 | |
| 458 | type_and_submit(&mut tui, PARENT_PROMPT)?; |
| 459 | wait_for_counter(&mut tui, &child_requests, 2, INTERACTION_TIMEOUT)?; |
| 460 | tui.wait_for_idle(Duration::from_millis(250), Duration::from_secs(6))?; |
| 461 | |
| 462 | let strip = work_bar_text(&mut tui); |
| 463 | let worker = work_bar_worker_row(&mut tui); |
| 464 | assert!( |
| 465 | worker.is_some(), |
| 466 | "a running sub-agent is not reachable in the top work bar while to-dos \ |
| 467 | occupy it — the strip painted only:\n{strip}\n---full---\n{}", |
| 468 | tui.debug_dump() |
| 469 | ); |
| 470 | let (row, col) = worker.expect("checked above"); |
| 471 | tui.send(keys::mouse::click(row, col))?; |
| 472 | tui.wait_for_text("Agent Details", Duration::from_secs(5)) |
| 473 | .map_err(|_| { |
| 474 | anyhow!( |
| 475 | "clicking the sub-agent row did not open its detail\n{}", |
| 476 | tui.debug_dump() |
| 477 | ) |
| 478 | })?; |
| 479 | |
| 480 | let _ = tui.shutdown(); |
| 481 | Ok(()) |
| 482 | } |
| 483 | |
| 484 | /// The owner's *own* `~/.codewhale/settings.toml` (2026-08-04) carries |
| 485 | /// `rail_panel = "pinned"`. That is the configuration the "I spawned a sub |
| 486 | /// agent and the top bar showed nothing" screenshot was taken under, and it |
| 487 | /// is the one the other probes here miss: they all run on a sealed HOME with |
| 488 | /// no `settings.toml`, so they only ever exercise the default `tasks` panel. |
| 489 | /// |
| 490 | /// With zero to-dos and no active goal the Pinned projection is empty, the |
| 491 | /// strip collapses to height 0, and a running sub-agent has nowhere in the |
| 492 | /// top bar to appear — there is no header chip or phase-strip fallback for |
| 493 | /// it. A panel choice must not be able to make running work uninspectable. |
| 494 | #[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
| 495 | async fn work_bar_shows_a_running_subagent_under_the_pinned_rail_panel() -> Result<()> { |
| 496 | let _guard = WORK_BAR_PTY_LOCK.lock().await; |
| 497 | let server = MockServer::start().await; |
| 498 | mount_models(&server).await; |
| 499 | let child_requests = Arc::new(AtomicUsize::new(0)); |
| 500 | Mock::given(method("POST")) |
| 501 | .and(path("/v1/chat/completions")) |
| 502 | .respond_with(ProbeResponder { |
| 503 | child_requests: Arc::clone(&child_requests), |
| 504 | parent_turns: Arc::new(AtomicUsize::new(0)), |
| 505 | workers: 2, |
| 506 | child_hold: Duration::from_secs(25), |
| 507 | }) |
| 508 | .mount(&server) |
| 509 | .await; |
| 510 | |
| 511 | let ws = make_sealed_workspace()?; |
| 512 | std::fs::write( |
| 513 | ws.home().join(".codewhale").join("config.toml"), |
| 514 | "[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n", |
| 515 | )?; |
| 516 | // Verbatim from the owner's settings.toml, minus the keys that do not |
| 517 | // touch the rail. |
| 518 | std::fs::write( |
| 519 | ws.home().join(".codewhale").join("settings.toml"), |
| 520 | "work_surface_placement = \"top\"\nwork_surface_top_height = 16\n\ |
| 521 | work_surface_side_width = 30\nrail_panel = \"pinned\"\n", |
| 522 | )?; |
| 523 | let mut tui = tui_builder(&ws, &server.uri()).spawn()?; |
| 524 | tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?; |
| 525 | type_and_submit(&mut tui, PARENT_PROMPT)?; |
| 526 | wait_for_counter(&mut tui, &child_requests, 2, INTERACTION_TIMEOUT)?; |
| 527 | tui.wait_for_idle(Duration::from_millis(250), Duration::from_secs(6))?; |
| 528 | |
| 529 | let strip = work_bar_text(&mut tui); |
| 530 | let worker = work_bar_worker_row(&mut tui); |
| 531 | assert!( |
| 532 | worker.is_some(), |
| 533 | "a running sub-agent is invisible in the top bar under \ |
| 534 | `rail_panel = \"pinned\"` — the strip painted only:\n{strip}\n---full---\n{}", |
| 535 | tui.debug_dump() |
| 536 | ); |
| 537 | let (row, col) = worker.expect("checked above"); |
| 538 | tui.send(keys::mouse::click(row, col))?; |
| 539 | tui.wait_for_text("Agent Details", Duration::from_secs(5)) |
| 540 | .map_err(|_| { |
| 541 | anyhow!( |
| 542 | "clicking the sub-agent row did not open its detail\n{}", |
| 543 | tui.debug_dump() |
| 544 | ) |
| 545 | })?; |
| 546 | |
| 547 | let _ = tui.shutdown(); |
| 548 | Ok(()) |
| 549 | } |
| 550 | |
| 551 | /// A finished agent collapses out of the Top strip (so fan-outs do not |
| 552 | /// permanently eat the transcript) but stays counted in the header. The |
| 553 | /// Agents panel remains the standing register — see |
| 554 | /// `agents_panel_click_opens_details_even_for_finished_agents`. |
| 555 | #[tokio::test(flavor = "multi_thread", worker_threads = 4)] |
| 556 | async fn work_bar_collapses_a_finished_subagent_into_the_header() -> Result<()> { |
| 557 | let _guard = WORK_BAR_PTY_LOCK.lock().await; |
| 558 | let server = MockServer::start().await; |
| 559 | mount_models(&server).await; |
| 560 | let child_requests = Arc::new(AtomicUsize::new(0)); |
| 561 | Mock::given(method("POST")) |
| 562 | .and(path("/v1/chat/completions")) |
| 563 | .respond_with(ProbeResponder { |
| 564 | child_requests: Arc::clone(&child_requests), |
| 565 | parent_turns: Arc::new(AtomicUsize::new(0)), |
| 566 | workers: 1, |
| 567 | child_hold: Duration::from_millis(0), |
| 568 | }) |
| 569 | .mount(&server) |
| 570 | .await; |
| 571 | |
| 572 | let ws = make_sealed_workspace()?; |
| 573 | std::fs::write( |
| 574 | ws.home().join(".codewhale").join("config.toml"), |
| 575 | "[subagents]\nmax_concurrent = 2\nlaunch_concurrency = 2\nmax_admitted = 2\n", |
| 576 | )?; |
| 577 | let mut tui = tui_builder(&ws, &server.uri()).spawn()?; |
| 578 | tui.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?; |
| 579 | type_and_submit(&mut tui, PARENT_PROMPT)?; |
| 580 | wait_for_counter(&mut tui, &child_requests, 1, INTERACTION_TIMEOUT)?; |
| 581 | // Let the child settle terminal and the parent turn finish. |
| 582 | tui.wait_for_idle(Duration::from_millis(300), Duration::from_secs(10))?; |
| 583 | |
| 584 | let strip = work_bar_text(&mut tui); |
| 585 | assert!( |
| 586 | strip.contains("Archived 1"), |
| 587 | "settled workers must remain counted in the Subagents Archived header; strip:\n{strip}\n---full---\n{}", |
| 588 | tui.debug_dump() |
| 589 | ); |
| 590 | assert!( |
| 591 | work_bar_worker_row(&mut tui).is_none(), |
| 592 | "a finished sub-agent must leave the Top strip rows; strip:\n{strip}\n---full---\n{}", |
| 593 | tui.debug_dump() |
| 594 | ); |
| 595 | |
| 596 | let _ = tui.shutdown(); |
| 597 | Ok(()) |
| 598 | } |
| 599 |