返回 CodeWhale
work_bar_subagents_pty.rs
根目录 / crates / tui / tests / work_bar_subagents_pty.rs
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
599 lines RUST