返回 CodeWhale
qa_pty.rs
根目录 / crates / tui / tests / qa_pty.rs
1 //! End-to-end TUI scenarios driven through a real pseudo-terminal.
2 //!
3 //! Each scenario boots `deepseek-tui` in a sealed workspace + sealed `$HOME`,
4 //! sends scripted input through the PTY, and asserts on the parsed terminal
5 //! frame and on the workspace filesystem. See `support/qa_harness/README.md`
6 //! for design + how-to.
7 //!
8 //! These tests are gated to Unix for now. Windows ConPTY behaviour (#923,
9 //! #765, #802) needs a separate audit before scenarios light up there.
10
11 #![cfg(unix)]
12
13 #[path = "support/qa_harness/mod.rs"]
14 mod qa_harness;
15
16 use std::io::{Read, Write};
17 use std::net::TcpListener;
18 use std::process::Command;
19 use std::sync::{Mutex, MutexGuard};
20 use std::time::{Duration, Instant};
21
22 use qa_harness::harness::{Harness, make_sealed_workspace};
23 use qa_harness::keys;
24 use sha2::{Digest, Sha256};
25 use unicode_width::UnicodeWidthStr;
26
27 const BOOT_TIMEOUT: Duration = Duration::from_secs(15);
28 const KEY_TIMEOUT: Duration = Duration::from_secs(5);
29 const SKILL_SCAN_TIMEOUT: Duration = Duration::from_secs(15);
30 const COMPOSER_READY_TEXT: &str = "Write a task";
31 /// Operate-mode composer placeholder, pinned as shipped copy since the
32 /// goal-first placeholder rewording (bf0478395): the mode ramp legs below
33 /// assert the exact user-visible text, not a substring of it.
34 const OPERATE_COMPOSER_TEXT: &str = "Describe the goal — Codewhale keeps working until it's done";
35 static QA_PTY_TEST_LOCK: Mutex<()> = Mutex::new(());
36
37 fn qa_pty_test_lock() -> MutexGuard<'static, ()> {
38 QA_PTY_TEST_LOCK
39 .lock()
40 .unwrap_or_else(|poison| poison.into_inner())
41 }
42
43 fn boot_minimal() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {
44 let ws = make_sealed_workspace()?;
45 spawn_minimal_with_env(ws, &[])
46 }
47
48 fn boot_minimal_over_ssh() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {
49 let ws = make_sealed_workspace()?;
50 spawn_minimal_with_env(ws, &[("SSH_CONNECTION", "192.0.2.10 51234 192.0.2.20 22")])
51 }
52
53 fn boot_minimal_without_retry() -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {
54 let ws = make_sealed_workspace()?;
55 std::fs::write(
56 ws.home().join(".deepseek").join("config.toml"),
57 "[retry]\nenabled = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
58 )?;
59 spawn_minimal_with_env(ws, &[])
60 }
61
62 fn spawn_minimal_with_env(
63 ws: qa_harness::harness::SealedWorkspace,
64 extra_env: &[(&str, &str)],
65 ) -> anyhow::Result<(qa_harness::harness::SealedWorkspace, Harness)> {
66 let mut builder = Harness::builder(Harness::cargo_bin("codewhale-tui"))
67 .cwd(ws.workspace())
68 .clear_env()
69 .seal_home(ws.home())
70 // Provide a stub key so the onboarding screen is bypassed and the TUI
71 // boots straight into the composer. The harness never makes a live
72 // request — we just need the binary to think a key exists.
73 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
74 // Force a known base URL so the doctor / model probe never escapes
75 // the box. 127.0.0.1:1 will refuse instantly.
76 .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
77 // PTY scenarios assert state transitions, not animation cadence. Freeze
78 // ambient motion so wait_for_idle measures product state instead of a
79 // decorative ocean frame.
80 .env("NO_ANIMATIONS", "1")
81 .env("RUST_LOG", "warn")
82 .args([
83 "--workspace",
84 ws.workspace().to_str().expect("utf-8 workspace path"),
85 "--no-project-config",
86 "--skip-onboarding",
87 ])
88 .size(40, 140);
89 for (key, value) in extra_env {
90 builder = builder.env(*key, *value);
91 }
92 let mut h = builder.spawn()?;
93 enter_launch_session(&mut h)?;
94 Ok((ws, h))
95 }
96
97 /// PTY scenarios exercise composer/runtime behavior. The default startup now
98 /// enters a session directly; users who explicitly enable `launch_screen`
99 /// retain the separate launch surface, covered by unit rendering tests.
100 fn enter_launch_session(h: &mut Harness) -> anyhow::Result<()> {
101 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
102 Ok(())
103 }
104
105 #[test]
106 fn composer_newline_and_stash_chords_keep_stable_roles() -> anyhow::Result<()> {
107 let _guard = qa_pty_test_lock();
108 let (ws, mut h) = boot_minimal()?;
109
110 h.send(keys::key::text("shift-line"))?;
111 h.send(keys::key::shift_enter())?;
112 h.send(keys::key::text("alt-line"))?;
113 h.send(keys::key::alt_enter())?;
114 h.send(keys::key::text("ctrl-j-line"))?;
115 h.send(keys::key::ctrl_j())?;
116 h.send(keys::key::text("last-line"))?;
117 h.wait_for_text("last-line", KEY_TIMEOUT)?;
118
119 let frame = h.frame();
120 let rows = ["shift-line", "alt-line", "ctrl-j-line", "last-line"].map(|line| {
121 frame
122 .find_text(line)
123 .expect("multiline draft stays visible")
124 .0
125 });
126 assert!(
127 rows.windows(2).all(|pair| pair[0] < pair[1]),
128 "Shift+Enter, Alt+Enter, and Ctrl+J must each add a line:\n{}",
129 frame.debug_dump()
130 );
131
132 h.send(keys::key::ctrl_g())?;
133 h.wait_for_text("Draft stashed", KEY_TIMEOUT)?;
134 h.wait_for_text(COMPOSER_READY_TEXT, KEY_TIMEOUT)?;
135 let stash_path = ws.home().join(".codewhale/composer_stash.jsonl");
136 let first_stash = std::fs::read_to_string(&stash_path)?;
137 assert!(first_stash.contains("shift-line\\nalt-line\\nctrl-j-line\\nlast-line"));
138
139 h.send(keys::key::text("/stash pop"))?;
140 h.wait_for_text("/stash pop", KEY_TIMEOUT)?;
141 std::thread::sleep(Duration::from_millis(180));
142 h.send(keys::key::enter())?;
143 h.wait_for_text("last-line", KEY_TIMEOUT)?;
144
145 h.send(keys::key::ctrl_s())?;
146 h.wait_for_text(COMPOSER_READY_TEXT, KEY_TIMEOUT)?;
147 let second_stash = std::fs::read_to_string(&stash_path)?;
148 assert!(second_stash.contains("shift-line\\nalt-line\\nctrl-j-line\\nlast-line"));
149
150 let _ = h.shutdown();
151 Ok(())
152 }
153
154 fn write_skill(root: std::path::PathBuf, name: &str, description: &str) -> anyhow::Result<()> {
155 let dir = root.join(name);
156 std::fs::create_dir_all(&dir)?;
157 std::fs::write(
158 dir.join("SKILL.md"),
159 format!("---\nname: {name}\ndescription: {description}\n---\nUse {name}.\n"),
160 )?;
161 Ok(())
162 }
163
164 fn spawn_approval_fixture_server() -> anyhow::Result<(String, std::thread::JoinHandle<()>)> {
165 let listener = TcpListener::bind("127.0.0.1:0")?;
166 listener.set_nonblocking(true)?;
167 let address = listener.local_addr()?;
168 let handle = std::thread::spawn(move || {
169 let deadline = Instant::now() + Duration::from_secs(20);
170 let mut request_index = 0usize;
171 while request_index < 2 && Instant::now() < deadline {
172 let Ok((mut stream, _)) = listener.accept() else {
173 std::thread::sleep(Duration::from_millis(10));
174 continue;
175 };
176 let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
177 let mut request = [0u8; 64 * 1024];
178 let _ = stream.read(&mut request);
179 let body = if request_index == 0 {
180 [
181 format!(
182 "data: {}\n\n",
183 serde_json::json!({
184 "id":"chatcmpl-approval",
185 "object":"chat.completion.chunk",
186 "model":"deepseek-v4-flash",
187 "choices":[{"index":0,"delta":{"tool_calls":[{
188 "index":0,
189 "id":"call_approval_pty",
190 "type":"function",
191 "function":{"name":"File","arguments":"{\"action\":\"write\",\"path\":\"approval-proof.txt\",\"content\":\"must-not-write\"}"}
192 }]},"finish_reason":null}]
193 })
194 ),
195 format!(
196 "data: {}\n\n",
197 serde_json::json!({
198 "id":"chatcmpl-approval",
199 "object":"chat.completion.chunk",
200 "model":"deepseek-v4-flash",
201 "choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],
202 "usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}
203 })
204 ),
205 "data: [DONE]\n\n".to_string(),
206 ]
207 .join("")
208 } else {
209 [
210 format!(
211 "data: {}\n\n",
212 serde_json::json!({
213 "id":"chatcmpl-denied",
214 "object":"chat.completion.chunk",
215 "model":"deepseek-v4-flash",
216 "choices":[{"index":0,"delta":{"content":"DENIAL-HONORED"},"finish_reason":null}]
217 })
218 ),
219 format!(
220 "data: {}\n\n",
221 serde_json::json!({
222 "id":"chatcmpl-denied",
223 "object":"chat.completion.chunk",
224 "model":"deepseek-v4-flash",
225 "choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
226 "usage":{"prompt_tokens":20,"completion_tokens":4,"total_tokens":24}
227 })
228 ),
229 "data: [DONE]\n\n".to_string(),
230 ]
231 .join("")
232 };
233 let response = format!(
234 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
235 body.len(),
236 body
237 );
238 let _ = stream.write_all(response.as_bytes());
239 let _ = stream.flush();
240 request_index += 1;
241 }
242 });
243 Ok((format!("http://{address}"), handle))
244 }
245
246 fn first_non_blank_row(frame: &qa_harness::Frame) -> Option<u16> {
247 (0..frame.rows()).find(|&row| !frame.row(row).trim().is_empty())
248 }
249
250 fn assert_viewport_starts_at_top(frame: &qa_harness::Frame) {
251 let dump = frame.debug_dump();
252 let first_row = first_non_blank_row(frame).expect("expected visible frame text");
253 assert_eq!(
254 first_row, 0,
255 "viewport content drifted below row 0:\n{dump}"
256 );
257 let header = frame.row(0).to_ascii_lowercase();
258 assert!(
259 header.contains("plan")
260 || header.contains("act")
261 || header.contains("agent")
262 || header.contains("operate")
263 || header.contains("yolo")
264 || header.contains("deepseek"),
265 "expected header content on row 0:\n{dump}"
266 );
267 }
268
269 fn visible_row_with_text(frame: &qa_harness::Frame, needle: &str) -> Option<u16> {
270 (0..frame.rows()).find(|&row| frame.row(row).contains(needle))
271 }
272
273 fn foreground_at_text(frame: &qa_harness::Frame, row: u16, needle: &str) -> qa_harness::Color {
274 let col = frame
275 .find_text_in_row(row, needle)
276 .unwrap_or_else(|| panic!("{needle:?} missing from row {row}: {:?}", frame.row(row)));
277 frame
278 .colors_at(row, col)
279 .unwrap_or_else(|| panic!("missing terminal cell at ({row}, {col})"))
280 .0
281 }
282
283 fn composer_edge_rows(frame: &qa_harness::Frame, placeholder: &str) -> (u16, u16) {
284 let input_row = visible_row_with_text(frame, placeholder)
285 .unwrap_or_else(|| panic!("composer placeholder {placeholder:?} missing"));
286 let minimum_rule_cells = usize::from(frame.cols() / 2);
287 let is_rule = |row: u16| {
288 frame
289 .row(row)
290 .chars()
291 .filter(|ch| {
292 matches!(
293 ch,
294 '-' | '─' | '━' | '╌' | '╍' | '┄' | '┅' | '┈' | '┉' | '═'
295 )
296 })
297 .count()
298 >= minimum_rule_cells
299 };
300 let top = (0..input_row)
301 .rev()
302 .find(|&row| is_rule(row))
303 .expect("composer top edge");
304 let bottom = (input_row.saturating_add(1)..frame.rows())
305 .find(|&row| is_rule(row))
306 .expect("composer bottom edge");
307 (top, bottom)
308 }
309
310 /// Assert the user-visible labels and the split composer edges tell the same
311 /// agency/permission story in the ANSI cells emitted through the real PTY.
312 fn assert_control_grammar(
313 frame: &qa_harness::Frame,
314 mode: &str,
315 permission: &str,
316 placeholder: &str,
317 ) -> (qa_harness::Color, qa_harness::Color) {
318 let dump = frame.debug_dump();
319 let header = frame.row(0);
320 assert!(
321 header.contains(mode),
322 "mode {mode:?} missing from header {header:?}:\n{dump}"
323 );
324 assert!(
325 header.contains(permission),
326 "permission {permission:?} missing from header {header:?}:\n{dump}"
327 );
328 let mode_color = foreground_at_text(frame, 0, mode);
329 let permission_color = foreground_at_text(frame, 0, permission);
330 let (permission_edge, mode_edge) = composer_edge_rows(frame, placeholder);
331 assert_eq!(
332 frame
333 .colors_at(permission_edge, 1)
334 .expect("permission edge cell")
335 .0,
336 permission_color,
337 "header permission and composer top edge diverged:\n{dump}"
338 );
339 assert_eq!(
340 frame.colors_at(mode_edge, 1).expect("mode edge cell").0,
341 mode_color,
342 "header mode and composer bottom edge diverged:\n{dump}"
343 );
344 (mode_color, permission_color)
345 }
346
347 fn assert_real_pty_frame_geometry(frame: &qa_harness::Frame, cols: u16, rows: u16) {
348 let dump = frame.debug_dump();
349 assert_eq!(frame.cols(), cols, "parsed PTY width changed:\n{dump}");
350 assert_eq!(frame.rows(), rows, "parsed PTY height changed:\n{dump}");
351 let (cursor_row, cursor_col) = frame.cursor();
352 assert!(
353 cursor_row < rows && cursor_col < cols,
354 "cursor escaped {cols}x{rows}: ({cursor_row}, {cursor_col})\n{dump}"
355 );
356 for row in 0..rows {
357 let width = UnicodeWidthStr::width(frame.row(row).as_str());
358 assert!(
359 width <= usize::from(cols),
360 "row {row} clips at width {width} in {cols}x{rows}:\n{dump}"
361 );
362 }
363 for fatal in [
364 "panicked at",
365 "fatal runtime error",
366 "thread 'main' panicked",
367 ] {
368 assert!(!frame.contains(fatal), "TUI exposed {fatal:?}:\n{dump}");
369 }
370 }
371
372 fn assert_empty_state_hierarchy(frame: &qa_harness::Frame, ascii_safe: bool) {
373 let dump = frame.debug_dump();
374 let context = visible_row_with_text(frame, "codewhale").expect("empty-state context row");
375 let composer = visible_row_with_text(frame, COMPOSER_READY_TEXT).expect("composer row");
376 assert!(
377 context < composer,
378 "empty-state facts must precede the composer:\n{dump}"
379 );
380 if let Some(fleet) = visible_row_with_text(frame, "Fleet ready") {
381 assert!(
382 context < fleet && fleet < composer,
383 "Fleet action must follow context and precede the composer:\n{dump}"
384 );
385 if let Some(help) = visible_row_with_text(frame, "/help") {
386 assert!(
387 fleet < help && help < composer,
388 "optional help must follow Fleet and precede the composer:\n{dump}"
389 );
390 }
391 } else {
392 assert!(
393 frame.rows() <= 12,
394 "only the 12-row compact tier may shed the Fleet action:\n{dump}"
395 );
396 }
397
398 let whale_row = (2..context).find(|&row| {
399 let text = frame.row(row);
400 if ascii_safe {
401 text.chars().filter(|ch| *ch == '#').count() >= 8
402 } else {
403 text.chars()
404 .filter(|ch| {
405 matches!(
406 ch,
407 '█' | '▄' | '▀' | '▗' | '▖' | '▙' | '▝' | '▚' | '▞' | '▐'
408 )
409 })
410 .count()
411 >= 8
412 }
413 });
414 // The idle whale is earned wherever the transcript can seat it, and the
415 // rail now yields the rows rather than evicting it. 24 rows is the
416 // shipped release-evidence size and the pre-rail contract; it stands.
417 if frame.cols() >= 80 && frame.rows() >= 24 {
418 assert!(
419 whale_row.is_some(),
420 "idle whale missing where the terminal earns decorative water:\n{dump}"
421 );
422 }
423 if let Some(row) = whale_row {
424 assert!(
425 row < context,
426 "idle whale must yield before functional empty-state facts:\n{dump}"
427 );
428 }
429 }
430
431 fn write_real_pty_evidence(
432 name: &str,
433 metadata: &str,
434 frame: &qa_harness::Frame,
435 ) -> anyhow::Result<()> {
436 write_real_pty_evidence_dump(name, metadata, &frame.debug_dump())
437 }
438
439 fn write_real_pty_evidence_dump(
440 name: &str,
441 metadata: &str,
442 frame_dump: &str,
443 ) -> anyhow::Result<()> {
444 let Some(dir) = std::env::var_os("CODEWHALE_QA_EVIDENCE_DIR") else {
445 return Ok(());
446 };
447 let dir = std::path::PathBuf::from(dir);
448 std::fs::create_dir_all(&dir)?;
449 std::fs::write(
450 dir.join(format!("v091-{name}.txt")),
451 format!("real_pty=true\n{metadata}\n\n{frame_dump}"),
452 )?;
453 Ok(())
454 }
455
456 /// Capture the exact semantic frame that satisfies `predicate`. Animated
457 /// redraws may emit a clear and the replacement composition in separate PTY
458 /// drains, so pumping once more after `wait_for` can observe the in-between
459 /// clear instead of the product frame that actually met the assertion.
460 fn wait_for_frame_dump<F>(
461 h: &mut Harness,
462 mut predicate: F,
463 timeout: Duration,
464 ) -> anyhow::Result<String>
465 where
466 F: FnMut(&qa_harness::Frame) -> bool,
467 {
468 let mut captured = None;
469 h.wait_for(
470 |frame| {
471 let matches = predicate(frame);
472 if matches {
473 captured = Some(frame.debug_dump());
474 }
475 matches
476 },
477 timeout,
478 )?;
479 Ok(captured.expect("matching PTY frame must be captured"))
480 }
481
482 /// Smoke: the binary boots into an alt-screen, paints a composer, and the
483 /// header shows the project label. If this fails, the harness itself is
484 /// broken before we worry about any scenario.
485 #[test]
486 fn smoke_boot_paints_composer() -> anyhow::Result<()> {
487 let _guard = qa_pty_test_lock();
488 let (_ws, mut h) = boot_minimal()?;
489
490 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
491
492 let f = h.frame();
493 assert!(
494 f.any_visible_text(),
495 "expected non-empty frame after boot:\n{}",
496 f.debug_dump()
497 );
498
499 let _ = h.shutdown();
500 Ok(())
501 }
502
503 /// v0.9.1 visual stopship: exercise the shipped shell through a real PTY at
504 /// every release evidence size. This is parsed terminal output, not a test
505 /// renderer or generated product image.
506 #[test]
507 fn v091_real_pty_visual_matrix_preserves_control_grammar() -> anyhow::Result<()> {
508 let _guard = qa_pty_test_lock();
509 let cases = [
510 (40_u16, 12_u16, "terminal", false),
511 (60, 16, "grayscale", false),
512 (80, 24, "dark", true),
513 (100, 30, "light", false),
514 (140, 40, "dark", false),
515 ];
516 let mut theme_signatures = Vec::<(&str, String)>::new();
517
518 for (cols, rows, theme, ascii_safe) in cases {
519 let ws = make_sealed_workspace()?;
520 let codewhale_home = ws.home().join(".codewhale");
521 let codex_home = ws.home().join(".codex");
522 std::fs::create_dir_all(&codex_home)?;
523 std::fs::write(
524 codewhale_home.join("config.toml"),
525 "reasoning_effort = \"low\"\n\n[update]\ncheck_for_updates = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
526 )?;
527 std::fs::write(
528 codewhale_home.join("settings.toml"),
529 format!(
530 "theme = \"{theme}\"\nlocale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"ask\"\nlow_motion = false\nfancy_animations = true\ncomposer_border = true\n"
531 ),
532 )?;
533 std::fs::write(
534 codex_home.join("models_cache.json"),
535 serde_json::to_vec_pretty(&serde_json::json!({
536 "fetched_at": chrono::Utc::now(),
537 "models": [{"slug": "gpt-pty-fixture", "priority": 1}]
538 }))?,
539 )?;
540
541 let mut builder = Harness::builder(Harness::cargo_bin("codewhale-tui"))
542 .cwd(ws.workspace())
543 .clear_env()
544 .seal_home(ws.home())
545 .env("CODEWHALE_HOME", codewhale_home.to_string_lossy())
546 .env(
547 "DEEPSEEK_CONFIG_PATH",
548 codewhale_home.join("config.toml").to_string_lossy(),
549 )
550 .env("CODEX_HOME", codex_home.to_string_lossy())
551 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
552 .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
553 // This runtime overlay must win over the saved animation opt-in.
554 .env("NO_ANIMATIONS", "1")
555 .env("RUST_LOG", "warn")
556 .args([
557 "--workspace",
558 ws.workspace().to_str().expect("utf-8 workspace path"),
559 "--no-project-config",
560 "--skip-onboarding",
561 ])
562 .size(rows, cols);
563 if ascii_safe {
564 builder = builder.env("CODEWHALE_ASCII_SAFE", "1");
565 }
566 let mut h = builder.spawn()?;
567 enter_launch_session(&mut h)?;
568 h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;
569
570 let first = h.frame().text();
571 // Motion evidence needs two real frames separated in wall-clock time.
572 std::thread::sleep(Duration::from_millis(450));
573 h.pump();
574 let second = h.frame().text();
575 assert_eq!(
576 first, second,
577 "NO_ANIMATIONS frame moved at {cols}x{rows} ({theme})"
578 );
579
580 {
581 let frame = h.frame();
582 let dump = frame.debug_dump();
583 assert_real_pty_frame_geometry(frame, cols, rows);
584 assert_empty_state_hierarchy(frame, ascii_safe);
585 assert_control_grammar(frame, "act", "ask", COMPOSER_READY_TEXT);
586 if ascii_safe {
587 assert!(
588 frame.text().is_ascii(),
589 "ASCII-safe PTY emitted non-ASCII cells:\n{dump}"
590 );
591 }
592
593 let signature = format!("{:?}", frame.colors_at(0, 0).expect("header mark cell"));
594 if let Some((_, previous)) = theme_signatures
595 .iter()
596 .find(|(previous_theme, _)| *previous_theme == theme)
597 {
598 assert_eq!(
599 &signature, previous,
600 "theme {theme} changed ANSI signature with terminal size"
601 );
602 } else {
603 for (previous_theme, previous) in &theme_signatures {
604 assert_ne!(
605 &signature, previous,
606 "themes {previous_theme} and {theme} emitted the same ANSI signature"
607 );
608 }
609 theme_signatures.push((theme, signature));
610 }
611 write_real_pty_evidence(
612 &format!(
613 "matrix-{theme}-{cols}x{rows}{}",
614 if ascii_safe { "-ascii" } else { "" }
615 ),
616 &format!(
617 "size={cols}x{rows}\ntheme={theme}\nmode=act\npermission=ask\nreduced_motion=true\nascii_safe={ascii_safe}"
618 ),
619 frame,
620 )?;
621 }
622
623 // Prove the cool agency ramp and warm permission ramp end to end in
624 // both dark and light themes. Header labels and split composer edges
625 // must change together, and each state must retain its own ANSI color.
626 if cols == 140 || theme == "light" {
627 let (act, ask) = assert_control_grammar(h.frame(), "act", "ask", COMPOSER_READY_TEXT);
628
629 h.send(b"\t")?;
630 h.wait_for(
631 |frame| frame.row(0).contains("operate") && frame.contains(OPERATE_COMPOSER_TEXT),
632 KEY_TIMEOUT,
633 )?;
634 let (operate, _) =
635 assert_control_grammar(h.frame(), "operate", "ask", OPERATE_COMPOSER_TEXT);
636 write_real_pty_evidence(
637 &format!("agency-operate-{theme}-{cols}x{rows}"),
638 &format!(
639 "size={cols}x{rows}\ntheme={theme}\nmode=operate\npermission=ask\nreduced_motion=true\nascii_safe=false"
640 ),
641 h.frame(),
642 )?;
643
644 h.send(b"\t")?;
645 h.wait_for(
646 |frame| {
647 frame.row(0).contains("plan")
648 && frame.row(0).contains("read only")
649 && frame.contains(COMPOSER_READY_TEXT)
650 },
651 KEY_TIMEOUT,
652 )?;
653 let (plan, _) =
654 assert_control_grammar(h.frame(), "plan", "read only", COMPOSER_READY_TEXT);
655 write_real_pty_evidence(
656 &format!("agency-plan-{theme}-{cols}x{rows}"),
657 &format!(
658 "size={cols}x{rows}\ntheme={theme}\nmode=plan\npermission=read-only\nreduced_motion=true\nascii_safe=false"
659 ),
660 h.frame(),
661 )?;
662 assert_ne!(plan, act, "Plan and Act collapsed to one ANSI color");
663 assert_ne!(
664 plan, operate,
665 "Plan and Operate collapsed to one ANSI color"
666 );
667 assert_ne!(act, operate, "Act and Operate collapsed to one ANSI color");
668
669 h.send(b"\t")?;
670 h.wait_for(
671 |frame| {
672 frame.row(0).contains("act")
673 && frame.row(0).contains("ask")
674 && frame.contains(COMPOSER_READY_TEXT)
675 },
676 KEY_TIMEOUT,
677 )?;
678 assert_control_grammar(h.frame(), "act", "ask", COMPOSER_READY_TEXT);
679
680 h.send(keys::key::backtab())?;
681 h.wait_for(
682 |frame| frame.row(0).contains("act") && frame.row(0).contains("auto"),
683 KEY_TIMEOUT,
684 )?;
685 let (_, auto) = assert_control_grammar(h.frame(), "act", "auto", COMPOSER_READY_TEXT);
686 write_real_pty_evidence(
687 &format!("permission-auto-{theme}-{cols}x{rows}"),
688 &format!(
689 "size={cols}x{rows}\ntheme={theme}\nmode=act\npermission=auto\nreduced_motion=true\nascii_safe=false"
690 ),
691 h.frame(),
692 )?;
693
694 h.send(keys::key::backtab())?;
695 h.wait_for(
696 |frame| frame.row(0).contains("act") && frame.row(0).contains("Full Access"),
697 KEY_TIMEOUT,
698 )?;
699 let (_, full_access) =
700 assert_control_grammar(h.frame(), "act", "Full Access", COMPOSER_READY_TEXT);
701 write_real_pty_evidence(
702 &format!("permission-full-access-{theme}-{cols}x{rows}"),
703 &format!(
704 "size={cols}x{rows}\ntheme={theme}\nmode=act\npermission=full-access\nreduced_motion=true\nascii_safe=false"
705 ),
706 h.frame(),
707 )?;
708 assert_ne!(ask, auto, "Ask and Auto collapsed to one ANSI color");
709 assert_ne!(
710 ask, full_access,
711 "Ask and Full Access collapsed to one ANSI color"
712 );
713 assert_ne!(
714 auto, full_access,
715 "Auto and Full Access collapsed to one ANSI color"
716 );
717 }
718
719 if let Some(status) = h.wait_for_exit(Duration::from_millis(1)) {
720 return Err(anyhow::anyhow!(
721 "TUI exited during {cols}x{rows} {theme} matrix case with {status}:\n{}",
722 h.debug_dump()
723 ));
724 }
725 let _ = h.shutdown();
726 }
727
728 Ok(())
729 }
730
731 /// Returning users with a missing hosted-provider key must enter the normal
732 /// provider picker rather than a dead-end key screen. This runs with a sealed
733 /// HOME and no API key, so opening the picker is also proof that recovery does
734 /// not require a live provider request. Esc must not rewrite the configured
735 /// Kimi Code route.
736 #[test]
737 fn returning_missing_kimi_code_key_opens_picker() -> anyhow::Result<()> {
738 let _guard = qa_pty_test_lock();
739 let ws = make_sealed_workspace()?;
740 let config_path = ws.home().join(".codewhale").join("config.toml");
741 let config_before = r#"provider = "moonshot"
742
743 [providers.moonshot]
744 base_url = "https://api.kimi.com/coding/v1"
745 model = "k3"
746 "#;
747 std::fs::write(&config_path, config_before)?;
748 std::fs::write(ws.home().join(".codewhale").join(".onboarded"), "")?;
749 // This is a returning user who has already settled the independent setup
750 // checkpoint. Keep that checkpoint out of the scenario so the assertion is
751 // about missing-key recovery rather than a constitution-update modal.
752 let mut setup_state = codewhale_config::SetupState::default();
753 for step in [
754 codewhale_config::SetupStep::Language,
755 codewhale_config::SetupStep::TrustSandbox,
756 codewhale_config::SetupStep::Constitution,
757 ] {
758 setup_state.set_step(
759 step,
760 codewhale_config::StepEntry::new(
761 codewhale_config::StepStatus::Verified,
762 true,
763 "0.8.67",
764 ),
765 );
766 }
767 setup_state.set_step(
768 codewhale_config::SetupStep::ProviderModel,
769 codewhale_config::StepEntry::new(codewhale_config::StepStatus::NeedsAction, true, "0.8.67"),
770 );
771 setup_state.runtime_posture_source = codewhale_config::RuntimePostureSource::Confirmed;
772 // A returning user has also answered the first-run telemetry notice. Without
773 // this the notice is owed, and it renders on the TTY and blocks on stdin
774 // before the TUI starts — which would make this a test about the notice
775 // rather than about missing-key recovery.
776 setup_state.record_telemetry_notice(codewhale_config::TELEMETRY_NOTICE_VERSION, false);
777 setup_state
778 .complete_constitution_checkpoint("0.8.67", codewhale_config::ConstitutionChoice::Bundled);
779 setup_state.constitution_source = codewhale_config::ConstitutionSource::Bundled;
780 setup_state.save_to(
781 &ws.home()
782 .join(".codewhale")
783 .join(codewhale_config::setup_state::SETUP_STATE_FILE_NAME),
784 )?;
785 std::fs::create_dir_all(ws.workspace().join(".deepseek"))?;
786 std::fs::write(ws.workspace().join(".deepseek").join("trusted"), "")?;
787
788 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
789 .cwd(ws.workspace())
790 .clear_env()
791 .seal_home(ws.home())
792 .env("NO_ANIMATIONS", "1")
793 .env("RUST_LOG", "warn")
794 .args([
795 "--workspace",
796 ws.workspace().to_str().expect("utf-8 workspace path"),
797 "--no-project-config",
798 ])
799 .size(40, 160)
800 .spawn()?;
801
802 h.wait_for_text("Moonshot/Kimi", BOOT_TIMEOUT)?;
803 h.wait_for_text("api.kimi.com", BOOT_TIMEOUT)?;
804 h.wait_for_text("missing MOONSHOT_API_KEY / KIMI_API_KEY", BOOT_TIMEOUT)?;
805
806 // The picker rendered without mutating the configured route.
807 assert_eq!(
808 std::fs::read_to_string(&config_path)?,
809 config_before,
810 "opening recovery must leave the configured route unchanged"
811 );
812
813 let _ = h.shutdown();
814 Ok(())
815 }
816
817 /// Esc from missing-key recovery must return a returning user to the offline
818 /// composer without mutating the saved route. The state transition is covered
819 /// by `back_from_provider_onboarding` unit behavior; this end-to-end leg is
820 /// ignored because the qa PTY harness exhibits input starvation during the
821 /// recovery boot window: instrumentation shows `run_event_loop` entered and
822 /// the terminal input pump spawned, yet `event::poll` never surfaces any byte
823 /// written to the PTY in this scenario (the same harness delivers input to
824 /// the composer/trust flows). Needs a dedicated investigation of boot-time
825 /// terminal queries vs. the non-responding test PTY.
826 #[test]
827 #[ignore = "qa PTY input starvation during recovery boot; see doc comment"]
828 fn returning_missing_kimi_code_key_esc_preserves_route() -> anyhow::Result<()> {
829 let _guard = qa_pty_test_lock();
830 let ws = make_sealed_workspace()?;
831 let config_path = ws.home().join(".codewhale").join("config.toml");
832 let config_before = r#"provider = "moonshot"
833
834 [providers.moonshot]
835 base_url = "https://api.kimi.com/coding/v1"
836 model = "k3"
837 "#;
838 std::fs::write(&config_path, config_before)?;
839 std::fs::write(ws.home().join(".codewhale").join(".onboarded"), "")?;
840
841 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
842 .cwd(ws.workspace())
843 .clear_env()
844 .seal_home(ws.home())
845 .env("NO_ANIMATIONS", "1")
846 .env("RUST_LOG", "warn")
847 .args([
848 "--workspace",
849 ws.workspace().to_str().expect("utf-8 workspace path"),
850 "--no-project-config",
851 ])
852 .size(40, 160)
853 .spawn()?;
854
855 h.wait_for_text("api.kimi.com", BOOT_TIMEOUT)?;
856 h.send(keys::key::esc())?;
857 h.wait_for_text(COMPOSER_READY_TEXT, KEY_TIMEOUT)?;
858 assert_eq!(
859 std::fs::read_to_string(&config_path)?,
860 config_before,
861 "Esc from recovery must leave the configured route unchanged"
862 );
863
864 let _ = h.shutdown();
865 Ok(())
866 }
867
868 /// Regression for v0.8.61 startup: the dispatcher-side config writer produced
869 /// camelCase keys plus `[features.enabled]`, while the TUI config reader only
870 /// accepted snake_case and flat `[features]` booleans. That failed before the
871 /// TUI log initialized and looked like an interactive launch crash from the
872 /// facade. Boot through a real PTY and prove early init reaches the trust
873 /// prompt and accepts input.
874 #[test]
875 fn interactive_init_accepts_input_with_dispatcher_written_config() -> anyhow::Result<()> {
876 let _guard = qa_pty_test_lock();
877 let ws = make_sealed_workspace()?;
878 std::fs::write(
879 ws.home().join(".codewhale").join("config.toml"),
880 r#"
881 provider = "zai"
882 fallbackProviders = []
883 apiKey = "deepseek-test-key"
884 defaultTextModel = "deepseek-v4-pro"
885 authMode = "api_key"
886
887 [providers.zai]
888 apiKey = "zai-test-key"
889 authMode = "api_key"
890
891 [providers.zai.httpHeaders]
892
893 [features.enabled]
894 shell_tool = true
895 subagents = true
896 web_search = true
897 "#,
898 )?;
899
900 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
901 .cwd(ws.workspace())
902 .clear_env()
903 .seal_home(ws.home())
904 .env("RUST_LOG", "warn")
905 .args([
906 "--workspace",
907 ws.workspace().to_str().expect("utf-8 workspace path"),
908 "--no-project-config",
909 ])
910 .size(40, 140)
911 .spawn()?;
912
913 // The first-run telemetry notice is the first thing an interactive launch
914 // shows, before the terminal enters raw mode. Enter takes the pre-selected
915 // "No thanks" — which is the whole point of the pre-selection, and is what
916 // this harness exercises: a user who presses Enter through onboarding never
917 // enables telemetry.
918 h.wait_for_text("Enable telemetry?", BOOT_TIMEOUT)?;
919 h.send(keys::key::enter())?;
920 h.wait_for_text("Press Enter to continue", BOOT_TIMEOUT)?;
921 h.send(keys::key::enter())?;
922 h.wait_for_text("Choose your language", BOOT_TIMEOUT)?;
923 h.send(keys::key::enter())?;
924 // The Appearance step (#3937) sits between language and trust; Enter
925 // keeps the current theme and advances. This is also the only PTY-level
926 // execution of that step's event-loop wiring, so a hang here is a real
927 // wiring bug, not a script gap.
928 h.wait_for_text("Make It Yours", BOOT_TIMEOUT)?;
929 h.send(keys::key::enter())?;
930 h.wait_for_text("Know this workspace", BOOT_TIMEOUT)?;
931 h.wait_for_text("Press 1/Y to trust and continue", BOOT_TIMEOUT)?;
932 // Decline through the explicit quit hotkey: since bf0478395 the number
933 // keys mirror the footer's reading order (1 trust, 2 continue untrusted,
934 // 3 quit), so 3/N/Esc is the decline-and-exit leg. Enter's fail-closed
935 // behavior is covered by the deterministic onboarding unit tests; this
936 // PTY leg is the early-init/config compatibility sentinel and should not
937 // depend on a transient status-toast redraw before proving input reaches
938 // the process.
939 h.send(keys::key::ch('3'))?;
940 assert_eq!(h.wait_for_exit(KEY_TIMEOUT), Some(0));
941 Ok(())
942 }
943
944 /// Regression for #1085: after a turn exits through the error path, terminal
945 /// origin/scroll-region state must not leave blank rows above the TUI.
946 #[test]
947 fn viewport_origin_stays_row_zero_after_failed_turn() -> anyhow::Result<()> {
948 let _guard = qa_pty_test_lock();
949 let (_ws, mut h) = boot_minimal_without_retry()?;
950 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
951 assert_viewport_starts_at_top(h.frame());
952
953 h.send(keys::key::text("trigger a failed turn"))?;
954 h.wait_for_idle(Duration::from_millis(200), Duration::from_secs(2))?;
955 h.send(keys::key::enter())?;
956 h.wait_for(
957 |frame| {
958 frame.contains("Turn failed")
959 || frame.contains("Connection refused")
960 || frame.contains("error")
961 },
962 Duration::from_secs(15),
963 )?;
964 h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;
965 assert_viewport_starts_at_top(h.frame());
966
967 let _ = h.shutdown();
968 Ok(())
969 }
970
971 /// Verifies the harness actually sees keystrokes — type a character and watch
972 /// it appear in the composer. This is the lowest-effort sanity check before
973 /// we lean on it for real scenarios.
974 #[test]
975 fn smoke_keystroke_reaches_composer() -> anyhow::Result<()> {
976 let _guard = qa_pty_test_lock();
977 let (_ws, mut h) = boot_minimal()?;
978 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
979
980 h.send(keys::key::text("hello-from-pty"))?;
981 h.wait_for_text("hello-from-pty", KEY_TIMEOUT)?;
982
983 let _ = h.shutdown();
984 Ok(())
985 }
986
987 #[test]
988 fn printable_v_stays_in_composer_and_alt_help_fallback_works() -> anyhow::Result<()> {
989 let _guard = qa_pty_test_lock();
990 let (_ws, mut h) = boot_minimal()?;
991 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
992
993 h.send(keys::key::ch('v'))?;
994 h.wait_for_text("v", KEY_TIMEOUT)?;
995 assert!(
996 h.frame().contains("v"),
997 "bare v must remain composer-owned:\n{}",
998 h.debug_dump()
999 );
1000 h.send(b"\x15")?; // Ctrl+U clears the composer before testing Alt/Option.
1001 h.send(keys::key::alt('?'))?;
1002 h.wait_for(
1003 |frame| frame.contains("Help") || frame.contains("Keyboard") || frame.contains("Shortcuts"),
1004 KEY_TIMEOUT,
1005 )?;
1006
1007 let _ = h.shutdown();
1008 Ok(())
1009 }
1010
1011 #[test]
1012 fn resize_and_mouse_wheel_preserve_composer_ownership() -> anyhow::Result<()> {
1013 let _guard = qa_pty_test_lock();
1014 let ws = make_sealed_workspace()?;
1015 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
1016 .cwd(ws.workspace())
1017 .clear_env()
1018 .seal_home(ws.home())
1019 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
1020 .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
1021 .env("NO_ANIMATIONS", "1")
1022 .env("RUST_LOG", "warn")
1023 .args([
1024 "--workspace",
1025 ws.workspace().to_str().expect("utf-8 workspace path"),
1026 "--no-project-config",
1027 "--skip-onboarding",
1028 "--mouse-capture",
1029 ])
1030 .size(40, 140)
1031 .spawn()?;
1032 enter_launch_session(&mut h)?;
1033
1034 h.resize(24, 80)?;
1035 h.wait_for_idle(Duration::from_millis(200), Duration::from_secs(3))?;
1036 assert_eq!((h.frame().rows(), h.frame().cols()), (24, 80));
1037 h.send(keys::mouse::wheel_down(5, 40))?;
1038 h.send(keys::mouse::click(22, 20))?;
1039 h.send(keys::key::text("mouse-resize-proof"))?;
1040 h.wait_for_text("mouse-resize-proof", KEY_TIMEOUT)?;
1041 let dump = h.debug_dump();
1042 assert!(
1043 !dump.contains("[<65"),
1044 "mouse bytes leaked into composer:\n{dump}"
1045 );
1046
1047 let _ = h.shutdown();
1048 Ok(())
1049 }
1050
1051 #[test]
1052 fn work_surface_real_rows_own_click_wheel_and_resize() -> anyhow::Result<()> {
1053 let _guard = qa_pty_test_lock();
1054 let ws = make_sealed_workspace()?;
1055 let session_path = ws.workspace().join("mouse-work-session.json");
1056 let todos = (0..14)
1057 .map(|index| {
1058 serde_json::json!({
1059 "id": index + 1,
1060 "content": format!("todo-mouse-{index:02}"),
1061 "status": if index == 0 { "in_progress" } else { "pending" }
1062 })
1063 })
1064 .collect::<Vec<_>>();
1065 std::fs::write(
1066 &session_path,
1067 serde_json::to_vec_pretty(&serde_json::json!({
1068 "schema_version": 1,
1069 "metadata": {
1070 "id": "pty-work-mouse",
1071 "title": "Mouse work surface",
1072 "created_at": "2026-07-13T00:00:00Z",
1073 "updated_at": "2026-07-13T00:00:00Z",
1074 "message_count": 0,
1075 "total_tokens": 0,
1076 "model": "deepseek-v4-pro",
1077 "model_provider": "deepseek",
1078 "workspace": ws.workspace(),
1079 "mode": "agent",
1080 "cost": {},
1081 "cumulative_turn_secs": 0
1082 },
1083 "messages": [],
1084 "system_prompt": null,
1085 "work_state": {
1086 "todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
1087 "plan": {"objective": "", "items": []}
1088 }
1089 }))?,
1090 )?;
1091
1092 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
1093 .cwd(ws.workspace())
1094 .clear_env()
1095 .seal_home(ws.home())
1096 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
1097 .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
1098 .env("NO_ANIMATIONS", "1")
1099 .env("RUST_LOG", "warn")
1100 .args([
1101 "--workspace",
1102 ws.workspace().to_str().expect("utf-8 workspace path"),
1103 "--no-project-config",
1104 "--skip-onboarding",
1105 "--mouse-capture",
1106 "--yolo",
1107 ])
1108 .size(32, 100)
1109 .spawn()?;
1110 enter_launch_session(&mut h)?;
1111 h.send(keys::key::text(&format!(
1112 "/load {}",
1113 session_path.to_string_lossy()
1114 )))?;
1115 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
1116 h.send(keys::key::enter())?;
1117 h.wait_for_text("todo-mouse-00", KEY_TIMEOUT)?;
1118
1119 let (first_row, first_col) = h
1120 .frame()
1121 .find_text("todo-mouse-00")
1122 .expect("real rendered first To-do row");
1123
1124 // The top Work surface auto-fits its row count to the plan (capped), so
1125 // the divider is not a fixed offset from the first selectable row — find
1126 // the rendered horizontal rule itself. Send genuine SGR down/drag/up
1127 // bytes and prove the resized surface exposes additional real rows
1128 // before exercising scroll.
1129 let divider_row = (first_row..first_row.saturating_add(20))
1130 .find(|&y| {
1131 h.frame()
1132 .row(y)
1133 .chars()
1134 .filter(|&c| c == '─' || c == '━')
1135 .count()
1136 >= 20
1137 })
1138 .expect("rendered divider row below the to-do rows");
1139 h.send(keys::mouse::down(divider_row, first_col))?;
1140 h.send(keys::mouse::drag(divider_row.saturating_add(4), first_col))?;
1141 h.send(keys::mouse::up(divider_row.saturating_add(4), first_col))?;
1142 h.wait_for_text("todo-mouse-04", KEY_TIMEOUT)?;
1143
1144 for _ in 0..8 {
1145 h.send(keys::mouse::wheel_down(first_row, first_col))?;
1146 h.wait_for_idle(Duration::from_millis(40), Duration::from_secs(1))?;
1147 }
1148 h.wait_for_text("todo-mouse-13", KEY_TIMEOUT)?;
1149 h.wait_for_idle(Duration::from_millis(200), Duration::from_secs(3))?;
1150 assert!(
1151 !h.debug_dump().contains("[<65"),
1152 "wheel over work surface leaked into the transcript/composer:\n{}",
1153 h.debug_dump()
1154 );
1155
1156 h.resize(24, 80)?;
1157 h.wait_for_idle(Duration::from_millis(200), Duration::from_secs(3))?;
1158 h.wait_for_text("todo-mouse-13", KEY_TIMEOUT)?;
1159 let target = "todo-mouse-13";
1160 let (row, col) = h.frame().find_text(target).expect("row survived resize");
1161 h.send(keys::mouse::click(row, col))?;
1162 h.wait_for_text("Work", KEY_TIMEOUT)?;
1163 h.wait_for_text(target, KEY_TIMEOUT)?;
1164 h.wait_for_text("q/Esc close", KEY_TIMEOUT)?;
1165 let _ = h.shutdown();
1166 Ok(())
1167 }
1168
1169 /// Owner-reported (2026-08-04): "you cannot click a work-bar row AT ALL".
1170 /// The bare-session click test above passes, so this probe reproduces the
1171 /// dogfood shape it does not cover: an active goal title occupying the
1172 /// strip's header row, then a real SGR click on a to-do row. The click must
1173 /// open the row's world (the Work inspector pager), not just move focus.
1174 #[test]
1175 fn work_surface_rows_stay_clickable_under_a_goal_title() -> anyhow::Result<()> {
1176 let _guard = qa_pty_test_lock();
1177 let ws = make_sealed_workspace()?;
1178 std::fs::write(
1179 ws.home().join(".deepseek").join("config.toml"),
1180 "[retry]\nenabled = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
1181 )?;
1182 let session_path = ws.workspace().join("goal-click-session.json");
1183 let todos = (0..6)
1184 .map(|index| {
1185 serde_json::json!({
1186 "id": index + 1,
1187 "content": format!("todo-goal-{index:02}"),
1188 "status": if index == 0 { "in_progress" } else { "pending" }
1189 })
1190 })
1191 .collect::<Vec<_>>();
1192 std::fs::write(
1193 &session_path,
1194 serde_json::to_vec_pretty(&serde_json::json!({
1195 "schema_version": 1,
1196 "metadata": {
1197 "id": "pty-goal-click",
1198 "title": "Goal-title click probe",
1199 "created_at": "2026-08-04T00:00:00Z",
1200 "updated_at": "2026-08-04T00:00:00Z",
1201 "message_count": 0,
1202 "total_tokens": 0,
1203 "model": "deepseek-v4-pro",
1204 "model_provider": "deepseek",
1205 "workspace": ws.workspace(),
1206 "mode": "agent",
1207 "cost": {},
1208 "cumulative_turn_secs": 0
1209 },
1210 "messages": [],
1211 "system_prompt": null,
1212 "work_state": {
1213 "todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
1214 "plan": {"objective": "", "items": []}
1215 }
1216 }))?,
1217 )?;
1218
1219 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
1220 .cwd(ws.workspace())
1221 .clear_env()
1222 .seal_home(ws.home())
1223 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
1224 .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
1225 .env("NO_ANIMATIONS", "1")
1226 .env("RUST_LOG", "warn")
1227 .args([
1228 "--workspace",
1229 ws.workspace().to_str().expect("utf-8 workspace path"),
1230 "--no-project-config",
1231 "--skip-onboarding",
1232 "--mouse-capture",
1233 "--yolo",
1234 ])
1235 .size(40, 140)
1236 .spawn()?;
1237 enter_launch_session(&mut h)?;
1238 h.send(keys::key::text(&format!(
1239 "/load {}",
1240 session_path.to_string_lossy()
1241 )))?;
1242 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
1243 h.send(keys::key::enter())?;
1244 h.wait_for_text("todo-goal-00", KEY_TIMEOUT)?;
1245
1246 // Declare a goal: the strip now pins `Goal: …` above the rows, which is
1247 // the header-offset condition the bare click test never exercises. The
1248 // /goal command also fires a turn; the refused base URL fails it fast.
1249 h.send(keys::key::text("/goal click-probe objective"))?;
1250 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
1251 h.send(keys::key::enter())?;
1252 h.wait_for_text("Goal:", KEY_TIMEOUT)?;
1253 h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(5))?;
1254
1255 let target = "todo-goal-03";
1256 let (row, col) = h.frame().find_text(target).expect("rendered to-do row");
1257 h.send(keys::mouse::click(row, col))?;
1258 h.wait_for_text("q/Esc close", KEY_TIMEOUT)?;
1259 h.wait_for_text(target, KEY_TIMEOUT)?;
1260
1261 let _ = h.shutdown();
1262 Ok(())
1263 }
1264
1265 /// A loopback SSE fixture that streams one content chunk, then deliberately
1266 /// holds the connection open before finishing, so a PTY scenario can interact
1267 /// with the TUI while a turn is genuinely live (`is_loading == true`).
1268 fn spawn_slow_stream_fixture(
1269 hold: Duration,
1270 ) -> anyhow::Result<(String, std::thread::JoinHandle<()>)> {
1271 let listener = TcpListener::bind("127.0.0.1:0")?;
1272 listener.set_nonblocking(true)?;
1273 let address = listener.local_addr()?;
1274 let handle = std::thread::spawn(move || {
1275 let deadline = Instant::now() + Duration::from_secs(30);
1276 let mut served = 0usize;
1277 while served < 3 && Instant::now() < deadline {
1278 let Ok((mut stream, _)) = listener.accept() else {
1279 std::thread::sleep(Duration::from_millis(10));
1280 continue;
1281 };
1282 let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
1283 let mut request = [0u8; 64 * 1024];
1284 let _ = stream.read(&mut request);
1285 let first = format!(
1286 "data: {}\n\n",
1287 serde_json::json!({
1288 "id":"chatcmpl-slow",
1289 "object":"chat.completion.chunk",
1290 "model":"deepseek-v4-flash",
1291 "choices":[{"index":0,"delta":{"content":"SLOW-STREAM-HOLD"},"finish_reason":null}]
1292 })
1293 );
1294 let rest = [
1295 format!(
1296 "data: {}\n\n",
1297 serde_json::json!({
1298 "id":"chatcmpl-slow",
1299 "object":"chat.completion.chunk",
1300 "model":"deepseek-v4-flash",
1301 "choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
1302 "usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}
1303 })
1304 ),
1305 "data: [DONE]\n\n".to_string(),
1306 ]
1307 .join("");
1308 // No Content-Length: the reader must see the first chunk while
1309 // the turn is still open, then the tail after the hold.
1310 let _ = stream.write_all(
1311 format!(
1312 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n{first}"
1313 )
1314 .as_bytes(),
1315 );
1316 let _ = stream.flush();
1317 if served == 0 {
1318 std::thread::sleep(hold);
1319 }
1320 let _ = stream.write_all(rest.as_bytes());
1321 let _ = stream.flush();
1322 served += 1;
1323 }
1324 });
1325 Ok((format!("http://{address}"), handle))
1326 }
1327
1328 /// Second owner-repro probe: click a to-do row while a turn is actively
1329 /// streaming. The bare and goal-title probes both pass idle; dogfood clicks
1330 /// happen mid-run, so pin the live-turn path too.
1331 #[test]
1332 fn work_surface_rows_stay_clickable_during_a_live_turn() -> anyhow::Result<()> {
1333 let _guard = qa_pty_test_lock();
1334 let ws = make_sealed_workspace()?;
1335 std::fs::write(
1336 ws.home().join(".deepseek").join("config.toml"),
1337 "[retry]\nenabled = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
1338 )?;
1339 let session_path = ws.workspace().join("live-click-session.json");
1340 let todos = (0..6)
1341 .map(|index| {
1342 serde_json::json!({
1343 "id": index + 1,
1344 "content": format!("todo-live-{index:02}"),
1345 "status": if index == 0 { "in_progress" } else { "pending" }
1346 })
1347 })
1348 .collect::<Vec<_>>();
1349 std::fs::write(
1350 &session_path,
1351 serde_json::to_vec_pretty(&serde_json::json!({
1352 "schema_version": 1,
1353 "metadata": {
1354 "id": "pty-live-click",
1355 "title": "Live-turn click probe",
1356 "created_at": "2026-08-04T00:00:00Z",
1357 "updated_at": "2026-08-04T00:00:00Z",
1358 "message_count": 0,
1359 "total_tokens": 0,
1360 "model": "deepseek-v4-pro",
1361 "model_provider": "deepseek",
1362 "workspace": ws.workspace(),
1363 "mode": "agent",
1364 "cost": {},
1365 "cumulative_turn_secs": 0
1366 },
1367 "messages": [],
1368 "system_prompt": null,
1369 "work_state": {
1370 "todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
1371 "plan": {"objective": "", "items": []}
1372 }
1373 }))?,
1374 )?;
1375
1376 let (base_url, server) = spawn_slow_stream_fixture(Duration::from_secs(6))?;
1377 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
1378 .cwd(ws.workspace())
1379 .clear_env()
1380 .seal_home(ws.home())
1381 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
1382 .env("DEEPSEEK_BASE_URL", &base_url)
1383 .env("NO_ANIMATIONS", "1")
1384 .env("RUST_LOG", "warn")
1385 .args([
1386 "--workspace",
1387 ws.workspace().to_str().expect("utf-8 workspace path"),
1388 "--no-project-config",
1389 "--skip-onboarding",
1390 "--mouse-capture",
1391 "--yolo",
1392 ])
1393 .size(40, 140)
1394 .spawn()?;
1395 enter_launch_session(&mut h)?;
1396 h.send(keys::key::text(&format!(
1397 "/load {}",
1398 session_path.to_string_lossy()
1399 )))?;
1400 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
1401 h.send(keys::key::enter())?;
1402 h.wait_for_text("todo-live-00", KEY_TIMEOUT)?;
1403
1404 // Start a turn; the fixture streams one chunk then holds ~6s.
1405 h.send(keys::key::text("hold the stream open"))?;
1406 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
1407 h.send(keys::key::enter())?;
1408 h.wait_for_text("SLOW-STREAM-HOLD", Duration::from_secs(10))?;
1409
1410 // Mid-stream: the turn is live. Click a to-do row and require its world
1411 // to open, exactly as it must when idle.
1412 let target = "todo-live-03";
1413 let (row, col) = h.frame().find_text(target).expect("rendered to-do row");
1414 h.send(keys::mouse::click(row, col))?;
1415 h.wait_for_text("q/Esc close", KEY_TIMEOUT)?;
1416
1417 let _ = h.shutdown();
1418 drop(server);
1419 Ok(())
1420 }
1421
1422 /// Third owner-repro probe: a user whose settings select a non-Tasks rail
1423 /// panel (the classic sidebar_focus migration lands many upgraders on
1424 /// Pinned) must still be able to click a to-do row and have its world open.
1425 /// Before the 2026-08-04 fix, non-Tasks panels wiped every hitbox — clicks
1426 /// did nothing at all, which is exactly what the owner reported.
1427 #[test]
1428 fn pinned_panel_rows_stay_clickable_in_a_real_pty() -> anyhow::Result<()> {
1429 let _guard = qa_pty_test_lock();
1430 let ws = make_sealed_workspace()?;
1431 std::fs::write(
1432 ws.home().join(".deepseek").join("config.toml"),
1433 "[retry]\nenabled = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
1434 )?;
1435 let settings_dir = ws.home().join(".codewhale");
1436 std::fs::create_dir_all(&settings_dir)?;
1437 std::fs::write(
1438 settings_dir.join("settings.toml"),
1439 "rail_panel = \"pinned\"\n",
1440 )?;
1441
1442 let session_path = ws.workspace().join("pinned-click-session.json");
1443 let todos = (0..5)
1444 .map(|index| {
1445 serde_json::json!({
1446 "id": index + 1,
1447 "content": format!("todo-pinned-{index:02}"),
1448 "status": if index == 0 { "in_progress" } else { "pending" }
1449 })
1450 })
1451 .collect::<Vec<_>>();
1452 std::fs::write(
1453 &session_path,
1454 serde_json::to_vec_pretty(&serde_json::json!({
1455 "schema_version": 1,
1456 "metadata": {
1457 "id": "pty-pinned-click",
1458 "title": "Pinned-panel click probe",
1459 "created_at": "2026-08-04T00:00:00Z",
1460 "updated_at": "2026-08-04T00:00:00Z",
1461 "message_count": 0,
1462 "total_tokens": 0,
1463 "model": "deepseek-v4-pro",
1464 "model_provider": "deepseek",
1465 "workspace": ws.workspace(),
1466 "mode": "agent",
1467 "cost": {},
1468 "cumulative_turn_secs": 0
1469 },
1470 "messages": [],
1471 "system_prompt": null,
1472 "work_state": {
1473 "todos": {"items": todos, "completion_pct": 0, "in_progress_id": 1},
1474 "plan": {"objective": "", "items": []}
1475 }
1476 }))?,
1477 )?;
1478
1479 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
1480 .cwd(ws.workspace())
1481 .clear_env()
1482 .seal_home(ws.home())
1483 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
1484 .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
1485 .env("NO_ANIMATIONS", "1")
1486 .env("RUST_LOG", "warn")
1487 .args([
1488 "--workspace",
1489 ws.workspace().to_str().expect("utf-8 workspace path"),
1490 "--no-project-config",
1491 "--skip-onboarding",
1492 "--mouse-capture",
1493 "--yolo",
1494 ])
1495 .size(40, 140)
1496 .spawn()?;
1497 enter_launch_session(&mut h)?;
1498 h.send(keys::key::text(&format!(
1499 "/load {}",
1500 session_path.to_string_lossy()
1501 )))?;
1502 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
1503 h.send(keys::key::enter())?;
1504 h.wait_for_text("todo-pinned-00", KEY_TIMEOUT)?;
1505
1506 let target = "todo-pinned-02";
1507 let (row, col) = h.frame().find_text(target).expect("rendered to-do row");
1508 h.send(keys::mouse::click(row, col))?;
1509 h.wait_for_text("q/Esc close", KEY_TIMEOUT)?;
1510 h.wait_for_text(target, KEY_TIMEOUT)?;
1511
1512 let _ = h.shutdown();
1513 Ok(())
1514 }
1515
1516 #[test]
1517 fn real_coordination_details_use_typed_persisted_receipts_in_a_unix_pty() -> anyhow::Result<()> {
1518 let _guard = qa_pty_test_lock();
1519 let ws = make_sealed_workspace()?;
1520 let state_dir = ws.workspace().join(".codewhale").join("state");
1521 std::fs::create_dir_all(&state_dir)?;
1522 std::fs::write(
1523 state_dir.join("subagents.v1.json"),
1524 serde_json::to_vec_pretty(&serde_json::json!({
1525 "schema_version": 1,
1526 "snapshot_sequence": 6,
1527 "agents": [],
1528 "workers": [],
1529 "coordination": {
1530 "schema_version": 1,
1531 "sequence": 6,
1532 "decisions": [
1533 {
1534 "decision_id": "decision-a",
1535 "subject": "release shell",
1536 "status": "accepted",
1537 "owner": "worker-a",
1538 "scope": ["path:crates/tui"],
1539 "constraints": ["PRIVATE-TRANSCRIPT-MARKER"],
1540 "evidence_handles": [],
1541 "version": 2,
1542 "sequence": 1
1543 },
1544 {
1545 "decision_id": "decision-b",
1546 "subject": "release shell",
1547 "status": "superseded",
1548 "owner": "worker-b",
1549 "scope": ["path:crates/tui"],
1550 "constraints": [],
1551 "evidence_handles": [],
1552 "version": 1,
1553 "sequence": 2
1554 }
1555 ],
1556 "write_claims": [{
1557 "claim": {
1558 "owner": "worker-a",
1559 "roots": ["crates/tui"],
1560 "exact_files": [],
1561 "contracts": ["ui-contract"]
1562 },
1563 "sequence": 3,
1564 "isolated_worktree": false
1565 }],
1566 "reconciliations": [{
1567 "reconciliation_id": "reconcile-release-shell",
1568 "subject": "release shell",
1569 "owner": "release-owner",
1570 "input_decisions": ["decision-a", "decision-b"],
1571 "outcome": "candidate-a",
1572 "evidence_handles": [],
1573 "candidate_handles": ["branch:candidate-a", "branch:candidate-b"],
1574 "retry_count": 1,
1575 "retry_limit": 3,
1576 "reviewer_evidence_handles": ["agent:reviewer"],
1577 "verifier_evidence_handles": ["agent:verifier"],
1578 "verification_outcome": "verified",
1579 "sequence": 4
1580 }],
1581 "projections": [{
1582 "child_id": "worker-a",
1583 "decision_ids": ["decision-a"],
1584 "projected_bytes": 128,
1585 "deduplicated": 1,
1586 "omitted": 0,
1587 "sequence": 5
1588 }],
1589 "contentions": [{
1590 "claimant": "worker-b",
1591 "conflicting_owner": "worker-a",
1592 "roots": ["crates/tui"],
1593 "exact_files": ["Cargo.toml"],
1594 "contracts": ["ui-contract"],
1595 "disposition": "blocked_pending_isolation_or_serialization",
1596 "sequence": 6
1597 }]
1598 }
1599 }))?,
1600 )?;
1601
1602 let (ws, mut h) = spawn_minimal_with_env(ws, &[])?;
1603 h.wait_for_idle(Duration::from_millis(250), Duration::from_secs(3))?;
1604 let ambient = h.frame().debug_dump();
1605 assert!(
1606 !ambient.contains("Coordination Work") && !ambient.contains("PRIVATE-TRANSCRIPT-MARKER"),
1607 "coordination details leaked into ambient chrome:\n{ambient}"
1608 );
1609 let _ = h.shutdown();
1610
1611 std::fs::write(
1612 ws.home().join(".codewhale").join("settings.toml"),
1613 "work_surface_placement = \"right\"\n",
1614 )?;
1615 let (_ws, mut h) = spawn_minimal_with_env(ws, &[])?;
1616 h.wait_for_text("Coordination Work", KEY_TIMEOUT)?;
1617 h.send(keys::key::alt('w'))?;
1618 h.wait_for_idle(Duration::from_millis(80), Duration::from_secs(2))?;
1619 h.send(keys::key::enter())?;
1620 let required_details = [
1621 "decision-a · release shell",
1622 "status accepted · owner worker-a · version 2",
1623 "claimant worker-b · owner worker-a",
1624 "paths crates/tui, Cargo.toml",
1625 "contracts ui-contract",
1626 "disposition blocked_pending_isolation_or_serialization",
1627 "release shell · 2 candidates · retry 1/3",
1628 "reviewer agent:reviewer",
1629 "verifier agent:verifier",
1630 "verification verified",
1631 "worker-a · decisions decision-a · 128 bytes · 1 deduplicated · 0 omitted",
1632 ];
1633 for required in required_details {
1634 h.wait_for_text(required, KEY_TIMEOUT)?;
1635 }
1636 let wide = wait_for_frame_dump(
1637 &mut h,
1638 |frame| required_details.iter().all(|detail| frame.contains(detail)),
1639 KEY_TIMEOUT,
1640 )?;
1641 assert!(!wide.contains("PRIVATE-TRANSCRIPT-MARKER"), "{wide}");
1642 write_real_pty_evidence_dump(
1643 "coordination-details-wide-140x40",
1644 "size=140x40\nstate=persisted-coordination\nplacement=right\naction=Alt+W then Enter\nprivate_marker_rendered=false",
1645 &wide,
1646 )?;
1647
1648 h.resize(18, 60)?;
1649 let narrow = wait_for_frame_dump(
1650 &mut h,
1651 |frame| {
1652 frame.rows() == 18
1653 && frame.cols() == 60
1654 && frame.contains("Coordination Work")
1655 && frame.contains("decision-a")
1656 },
1657 KEY_TIMEOUT,
1658 )?;
1659 assert!(!narrow.contains("PRIVATE-TRANSCRIPT-MARKER"), "{narrow}");
1660 assert!(narrow.contains("decision-a"), "{narrow}");
1661 write_real_pty_evidence_dump(
1662 "coordination-details-narrow-60x18",
1663 "size=60x18\nstate=persisted-coordination\naction=resize with pager open\nprivate_marker_rendered=false",
1664 &narrow,
1665 )?;
1666
1667 let _ = h.shutdown();
1668 Ok(())
1669 }
1670
1671 #[test]
1672 fn approval_modal_keeps_wheel_for_review_and_denies_without_side_effect() -> anyhow::Result<()> {
1673 let _guard = qa_pty_test_lock();
1674 let (base_url, server) = spawn_approval_fixture_server()?;
1675 let ws = make_sealed_workspace()?;
1676 let denied_path = ws.workspace().join("approval-proof.txt");
1677 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
1678 .cwd(ws.workspace())
1679 .clear_env()
1680 .seal_home(ws.home())
1681 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
1682 .env("DEEPSEEK_BASE_URL", &base_url)
1683 .env("NO_ANIMATIONS", "1")
1684 .env("RUST_LOG", "warn")
1685 .args([
1686 "--workspace",
1687 ws.workspace().to_str().expect("utf-8 workspace path"),
1688 "--no-project-config",
1689 "--skip-onboarding",
1690 "--mouse-capture",
1691 ])
1692 .size(32, 100)
1693 .spawn()?;
1694 enter_launch_session(&mut h)?;
1695
1696 let prompt = "Request the fixture File.write call; do not change its arguments.";
1697 // This is a whole prompt, not simulated human typing. Send it as the
1698 // bracketed paste a real terminal would emit so the raw-key paste-burst
1699 // heuristic cannot absorb the following Enter as a pasted newline.
1700 h.paste(prompt)?;
1701 h.wait_for_text(prompt, KEY_TIMEOUT)?;
1702 h.wait_for_idle(Duration::from_millis(100), Duration::from_secs(2))?;
1703 h.send(keys::key::enter())?;
1704 h.wait_for_text("Allow once", Duration::from_secs(10))?;
1705 h.wait_for_text("Deny this call", KEY_TIMEOUT)?;
1706
1707 let (deny_row, deny_col) = h
1708 .frame()
1709 .find_text("Deny this call")
1710 .expect("rendered denial option");
1711 h.send(keys::key::page_up())?;
1712 h.wait_for_text("❯ [3 / d / n]", KEY_TIMEOUT)?;
1713 h.send(keys::mouse::wheel_down(deny_row, deny_col))?;
1714 h.wait_for_text("❯ [3 / d / n]", KEY_TIMEOUT)?;
1715 h.resize(24, 80)?;
1716 h.wait_for(
1717 |frame| frame.rows() == 24 && frame.cols() == 80,
1718 KEY_TIMEOUT,
1719 )?;
1720 h.wait_for_idle(Duration::from_millis(200), Duration::from_secs(3))?;
1721 h.wait_for_text("Deny this call", KEY_TIMEOUT)?;
1722 let (deny_row, deny_col) = h
1723 .frame()
1724 .find_text("Deny this call")
1725 .expect("denial option survived resize");
1726 h.send(keys::mouse::wheel_down(deny_row, deny_col))?;
1727 h.wait_for_text("❯ [3 / d / n]", KEY_TIMEOUT)?;
1728 h.send(keys::mouse::click(deny_row, deny_col))?;
1729 if let Err(err) = h.wait_for_text("DENIAL-HONORED", Duration::from_secs(10)) {
1730 let logs = std::fs::read_dir(ws.home().join(".codewhale/logs"))
1731 .ok()
1732 .into_iter()
1733 .flatten()
1734 .filter_map(Result::ok)
1735 .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
1736 .collect::<Vec<_>>()
1737 .join("\n");
1738 return Err(anyhow::anyhow!("{err:#}\napproval logs:\n{logs}"));
1739 }
1740 assert!(
1741 !denied_path.exists(),
1742 "denied approval executed its File.write side effect: {}",
1743 denied_path.display()
1744 );
1745
1746 let _ = h.shutdown();
1747 server.join().expect("approval fixture server thread");
1748 Ok(())
1749 }
1750
1751 /// Release stopship coverage: a real built TUI restores durable To-do state and
1752 /// keeps both active To-dos and the effective permission posture visible at each
1753 /// supported compact evidence size. No model turn is sent.
1754 #[test]
1755 fn work_and_permission_are_visible_at_release_terminal_sizes() -> anyhow::Result<()> {
1756 let _guard = qa_pty_test_lock();
1757
1758 for (cols, rows) in [(120_u16, 32_u16), (100, 30), (80, 24), (60, 16), (40, 12)] {
1759 let ws = make_sealed_workspace()?;
1760 let codewhale_home = ws.home().join(".codewhale");
1761 let codex_home = ws.home().join(".codex");
1762 std::fs::create_dir_all(&codex_home)?;
1763 std::fs::write(
1764 codewhale_home.join("config.toml"),
1765 "allow_shell = true\nreasoning_effort = \"low\"\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
1766 )?;
1767 std::fs::write(
1768 codewhale_home.join("settings.toml"),
1769 "permission_posture = \"full-access\"\n",
1770 )?;
1771 std::fs::write(
1772 codex_home.join("models_cache.json"),
1773 serde_json::to_vec_pretty(&serde_json::json!({
1774 "fetched_at": chrono::Utc::now(),
1775 "models": [{"slug": "gpt-pty-fixture", "priority": 1}]
1776 }))?,
1777 )?;
1778
1779 let session_path = ws.workspace().join("release-work-session.json");
1780 std::fs::write(
1781 &session_path,
1782 serde_json::to_vec_pretty(&serde_json::json!({
1783 "schema_version": 1,
1784 "metadata": {
1785 "id": format!("pty-{cols}x{rows}"),
1786 "title": "Release Work continuity",
1787 "created_at": "2026-07-10T00:00:00Z",
1788 "updated_at": "2026-07-10T00:00:00Z",
1789 "message_count": 0,
1790 "total_tokens": 0,
1791 "model": "deepseek-v4-pro",
1792 "model_provider": "deepseek",
1793 "workspace": ws.workspace(),
1794 "mode": "operate",
1795 "cost": {},
1796 "cumulative_turn_secs": 0
1797 },
1798 "messages": [],
1799 "system_prompt": null,
1800 "work_state": {
1801 "todos": {
1802 "items": [
1803 {"id": 1, "content": "persisted inspect", "status": "completed"},
1804 {"id": 2, "content": "persisted patch", "status": "in_progress"}
1805 ],
1806 "completion_pct": 50,
1807 "in_progress_id": 2
1808 },
1809 "plan": {
1810 "objective": "Keep release Work visible",
1811 "items": [
1812 {"step": "verify PTY", "status": "in_progress"}
1813 ]
1814 }
1815 }
1816 }))?,
1817 )?;
1818
1819 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
1820 .cwd(ws.workspace())
1821 .clear_env()
1822 .seal_home(ws.home())
1823 .env("CODEWHALE_HOME", codewhale_home.to_string_lossy())
1824 .env(
1825 "DEEPSEEK_CONFIG_PATH",
1826 codewhale_home.join("config.toml").to_string_lossy(),
1827 )
1828 .env("CODEX_HOME", codex_home.to_string_lossy())
1829 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
1830 .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
1831 .env("NO_ANIMATIONS", "1")
1832 .env("RUST_LOG", "warn")
1833 .args([
1834 "--workspace",
1835 ws.workspace().to_str().expect("utf-8 workspace path"),
1836 "--no-project-config",
1837 "--skip-onboarding",
1838 ])
1839 .size(rows, cols)
1840 .spawn()?;
1841
1842 enter_launch_session(&mut h)?;
1843 h.send(keys::key::text(&format!(
1844 "/load {}",
1845 session_path.to_string_lossy()
1846 )))?;
1847 h.wait_for_text("/load", KEY_TIMEOUT)?;
1848 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
1849 h.send(keys::key::enter())?;
1850 h.wait_for_text("To-do ·", KEY_TIMEOUT)?;
1851 h.wait_for_text("Full Access", KEY_TIMEOUT)?;
1852 h.wait_for_idle(Duration::from_millis(250), Duration::from_secs(3))?;
1853
1854 let frame = h.frame();
1855 let dump = frame.debug_dump();
1856 assert!(
1857 frame.contains("To-do · 1/3 · 2 left"),
1858 "numbered To-do progress missing at {cols}x{rows}:\n{dump}"
1859 );
1860 assert!(
1861 frame.contains("1 ·") && frame.contains("verify PTY"),
1862 "canonical current To-do missing at {cols}x{rows}:\n{dump}"
1863 );
1864 assert!(
1865 frame.contains("Full Access"),
1866 "effective permission missing at {cols}x{rows}:\n{dump}"
1867 );
1868 assert!(
1869 frame.contains("Operate") || frame.contains("operate"),
1870 "restored mode missing at {cols}x{rows}:\n{dump}"
1871 );
1872 let header = frame.row(0);
1873 let effort_receipt = if cols >= 60 { " · low " } else { " · l " };
1874 assert!(
1875 header.contains(effort_receipt),
1876 "effective effort missing at {cols}x{rows}: {header:?}\n{dump}"
1877 );
1878
1879 if let Some(dir) = std::env::var_os("CODEWHALE_QA_EVIDENCE_DIR") {
1880 let dir = std::path::PathBuf::from(dir);
1881 std::fs::create_dir_all(&dir)?;
1882 std::fs::write(dir.join(format!("tui-{cols}x{rows}.txt")), dump)?;
1883 }
1884
1885 let _ = h.shutdown();
1886 }
1887 Ok(())
1888 }
1889
1890 /// WG6 integrated proof: a real TUI migrates legacy Plan/To-do state, records
1891 /// Ctrl+T as a typed receipt, preserves it across explicit save/export/save,
1892 /// and restores the same graph-backed Work state after process restart.
1893 #[test]
1894 fn legacy_work_ctrl_t_save_export_and_restart_are_consistent() -> anyhow::Result<()> {
1895 let _guard = qa_pty_test_lock();
1896 let ws = make_sealed_workspace()?;
1897 let codewhale_home = ws.home().join(".codewhale");
1898 let codex_home = ws.home().join(".codex");
1899 std::fs::create_dir_all(&codex_home)?;
1900 std::fs::write(
1901 codewhale_home.join("config.toml"),
1902 "allow_shell = true\nreasoning_effort = \"low\"\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
1903 )?;
1904 std::fs::write(
1905 codewhale_home.join("settings.toml"),
1906 "permission_posture = \"full-access\"\n",
1907 )?;
1908 std::fs::write(
1909 codex_home.join("models_cache.json"),
1910 serde_json::to_vec_pretty(&serde_json::json!({
1911 "fetched_at": chrono::Utc::now(),
1912 "models": [{"slug": "gpt-pty-fixture", "priority": 1}]
1913 }))?,
1914 )?;
1915
1916 let legacy_path = ws.workspace().join("legacy-work-session.json");
1917 std::fs::write(
1918 &legacy_path,
1919 serde_json::to_vec_pretty(&serde_json::json!({
1920 "schema_version": 1,
1921 "metadata": {
1922 "id": "pty-wg6-legacy",
1923 "title": "WG6 legacy continuity",
1924 "created_at": "2026-07-10T00:00:00Z",
1925 "updated_at": "2026-07-10T00:00:00Z",
1926 "message_count": 0,
1927 "total_tokens": 0,
1928 "model": "deepseek-v4-pro",
1929 "model_provider": "deepseek",
1930 "workspace": ws.workspace(),
1931 "mode": "operate",
1932 "cost": {},
1933 "cumulative_turn_secs": 0
1934 },
1935 "messages": [],
1936 "system_prompt": null,
1937 "work_state": {
1938 "todos": {
1939 "items": [
1940 {"id": 1, "content": "persisted inspect", "status": "completed"},
1941 {"id": 2, "content": "persisted patch", "status": "in_progress"}
1942 ],
1943 "completion_pct": 50,
1944 "in_progress_id": 2
1945 },
1946 "plan": {
1947 "objective": "Keep WG6 Work durable",
1948 "items": [{"step": "verify integrated PTY", "status": "in_progress"}]
1949 }
1950 }
1951 }))?,
1952 )?;
1953
1954 let spawn = || {
1955 Harness::builder(Harness::cargo_bin("codewhale-tui"))
1956 .cwd(ws.workspace())
1957 .clear_env()
1958 .seal_home(ws.home())
1959 .env("CODEWHALE_HOME", codewhale_home.to_string_lossy())
1960 .env(
1961 "DEEPSEEK_CONFIG_PATH",
1962 codewhale_home.join("config.toml").to_string_lossy(),
1963 )
1964 .env("CODEX_HOME", codex_home.to_string_lossy())
1965 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
1966 .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
1967 .env("NO_ANIMATIONS", "1")
1968 .env("RUST_LOG", "warn")
1969 .args([
1970 "--workspace",
1971 ws.workspace().to_str().expect("utf-8 workspace path"),
1972 "--no-project-config",
1973 "--skip-onboarding",
1974 ])
1975 .size(16, 60)
1976 .spawn()
1977 };
1978
1979 let mut h = spawn()?;
1980 enter_launch_session(&mut h)?;
1981 h.send(keys::key::text(&format!(
1982 "/load {}",
1983 legacy_path.to_string_lossy()
1984 )))?;
1985 h.wait_for_text("/load", KEY_TIMEOUT)?;
1986 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
1987 h.send(keys::key::enter())?;
1988 h.wait_for_text("To-do ·", KEY_TIMEOUT)?;
1989 h.wait_for_idle(Duration::from_millis(250), Duration::from_secs(3))?;
1990 assert!(
1991 h.frame().contains("To-do · 1/3 · 2 left")
1992 && h.frame().contains("1 ·")
1993 && h.frame().contains("verify integrated PTY"),
1994 "{}",
1995 h.frame().debug_dump()
1996 );
1997
1998 h.send(b"\x14")?;
1999 h.wait_for_text("Reasoning effort: max", KEY_TIMEOUT)?;
2000 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
2001 let cycled = h.frame();
2002 assert!(
2003 cycled.row(0).contains(" · max ") && cycled.row(0).contains("Full Access"),
2004 "Ctrl+T effort missing from narrow header:\n{}",
2005 cycled.debug_dump()
2006 );
2007 assert!(cycled.contains("To-do ·"), "{}", cycled.debug_dump());
2008
2009 let before_path = ws.workspace().join("wg6-before-export.json");
2010 h.send(keys::key::text("/save wg6-before-export.json"))?;
2011 h.wait_for_text("/save", KEY_TIMEOUT)?;
2012 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
2013 h.send(keys::key::enter())?;
2014 h.wait_for_text("Session saved to", KEY_TIMEOUT)?;
2015
2016 let export_path = ws.workspace().join("wg6-export.md");
2017 h.send(keys::key::text("/export wg6-export.md"))?;
2018 h.wait_for_text("/export", KEY_TIMEOUT)?;
2019 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
2020 h.send(keys::key::enter())?;
2021 h.wait_for_text("Conversation exported to", KEY_TIMEOUT)?;
2022
2023 let after_path = ws.workspace().join("wg6-after-export.json");
2024 h.send(keys::key::text("/save wg6-after-export.json"))?;
2025 h.wait_for_text("/save", KEY_TIMEOUT)?;
2026 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
2027 h.send(keys::key::enter())?;
2028 h.wait_for_text("Session saved to", KEY_TIMEOUT)?;
2029
2030 let deadline = Instant::now() + KEY_TIMEOUT;
2031 while (!before_path.exists() || !after_path.exists() || !export_path.exists())
2032 && Instant::now() < deadline
2033 {
2034 h.pump();
2035 std::thread::sleep(Duration::from_millis(20));
2036 }
2037 assert!(before_path.exists(), "first save was not written");
2038 assert!(after_path.exists(), "post-export save was not written");
2039 assert!(export_path.exists(), "export was not written");
2040
2041 let before: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&before_path)?)?;
2042 let after: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&after_path)?)?;
2043 let work = &before["work_state"];
2044 assert!(work["graph"].is_object(), "migrated graph missing: {work}");
2045 assert_eq!(
2046 work["todos"]["items"][1]["content"], "persisted patch",
2047 "visible legacy To-do state drifted"
2048 );
2049 assert_eq!(
2050 work["plan"]["objective"], "Keep WG6 Work durable",
2051 "visible legacy Plan state drifted"
2052 );
2053 let activity = work["graph"]["activities"]
2054 .as_array()
2055 .and_then(|activities| activities.last())
2056 .expect("Ctrl+T Work activity");
2057 assert_eq!(activity["kind"], "reasoning_effort_changed");
2058 assert_eq!(activity["requested"], "max");
2059 assert_eq!(activity["effective"], "max");
2060 assert_eq!(activity["provider"], "deepseek");
2061 let receipt = activity.as_object().expect("typed activity object");
2062 for forbidden in ["text", "content", "reasoning", "reasoning_text"] {
2063 assert!(
2064 !receipt.contains_key(forbidden),
2065 "activity leaked forbidden field {forbidden}: {activity}"
2066 );
2067 }
2068 assert_eq!(
2069 before["work_state"], after["work_state"],
2070 "export mutated graph-backed Work state"
2071 );
2072 assert!(
2073 std::fs::read_to_string(&export_path)?.contains("# Codewhale conversation export"),
2074 "full export artifact missing"
2075 );
2076 let _ = h.shutdown();
2077
2078 let mut restored = spawn()?;
2079 enter_launch_session(&mut restored)?;
2080 // The Ctrl+T selection above is the user's last explicit choice, so it is
2081 // the startup default the next launch must come up with — before any
2082 // session is loaded. Asserting it here separates the two mechanisms that
2083 // could otherwise both explain a `max` header after `/load`: a persisted
2084 // startup default, or session-restored state.
2085 assert!(
2086 restored.frame().row(0).contains(" · max "),
2087 "fresh launch lost the persisted effort selection:\n{}",
2088 restored.frame().debug_dump()
2089 );
2090 restored.send(keys::key::text(&format!(
2091 "/load {}",
2092 after_path.to_string_lossy()
2093 )))?;
2094 restored.wait_for_text("/load", KEY_TIMEOUT)?;
2095 restored.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
2096 restored.send(keys::key::enter())?;
2097 restored.wait_for_text("To-do ·", KEY_TIMEOUT)?;
2098 restored.wait_for_idle(Duration::from_millis(250), Duration::from_secs(3))?;
2099 let frame = restored.frame();
2100 assert!(
2101 frame.contains("To-do · 1/3 · 2 left")
2102 && frame.contains("1 ·")
2103 && frame.contains("verify integrated PTY"),
2104 "{}",
2105 frame.debug_dump()
2106 );
2107 assert!(
2108 frame.row(0).contains(" · max ") && frame.row(0).contains("Full Access"),
2109 "restart lost narrow effort/permission truth:\n{}",
2110 frame.debug_dump()
2111 );
2112 let _ = restored.shutdown();
2113 Ok(())
2114 }
2115
2116 /// A composer `!` command is a host-owned shell turn. Cancelling it must
2117 /// settle the transcript card instead of leaving a permanent `run running`
2118 /// spinner after the process has been killed.
2119 #[test]
2120 fn cancelled_bang_shell_settles_transcript_card() -> anyhow::Result<()> {
2121 let _guard = qa_pty_test_lock();
2122 let ws = make_sealed_workspace()?;
2123 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
2124 .cwd(ws.workspace())
2125 .clear_env()
2126 .seal_home(ws.home())
2127 // Match the Android/Termux release probe: `--skip-onboarding` with no
2128 // provider credential leaves the bang shell as the first transcript
2129 // cell, which is the cache-transition edge this regression covers.
2130 .env("DEEPSEEK_API_KEY", "")
2131 .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
2132 .env("NO_ANIMATIONS", "1")
2133 .env("RUST_LOG", "warn")
2134 .args([
2135 "--workspace",
2136 ws.workspace().to_str().expect("utf-8 workspace path"),
2137 "--no-project-config",
2138 "--skip-onboarding",
2139 "--yolo",
2140 ])
2141 .size(32, 120)
2142 .spawn()?;
2143
2144 enter_launch_session(&mut h)?;
2145 let command = "! echo $$ > shell.pid; sleep 30 & echo $! > sleep.pid; \
2146 echo CWQA_SHELL_STARTED; wait";
2147 h.send(keys::key::text(command))?;
2148 h.wait_for_text("CWQA_SHELL_STARTED", KEY_TIMEOUT)?;
2149 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
2150 h.send(keys::key::enter())?;
2151 h.wait_for_text("run running", KEY_TIMEOUT)?;
2152 let process_deadline = std::time::Instant::now() + KEY_TIMEOUT;
2153 while (!ws.workspace().join("shell.pid").exists() || !ws.workspace().join("sleep.pid").exists())
2154 && std::time::Instant::now() < process_deadline
2155 {
2156 std::thread::sleep(Duration::from_millis(20));
2157 }
2158 assert!(ws.workspace().join("shell.pid").exists());
2159 assert!(ws.workspace().join("sleep.pid").exists());
2160
2161 h.send(b"\x03")?;
2162 h.wait_for_text("Request cancelled", KEY_TIMEOUT)?;
2163 h.wait_for(
2164 |frame| !frame.contains("run running"),
2165 Duration::from_secs(5),
2166 )?;
2167 h.wait_for_idle(Duration::from_millis(250), Duration::from_secs(5))?;
2168
2169 let frame = h.frame();
2170 let dump = frame.debug_dump();
2171 assert!(
2172 !frame.contains("run running"),
2173 "cancelled bang shell stayed live in transcript:\n{dump}"
2174 );
2175 assert!(
2176 frame.contains("run issue") || frame.contains("interrupted"),
2177 "cancelled bang shell did not expose a terminal card:\n{dump}"
2178 );
2179 assert!(
2180 !frame.contains("turn completed"),
2181 "cancelled bang shell was reported as a completed turn:\n{dump}"
2182 );
2183
2184 let _ = h.shutdown();
2185 Ok(())
2186 }
2187
2188 /// Bare `/skills` opens the unified Skills Manager (owned-only, zero network).
2189 /// Compatible roots (e.g. `.agents/skills`) appear only after toggling scan mode.
2190 #[test]
2191 #[ignore = "runs in its own CI process to avoid PTY event interference from the full suite"]
2192 fn skills_opens_manager_owned_then_compatible() -> anyhow::Result<()> {
2193 let _guard = qa_pty_test_lock();
2194 let ws = make_sealed_workspace()?;
2195 // Compatible external root — hidden until the user presses `c`.
2196 write_skill(
2197 ws.workspace().join(".agents").join("skills"),
2198 "workspace-beta",
2199 "Workspace beta skill",
2200 )?;
2201
2202 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
2203 .cwd(ws.workspace())
2204 .clear_env()
2205 .seal_home(ws.home())
2206 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
2207 .env("DEEPSEEK_BASE_URL", "http://127.0.0.1:1")
2208 .env("NO_ANIMATIONS", "1")
2209 .env("RUST_LOG", "warn")
2210 .args([
2211 "--workspace",
2212 ws.workspace().to_str().expect("utf-8 workspace path"),
2213 "--no-project-config",
2214 "--skip-onboarding",
2215 ])
2216 .size(40, 140)
2217 .spawn()?;
2218
2219 enter_launch_session(&mut h)?;
2220 h.send(keys::key::text("/skills"))?;
2221 h.wait_for_text("/skills", KEY_TIMEOUT)?;
2222 h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;
2223 h.send(keys::key::enter())?;
2224 h.wait_for_text("Skills Manager", KEY_TIMEOUT)?;
2225 h.wait_for_text("scan=owned import-target=global idle", KEY_TIMEOUT)?;
2226
2227 let owned = h.frame();
2228 let owned_dump = owned.debug_dump();
2229 assert!(
2230 !owned.contains("workspace-beta"),
2231 "compatible skill must stay hidden in owned-only scan:\n{owned_dump}"
2232 );
2233 assert!(
2234 !owned.contains("Available skills"),
2235 "bare /skills must open manager, not the legacy text list:\n{owned_dump}"
2236 );
2237 assert!(
2238 !owned.contains("Fetching registry") && !owned.contains("registry.json"),
2239 "default manager must stay zero-network:\n{owned_dump}"
2240 );
2241
2242 // Toggle to compatible scan so external roots appear. The mode change runs
2243 // a bounded filesystem audit synchronously; on cold Linux CI filesystems,
2244 // hashing the bundled skill tree can legitimately take longer than an
2245 // ordinary key response. Wait for the explicit mode receipt with the scan
2246 // budget, then use the ordinary interaction budget for the rendered row.
2247 h.send(keys::key::ch('c'))?;
2248 h.wait_for_text("scan=compatible", SKILL_SCAN_TIMEOUT)?;
2249 h.wait_for_text("workspace-beta", KEY_TIMEOUT)?;
2250 let compat = h.frame();
2251 let compat_dump = compat.debug_dump();
2252 assert!(
2253 compat.contains("workspace-beta"),
2254 "compatible skill missing after toggle:\n{compat_dump}"
2255 );
2256
2257 h.send(keys::key::esc())?;
2258 h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;
2259 h.wait_for_text(COMPOSER_READY_TEXT, KEY_TIMEOUT)?;
2260 let after = h.frame();
2261 assert!(
2262 !after.contains("Skills Manager"),
2263 "Esc should close the skills manager:\n{}",
2264 after.debug_dump()
2265 );
2266
2267 let _ = h.shutdown();
2268 Ok(())
2269 }
2270
2271 // ===========================================================================
2272 // #1073 — pasting multi-line text with a trailing newline must NOT auto-submit
2273 // ===========================================================================
2274
2275 /// Bracketed-paste path: terminal wraps the payload in `ESC[200~ … ESC[201~`,
2276 /// crossterm delivers an `Event::Paste(text)`, and the TUI's bracketed path
2277 /// inserts it into the composer. The trailing `\n` should leave the composer
2278 /// holding the text, not start a turn.
2279 #[test]
2280 fn paste_bracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {
2281 let _guard = qa_pty_test_lock();
2282 let (_ws, mut h) = boot_minimal()?;
2283 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
2284
2285 // ~200 chars matching the original report. Trailing newline is the
2286 // payload that historically triggered the auto-submit.
2287 let payload = "first line of the multi-line paste body\n\
2288 second line continuing the paragraph until the end\n\
2289 third line that finishes with a trailing newline character\n";
2290 h.paste(payload)?;
2291 h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;
2292
2293 let f = h.frame();
2294 let dump = f.debug_dump();
2295
2296 // Auto-submit would replace the composer with a "working / thinking"
2297 // status chip and clear the composer text. Either signal indicates the
2298 // bug fired.
2299 assert!(
2300 !f.contains("Working") && !f.contains("thinking") && !f.contains("Thinking"),
2301 "bracketed paste with trailing newline auto-submitted:\n{dump}"
2302 );
2303 assert!(
2304 f.contains("first line") || f.contains("third line"),
2305 "pasted text should be visible in composer:\n{dump}"
2306 );
2307
2308 let _ = h.shutdown();
2309 Ok(())
2310 }
2311
2312 /// A macOS terminal's Cmd+V is handled on the client: it injects bracketed
2313 /// paste bytes into the Linux SSH PTY. SSH detection must not divert that
2314 /// event into the remote host clipboard path.
2315 #[test]
2316 fn paste_bracketed_from_macos_client_into_linux_ssh_stays_in_composer() -> anyhow::Result<()> {
2317 let _guard = qa_pty_test_lock();
2318 let (_ws, mut h) = boot_minimal_over_ssh()?;
2319 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
2320
2321 let payload = "mac-client-to-linux-host\nsecond-line-stays-in-composer";
2322 h.paste(payload)?;
2323 h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(2))?;
2324
2325 let frame = h.frame();
2326 let dump = frame.debug_dump();
2327 assert!(
2328 frame.contains("mac-client-to-linux-host"),
2329 "SSH bracketed paste was not inserted:\n{dump}"
2330 );
2331 assert!(
2332 !frame.contains("Working") && !frame.contains("thinking"),
2333 "SSH bracketed paste unexpectedly submitted a turn:\n{dump}"
2334 );
2335
2336 let _ = h.shutdown();
2337 Ok(())
2338 }
2339
2340 /// End-to-end regression for SSH inside stock tmux: make a real Codewhale
2341 /// selection, press the in-app Ctrl+C binding, and verify the text reaches the
2342 /// tmux paste buffer through `load-buffer -w`. A stock `/dev/null` tmux config
2343 /// keeps `allow-passthrough` off, which is the case the old DCS wrapper lost.
2344 #[test]
2345 fn copy_selection_over_ssh_uses_default_tmux_clipboard_path() -> anyhow::Result<()> {
2346 let _guard = qa_pty_test_lock();
2347 if !Command::new("tmux")
2348 .arg("-V")
2349 .status()
2350 .is_ok_and(|status| status.success())
2351 {
2352 eprintln!("skipping SSH tmux PTY test: tmux is unavailable");
2353 return Ok(());
2354 }
2355
2356 let nonce = std::time::SystemTime::now()
2357 .duration_since(std::time::UNIX_EPOCH)?
2358 .as_nanos();
2359 let socket = format!("codewhale-pty-{}-{nonce}", std::process::id());
2360 struct TmuxServer(String);
2361 impl Drop for TmuxServer {
2362 fn drop(&mut self) {
2363 let _ = Command::new("tmux")
2364 .args(["-L", self.0.as_str(), "kill-server"])
2365 .status();
2366 }
2367 }
2368 let server = TmuxServer(socket);
2369 let started = Command::new("tmux")
2370 .args([
2371 "-L",
2372 server.0.as_str(),
2373 "-f",
2374 "/dev/null",
2375 "new-session",
2376 "-d",
2377 ])
2378 .status()?;
2379 anyhow::ensure!(started.success(), "isolated tmux server failed to start");
2380 let tmux_env = Command::new("tmux")
2381 .args([
2382 "-L",
2383 server.0.as_str(),
2384 "display-message",
2385 "-p",
2386 "-t",
2387 "0",
2388 "#{socket_path},#{session_id},#{window_id}",
2389 ])
2390 .output()?;
2391 anyhow::ensure!(tmux_env.status.success(), "could not resolve TMUX value");
2392 let tmux_env = String::from_utf8(tmux_env.stdout)?.trim().to_string();
2393 let path = std::env::var("PATH").unwrap_or_default();
2394
2395 let ws = make_sealed_workspace()?;
2396 let (_ws, mut h) = spawn_minimal_with_env(
2397 ws,
2398 &[
2399 ("SSH_CONNECTION", "192.0.2.10 51234 192.0.2.20 22"),
2400 ("TMUX", tmux_env.as_str()),
2401 ("PATH", path.as_str()),
2402 ],
2403 )?;
2404 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
2405
2406 let text = "copy-over-ssh-tmux";
2407 h.send(keys::key::text(text))?;
2408 h.wait_for_text(text, KEY_TIMEOUT)?;
2409 let shift_left = b"\x1b[1;2D".repeat(text.chars().count());
2410 h.send(&shift_left)?;
2411 h.send(b"\x03")?; // Ctrl+C in raw mode.
2412 h.wait_for_text("Selection copied", KEY_TIMEOUT)?;
2413
2414 let buffer = Command::new("tmux")
2415 .args(["-L", server.0.as_str(), "show-buffer"])
2416 .output()?;
2417 anyhow::ensure!(buffer.status.success(), "tmux buffer could not be read");
2418 assert_eq!(buffer.stdout, text.as_bytes());
2419
2420 let _ = h.shutdown();
2421 Ok(())
2422 }
2423
2424 /// Unbracketed-paste path: terminal does NOT wrap the payload, so crossterm
2425 /// sees the bytes as ordinary keystrokes. The TUI's `paste_burst` detector is
2426 /// supposed to recognize the rapid stream and treat it as a single paste, but
2427 /// historically the trailing `\r` (Enter) of the burst leaks through and
2428 /// triggers submit while the burst flush dumps the text into the now-empty
2429 /// composer.
2430 ///
2431 /// This is the Windows / PowerShell repro from #1073.
2432 #[test]
2433 fn paste_unbracketed_with_trailing_newline_does_not_autosubmit() -> anyhow::Result<()> {
2434 let _guard = qa_pty_test_lock();
2435 let (_ws, mut h) = boot_minimal()?;
2436 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
2437 // Let the boot fully settle so input handling is wired up.
2438 h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;
2439
2440 let payload = "first line of the multi-line paste body\n\
2441 second line continuing the paragraph until the end\n\
2442 third line that finishes with a trailing newline character\n";
2443 h.paste_unbracketed(payload)?;
2444 h.wait_for_idle(Duration::from_millis(400), Duration::from_secs(3))?;
2445
2446 let f = h.frame();
2447 let dump = f.debug_dump();
2448 eprintln!("=== AFTER UNBRACKETED PASTE ===\n{dump}");
2449
2450 // The visible signal of an auto-submit: the text appears in the
2451 // transcript above the composer (sent as a user message). The composer
2452 // is also typically reset, but #1073 reports residual text in addition
2453 // to the auto-submit, so checking the transcript is more reliable.
2454 let count = dump.matches("first line").count();
2455 assert!(
2456 count <= 1,
2457 "'first line' appears {count} times — auto-submitted into transcript AND \
2458 composer:\n{dump}"
2459 );
2460 // And the pasted text should be visible somewhere.
2461 assert!(
2462 f.contains("first line"),
2463 "pasted text should be on-screen somewhere:\n{dump}"
2464 );
2465
2466 let _ = h.shutdown();
2467 Ok(())
2468 }
2469
2470 /// Markers the TUI paints once a turn has actually been dispatched. The PTY
2471 /// scenarios point at `127.0.0.1:1`, so a submitted turn fails immediately and
2472 /// leaves one of these on screen; an Enter that was swallowed by paste-burst
2473 /// suppression leaves none of them.
2474 fn frame_shows_dispatched_turn(frame: &qa_harness::Frame) -> bool {
2475 frame.contains("Turn failed") || frame.contains("Connection refused") || frame.contains("error")
2476 }
2477
2478 /// Paste-burst Enter suppression must be *bounded*. After an unbracketed
2479 /// paste flushes, the ~120ms window may absorb one Enter as the paste's
2480 /// possible trailing newline — but absorbing it must not re-arm the window.
2481 /// It used to, so every Enter bought another 120ms and a user pressing Enter
2482 /// to send just watched newlines pile up in a composer that never submitted.
2483 #[test]
2484 fn paste_unbracketed_then_repeated_enter_still_submits() -> anyhow::Result<()> {
2485 let _guard = qa_pty_test_lock();
2486 let (_ws, mut h) = boot_minimal_without_retry()?;
2487 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
2488 h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;
2489
2490 // No trailing newline: the user pastes a prompt and then presses Enter
2491 // themselves. This is the gesture the suppression window used to eat.
2492 h.paste_unbracketed("qa paste burst enter release payload")?;
2493 h.wait_for_text("qa paste burst enter release payload", KEY_TIMEOUT)?;
2494 let frame = h.frame();
2495 let dump = frame.debug_dump();
2496 assert!(
2497 !frame_shows_dispatched_turn(frame),
2498 "nothing may be dispatched before Enter is pressed:\n{dump}"
2499 );
2500
2501 // Press Enter repeatedly at a human "it didn't send?" cadence. The gaps
2502 // are shorter than the 120ms window, so the old re-arming behaviour kept
2503 // suppression alive indefinitely and none of these ever submitted.
2504 for _ in 0..6 {
2505 std::thread::sleep(Duration::from_millis(60));
2506 h.send(keys::key::enter())?;
2507 }
2508
2509 h.wait_for(frame_shows_dispatched_turn, Duration::from_secs(15))
2510 .map_err(|error| {
2511 anyhow::anyhow!("pasted prompt never submitted despite repeated Enter: {error:#}")
2512 })?;
2513
2514 let _ = h.shutdown();
2515 Ok(())
2516 }
2517
2518 /// IME Enter ambiguity: a CJK message typed one candidate commit at a time is
2519 /// ordinary typing, not a paste. Each commit used to re-arm the full ~120ms
2520 /// Enter-suppression window, so the Enter that follows a Chinese sentence was
2521 /// swallowed into a newline and the message never sent.
2522 #[test]
2523 fn ime_committed_cjk_then_enter_submits() -> anyhow::Result<()> {
2524 let _guard = qa_pty_test_lock();
2525 let (_ws, mut h) = boot_minimal_without_retry()?;
2526 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
2527 h.wait_for_idle(Duration::from_millis(300), Duration::from_secs(3))?;
2528
2529 // An IME delivers each committed character as its own key event, with
2530 // human-scale gaps between them — far slower than the 8ms burst interval.
2531 for ch in "你好世".chars() {
2532 h.send(keys::key::ch(ch))?;
2533 std::thread::sleep(Duration::from_millis(60));
2534 }
2535 // Sync on the echo so the child is provably caught up before the last
2536 // commit — the gap that follows must be measured from when the TUI
2537 // *processes* that character, not from when we wrote it.
2538 h.wait_for_text("你好世", KEY_TIMEOUT)?;
2539 let frame = h.frame();
2540 let dump = frame.debug_dump();
2541 assert!(
2542 !frame_shows_dispatched_turn(frame),
2543 "nothing may be dispatched before Enter is pressed:\n{dump}"
2544 );
2545
2546 // Final commit, then one Enter at a realistic remove from it: far beyond
2547 // the 8ms burst interval, comfortably inside the old 120ms window.
2548 h.send(keys::key::ch('界'))?;
2549 std::thread::sleep(Duration::from_millis(50));
2550 h.send(keys::key::enter())?;
2551
2552 h.wait_for(frame_shows_dispatched_turn, Duration::from_secs(15))
2553 .map_err(|error| {
2554 anyhow::anyhow!("IME-typed CJK message never submitted on Enter: {error:#}")
2555 })?;
2556
2557 let _ = h.shutdown();
2558 Ok(())
2559 }
2560
2561 /// A loopback SSE fixture that answers the first chat request with one long
2562 /// assistant message so the transcript exceeds several viewports. Later
2563 /// requests get a short stop so the server thread always drains.
2564 fn spawn_long_reply_fixture(
2565 content: String,
2566 ) -> anyhow::Result<(String, std::thread::JoinHandle<()>)> {
2567 let listener = TcpListener::bind("127.0.0.1:0")?;
2568 listener.set_nonblocking(true)?;
2569 let address = listener.local_addr()?;
2570 let handle = std::thread::spawn(move || {
2571 let deadline = Instant::now() + Duration::from_secs(20);
2572 let mut served = 0usize;
2573 while served < 4 && Instant::now() < deadline {
2574 let Ok((mut stream, _)) = listener.accept() else {
2575 std::thread::sleep(Duration::from_millis(10));
2576 continue;
2577 };
2578 let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
2579 let mut request = [0u8; 64 * 1024];
2580 let _ = stream.read(&mut request);
2581 let reply = if served == 0 {
2582 content.as_str()
2583 } else {
2584 "SCROLLPROBE-EXTRA"
2585 };
2586 let body = [
2587 format!(
2588 "data: {}\n\n",
2589 serde_json::json!({
2590 "id":"chatcmpl-scroll",
2591 "object":"chat.completion.chunk",
2592 "model":"deepseek-v4-flash",
2593 "choices":[{"index":0,"delta":{"content":reply},"finish_reason":null}]
2594 })
2595 ),
2596 format!(
2597 "data: {}\n\n",
2598 serde_json::json!({
2599 "id":"chatcmpl-scroll",
2600 "object":"chat.completion.chunk",
2601 "model":"deepseek-v4-flash",
2602 "choices":[{"index":0,"delta":{},"finish_reason":"stop"}],
2603 "usage":{"prompt_tokens":10,"completion_tokens":4,"total_tokens":14}
2604 })
2605 ),
2606 "data: [DONE]\n\n".to_string(),
2607 ]
2608 .join("");
2609 let response = format!(
2610 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
2611 body.len(),
2612 body
2613 );
2614 let _ = stream.write_all(response.as_bytes());
2615 let _ = stream.flush();
2616 served += 1;
2617 }
2618 });
2619 Ok((format!("http://{address}"), handle))
2620 }
2621
2622 fn read_http_request(stream: &mut std::net::TcpStream) -> anyhow::Result<String> {
2623 // Reqwest may establish the next loopback connection before the real PTY
2624 // test releases a blocked tool. Keep that idle socket bounded, but long
2625 // enough for the deliberately observed reasoning/read phases to finish.
2626 // Darwin can propagate O_NONBLOCK from the listener to accepted sockets;
2627 // restore blocking reads before applying the timeout or an early accept
2628 // races the first request byte and reports EAGAIN as a network failure.
2629 stream.set_nonblocking(false)?;
2630 stream.set_read_timeout(Some(Duration::from_secs(60)))?;
2631 let mut request = Vec::new();
2632 let mut chunk = [0_u8; 16 * 1024];
2633 loop {
2634 let count = stream.read(&mut chunk)?;
2635 if count == 0 {
2636 break;
2637 }
2638 request.extend_from_slice(&chunk[..count]);
2639
2640 let Some(header_end) = request
2641 .windows(4)
2642 .position(|window| window == b"\r\n\r\n")
2643 .map(|index| index + 4)
2644 else {
2645 continue;
2646 };
2647 let headers = String::from_utf8_lossy(&request[..header_end]);
2648 let content_length = headers.lines().find_map(|line| {
2649 let (name, value) = line.split_once(':')?;
2650 name.eq_ignore_ascii_case("content-length")
2651 .then(|| value.trim().parse::<usize>().ok())
2652 .flatten()
2653 });
2654 if content_length.is_none_or(|length| request.len() >= header_end + length) {
2655 break;
2656 }
2657 }
2658 Ok(String::from_utf8_lossy(&request).into_owned())
2659 }
2660
2661 fn pty_tool_call_sse(id: &str, name: &str, arguments: serde_json::Value) -> String {
2662 [
2663 format!(
2664 "data: {}\n\n",
2665 serde_json::json!({
2666 "id": format!("chatcmpl-{id}"),
2667 "object": "chat.completion.chunk",
2668 "model": "deepseek-v4-pro",
2669 "choices": [{
2670 "index": 0,
2671 "delta": {"tool_calls": [{
2672 "index": 0,
2673 "id": id,
2674 "type": "function",
2675 "function": {
2676 "name": name,
2677 "arguments": serde_json::to_string(&arguments)
2678 .expect("tool arguments JSON")
2679 }
2680 }]},
2681 "finish_reason": null
2682 }]
2683 })
2684 ),
2685 format!(
2686 "data: {}\n\n",
2687 serde_json::json!({
2688 "id": format!("chatcmpl-{id}"),
2689 "object": "chat.completion.chunk",
2690 "model": "deepseek-v4-pro",
2691 "choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}],
2692 "usage": {"prompt_tokens": 12, "completion_tokens": 4, "total_tokens": 16}
2693 })
2694 ),
2695 "data: [DONE]\n\n".to_string(),
2696 ]
2697 .join("")
2698 }
2699
2700 fn pty_text_sse(content: &str) -> String {
2701 [
2702 format!(
2703 "data: {}\n\n",
2704 serde_json::json!({
2705 "id": "chatcmpl-pty-lifecycle-final",
2706 "object": "chat.completion.chunk",
2707 "model": "deepseek-v4-pro",
2708 "choices": [{
2709 "index": 0,
2710 "delta": {"content": content},
2711 "finish_reason": null
2712 }]
2713 })
2714 ),
2715 format!(
2716 "data: {}\n\n",
2717 serde_json::json!({
2718 "id": "chatcmpl-pty-lifecycle-final",
2719 "object": "chat.completion.chunk",
2720 "model": "deepseek-v4-pro",
2721 "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
2722 "usage": {"prompt_tokens": 24, "completion_tokens": 80, "total_tokens": 104}
2723 })
2724 ),
2725 "data: [DONE]\n\n".to_string(),
2726 ]
2727 .join("")
2728 }
2729
2730 /// Sealed loopback fixture for the #4636 File-mutation receipt. One canonical
2731 /// `File.patch` call performs an update, create, delete, and byte-identical
2732 /// delete/create rename in a single transaction; the second response settles
2733 /// the turn. No provider or external network is involved.
2734 fn spawn_file_mutation_screen_fixture(
2735 tool_allowed: bool,
2736 ) -> anyhow::Result<(String, std::thread::JoinHandle<anyhow::Result<()>>)> {
2737 let listener = TcpListener::bind("127.0.0.1:0")?;
2738 listener.set_nonblocking(true)?;
2739 let address = listener.local_addr()?;
2740 let patch = r"diff --git a/old-name.txt b/old-name.txt
2741 --- a/old-name.txt
2742 +++ /dev/null
2743 @@ -1 +0,0 @@
2744 -RENAME-SENTINEL
2745 diff --git a/new-name.txt b/new-name.txt
2746 --- /dev/null
2747 +++ b/new-name.txt
2748 @@ -0,0 +1 @@
2749 +RENAME-SENTINEL
2750 diff --git a/update.txt b/update.txt
2751 --- a/update.txt
2752 +++ b/update.txt
2753 @@ -1 +1 @@
2754 -DIFF-OLD-SENTINEL
2755 +DIFF-NEW-SENTINEL
2756 diff --git a/create.txt b/create.txt
2757 --- /dev/null
2758 +++ b/create.txt
2759 @@ -0,0 +1 @@
2760 +CREATE-SENTINEL
2761 diff --git a/delete.txt b/delete.txt
2762 --- a/delete.txt
2763 +++ /dev/null
2764 @@ -1 +0,0 @@
2765 -DELETE-SENTINEL
2766 ";
2767 let replies = [
2768 pty_tool_call_sse(
2769 "call_file_mutation_pty",
2770 "File",
2771 serde_json::json!({"action": "patch", "patch": patch}),
2772 ),
2773 pty_text_sse("FILE-MUTATION-FIXTURE-DONE"),
2774 ];
2775 let expected_result_marker = if tool_allowed {
2776 "files_applied"
2777 } else {
2778 "destructive action requires explicit review"
2779 };
2780
2781 let handle = std::thread::spawn(move || -> anyhow::Result<()> {
2782 let deadline = Instant::now() + Duration::from_secs(45);
2783 let mut chat_index = 0_usize;
2784 let mut contract_errors = Vec::new();
2785 while chat_index < replies.len() && Instant::now() < deadline {
2786 let Ok((mut stream, _)) = listener.accept() else {
2787 std::thread::sleep(Duration::from_millis(10));
2788 continue;
2789 };
2790 let request = read_http_request(&mut stream)?;
2791 let request_line = request.lines().next().unwrap_or_default();
2792 let (content_type, body) = if request_line.starts_with("GET ")
2793 && request_line.contains("/models")
2794 {
2795 (
2796 "application/json",
2797 serde_json::json!({
2798 "object": "list",
2799 "data": [{"id": "deepseek-v4-pro", "object": "model"}]
2800 })
2801 .to_string(),
2802 )
2803 } else if request_line.starts_with("POST ")
2804 && request_line.contains("/chat/completions")
2805 {
2806 let request_body = request
2807 .split_once("\r\n\r\n")
2808 .map(|(_, body)| body)
2809 .unwrap_or_default();
2810 let request_json: serde_json::Value = serde_json::from_str(request_body)?;
2811 let request_contract = request_json.to_string();
2812 match chat_index {
2813 0 if !request_contract
2814 .contains("exercise the canonical File mutation receipt") =>
2815 {
2816 contract_errors.push("initial request omitted the fixture prompt".into());
2817 }
2818 1 if !(request_contract.contains("call_file_mutation_pty")
2819 && request_contract.contains(expected_result_marker)
2820 && request_contract.contains("\"role\":\"tool\"")) =>
2821 {
2822 let sample = request_contract.chars().take(1_200).collect::<String>();
2823 contract_errors.push(format!(
2824 "settling request omitted the expected File result: {sample}"
2825 ));
2826 }
2827 0 | 1 => {}
2828 _ => unreachable!("bounded File fixture"),
2829 }
2830 let body = replies[chat_index].clone();
2831 chat_index += 1;
2832 ("text/event-stream", body)
2833 } else {
2834 ("text/plain", "not found".to_string())
2835 };
2836 let response = format!(
2837 "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
2838 body.len()
2839 );
2840 stream.write_all(response.as_bytes())?;
2841 stream.flush()?;
2842 }
2843 anyhow::ensure!(
2844 chat_index == replies.len(),
2845 "File fixture served {chat_index}/{} chat requests",
2846 replies.len()
2847 );
2848 if !contract_errors.is_empty() {
2849 anyhow::bail!(
2850 "File fixture contract errors:\n{}",
2851 contract_errors.join("\n")
2852 );
2853 }
2854 Ok(())
2855 });
2856 Ok((format!("http://{address}"), handle))
2857 }
2858
2859 fn spawn_file_mutation_harness(
2860 ws: &qa_harness::harness::SealedWorkspace,
2861 base_url: &str,
2862 rows: u16,
2863 cols: u16,
2864 ascii_safe: bool,
2865 ) -> anyhow::Result<Harness> {
2866 let codewhale_home = ws.home().join(".codewhale");
2867 let codex_home = ws.home().join(".codex");
2868 let mut builder = Harness::builder(Harness::cargo_bin("codewhale-tui"))
2869 .cwd(ws.workspace())
2870 .clear_env()
2871 .seal_home(ws.home())
2872 .env("CODEWHALE_HOME", codewhale_home.to_string_lossy())
2873 .env(
2874 "DEEPSEEK_CONFIG_PATH",
2875 codewhale_home.join("config.toml").to_string_lossy(),
2876 )
2877 .env("CODEX_HOME", codex_home.to_string_lossy())
2878 .env("CODEWHALE_PROVIDER", "deepseek")
2879 .env("DEEPSEEK_API_KEY", "deepseek-local-test-key")
2880 .env("DEEPSEEK_BASE_URL", base_url)
2881 .env("CODEWHALE_BASE_URL", base_url)
2882 .env("DEEPSEEK_MODEL", "deepseek-v4-pro")
2883 .env("CODEWHALE_MODEL", "deepseek-v4-pro")
2884 .env("NO_ANIMATIONS", "1")
2885 .env("RUST_LOG", "warn")
2886 .args([
2887 "--workspace",
2888 ws.workspace().to_str().expect("utf-8 workspace path"),
2889 "--no-project-config",
2890 "--skip-onboarding",
2891 "--mouse-capture",
2892 ])
2893 .size(rows, cols);
2894 if ascii_safe {
2895 builder = builder.env("CODEWHALE_ASCII_SAFE", "1");
2896 }
2897 builder.spawn()
2898 }
2899
2900 /// #4636: real terminal frames for the persisted full/summary/off contract.
2901 /// The three cases jointly cover Ask/Auto/Full Access, narrow/wide, dark/light,
2902 /// reduced-motion, and ASCII-safe operation. The off case is changed through
2903 /// `/config --save` and then rebooted before execution, proving the setting
2904 /// survives restart.
2905 #[test]
2906 fn work_surface_file_mutation_modes_are_truthful_in_real_pty_frames() -> anyhow::Result<()> {
2907 let _guard = qa_pty_test_lock();
2908 let cases = [
2909 (
2910 "full", 140_u16, 40_u16, "dark", false, false, "ask", "ask", true,
2911 ),
2912 (
2913 "summary", 100, 32, "light", false, false, "auto", "auto", false,
2914 ),
2915 (
2916 "off",
2917 80,
2918 24,
2919 "dark",
2920 true,
2921 true,
2922 "full-access",
2923 "Full Access",
2924 false,
2925 ),
2926 ];
2927
2928 for (
2929 mode,
2930 cols,
2931 rows,
2932 theme,
2933 ascii_safe,
2934 persist_through_restart,
2935 permission_posture,
2936 permission_label,
2937 approve_once,
2938 ) in cases
2939 {
2940 let ws = make_sealed_workspace()?;
2941 let codewhale_home = ws.home().join(".codewhale");
2942 let codex_home = ws.home().join(".codex");
2943 std::fs::create_dir_all(&codex_home)?;
2944 std::fs::write(
2945 codewhale_home.join("config.toml"),
2946 "reasoning_effort = \"low\"\n\n[retry]\nenabled = false\n\n[update]\ncheck_for_updates = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
2947 )?;
2948 let initial_mode = if persist_through_restart {
2949 "full"
2950 } else {
2951 mode
2952 };
2953 std::fs::write(
2954 codewhale_home.join("settings.toml"),
2955 format!(
2956 "theme = \"{theme}\"\nlocale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"{permission_posture}\"\ninline_diffs = \"{initial_mode}\"\nlow_motion = true\nfancy_animations = false\ncomposer_border = true\n"
2957 ),
2958 )?;
2959 std::fs::write(
2960 codex_home.join("models_cache.json"),
2961 serde_json::to_vec_pretty(&serde_json::json!({
2962 "fetched_at": chrono::Utc::now(),
2963 "models": [{"slug": "deepseek-v4-pro", "priority": 1}]
2964 }))?,
2965 )?;
2966 std::fs::write(ws.workspace().join("old-name.txt"), "RENAME-SENTINEL\n")?;
2967 std::fs::write(ws.workspace().join("update.txt"), "DIFF-OLD-SENTINEL\n")?;
2968 std::fs::write(ws.workspace().join("delete.txt"), "DELETE-SENTINEL\n")?;
2969
2970 if persist_through_restart {
2971 let mut setup =
2972 spawn_file_mutation_harness(&ws, "http://127.0.0.1:1", rows, cols, ascii_safe)?;
2973 enter_launch_session(&mut setup)?;
2974 setup.paste("/config inline_diffs off --save")?;
2975 setup.wait_for_text("/config inline_diffs off --save", KEY_TIMEOUT)?;
2976 setup.send(keys::key::enter())?;
2977 setup.wait_for_text("inline_diffs = off (saved)", KEY_TIMEOUT)?;
2978 let _ = setup.shutdown();
2979 let persisted = std::fs::read_to_string(codewhale_home.join("settings.toml"))?;
2980 anyhow::ensure!(
2981 persisted.contains("inline_diffs = \"off\""),
2982 "off mode did not persist before restart: {persisted}"
2983 );
2984 }
2985
2986 // Auto-Review deliberately has no approval escape hatch for destructive
2987 // create/delete work; Ask and Full Access can complete the transaction.
2988 let tool_allowed = permission_posture != "auto";
2989 let (base_url, server) = spawn_file_mutation_screen_fixture(tool_allowed)?;
2990 let mut h = spawn_file_mutation_harness(&ws, &base_url, rows, cols, ascii_safe)?;
2991 enter_launch_session(&mut h)?;
2992 assert_real_pty_frame_geometry(h.frame(), cols, rows);
2993 assert_control_grammar(h.frame(), "act", permission_label, COMPOSER_READY_TEXT);
2994
2995 let prompt = "exercise the canonical File mutation receipt";
2996 h.paste(prompt)?;
2997 h.wait_for_text(prompt, KEY_TIMEOUT)?;
2998 h.send(keys::key::enter())?;
2999 if approve_once {
3000 h.wait_for_text("Allow once", Duration::from_secs(10))?;
3001 h.send(b"y")?;
3002 }
3003 h.wait_for_text("FILE-MUTATION-FIXTURE-DONE", Duration::from_secs(20))?;
3004 if tool_allowed {
3005 h.wait_for(
3006 |frame| frame.contains("4 files") && frame.contains("done"),
3007 Duration::from_secs(10),
3008 )?;
3009 } else {
3010 h.wait_for(
3011 |frame| {
3012 frame.contains("tool issue")
3013 && frame.contains("destructive action")
3014 && frame.contains("done")
3015 },
3016 Duration::from_secs(10),
3017 )?;
3018 }
3019 h.wait_for_idle(Duration::from_millis(250), Duration::from_secs(3))?;
3020
3021 if tool_allowed {
3022 assert!(
3023 !h.frame().contains("Wrote 4 files"),
3024 "completed file-operation summary leaked into ambient chrome:\n{}",
3025 h.frame().debug_dump()
3026 );
3027 assert_eq!(
3028 std::fs::read_to_string(ws.workspace().join("new-name.txt"))?,
3029 "RENAME-SENTINEL\n"
3030 );
3031 assert!(!ws.workspace().join("old-name.txt").exists());
3032 assert_eq!(
3033 std::fs::read_to_string(ws.workspace().join("update.txt"))?,
3034 "DIFF-NEW-SENTINEL\n"
3035 );
3036 assert_eq!(
3037 std::fs::read_to_string(ws.workspace().join("create.txt"))?,
3038 "CREATE-SENTINEL\n"
3039 );
3040 assert!(!ws.workspace().join("delete.txt").exists());
3041 } else {
3042 assert_eq!(
3043 std::fs::read_to_string(ws.workspace().join("old-name.txt"))?,
3044 "RENAME-SENTINEL\n"
3045 );
3046 assert_eq!(
3047 std::fs::read_to_string(ws.workspace().join("update.txt"))?,
3048 "DIFF-OLD-SENTINEL\n"
3049 );
3050 assert!(!ws.workspace().join("new-name.txt").exists());
3051 assert!(!ws.workspace().join("create.txt").exists());
3052 assert_eq!(
3053 std::fs::read_to_string(ws.workspace().join("delete.txt"))?,
3054 "DELETE-SENTINEL\n"
3055 );
3056 }
3057
3058 let settled_frame = h.frame().text();
3059 std::thread::sleep(Duration::from_millis(300));
3060 h.pump();
3061 assert_eq!(
3062 settled_frame,
3063 h.frame().text(),
3064 "reduced-motion settled frame moved in {mode} mode"
3065 );
3066 assert_real_pty_frame_geometry(h.frame(), cols, rows);
3067 if ascii_safe {
3068 assert!(
3069 h.frame().text().is_ascii(),
3070 "ASCII-safe mutation frame emitted non-ASCII cells:\n{}",
3071 h.frame().debug_dump()
3072 );
3073 }
3074
3075 match mode {
3076 "full" => {
3077 assert!(
3078 scroll_until(&mut h, ScrollDir::Up, "DIFF-NEW-SENTINEL"),
3079 "full mode omitted the added line:\n{}",
3080 h.frame().debug_dump()
3081 );
3082 assert!(h.frame().contains("DIFF-OLD-SENTINEL"));
3083 let old_row = visible_row_with_text(h.frame(), "DIFF-OLD-SENTINEL")
3084 .expect("deleted line row");
3085 let new_row =
3086 visible_row_with_text(h.frame(), "DIFF-NEW-SENTINEL").expect("added line row");
3087 let old_color = foreground_at_text(h.frame(), old_row, "DIFF-OLD-SENTINEL");
3088 let new_color = foreground_at_text(h.frame(), new_row, "DIFF-NEW-SENTINEL");
3089 assert_ne!(old_color, qa_harness::Color::Default);
3090 assert_ne!(new_color, qa_harness::Color::Default);
3091 assert_ne!(old_color, new_color, "added/deleted ANSI roles collapsed");
3092 }
3093 "summary" => {
3094 assert!(
3095 scroll_until(&mut h, ScrollDir::Up, "+3 / -3"),
3096 "held Auto-Review mutation omitted semantic stats:\n{}",
3097 h.frame().debug_dump()
3098 );
3099 assert!(h.frame().contains("explicit review"));
3100 assert!(!scroll_until(&mut h, ScrollDir::Up, "DIFF-NEW-SENTINEL"));
3101 assert!(!scroll_until(&mut h, ScrollDir::Down, "DIFF-NEW-SENTINEL"));
3102 }
3103 "off" => {
3104 assert!(
3105 scroll_until(&mut h, ScrollDir::Up, "4 files"),
3106 "off mode lost the concise File outcome:\n{}",
3107 h.frame().debug_dump()
3108 );
3109 assert!(!scroll_until(&mut h, ScrollDir::Up, "+2 -2"));
3110 assert!(!scroll_until(&mut h, ScrollDir::Down, "+2 -2"));
3111 assert!(!scroll_until(&mut h, ScrollDir::Up, "DIFF-NEW-SENTINEL"));
3112 assert!(!scroll_until(&mut h, ScrollDir::Down, "DIFF-NEW-SENTINEL"));
3113
3114 h.send(keys::key::alt('v'))?;
3115 h.wait_for_text("Raw detail", KEY_TIMEOUT)?;
3116 assert!(
3117 scroll_until(&mut h, ScrollDir::Down, "Exact File change"),
3118 "off mode lost the exact-evidence section:\n{}",
3119 h.frame().debug_dump()
3120 );
3121 assert!(
3122 scroll_until(&mut h, ScrollDir::Down, "DIFF-NEW-SENTINEL"),
3123 "off mode exact evidence omitted the applied diff:\n{}",
3124 h.frame().debug_dump()
3125 );
3126 }
3127 _ => unreachable!("bounded diff mode matrix"),
3128 }
3129
3130 write_real_pty_evidence(
3131 &format!("file-mutation-{mode}-{cols}x{rows}"),
3132 &format!(
3133 "size={cols}x{rows}\ntheme={theme}\ninline_diffs={mode}\npermission={permission_posture}\nreduced_motion=true\nascii_safe={ascii_safe}\nprovider=sealed-loopback"
3134 ),
3135 h.frame(),
3136 )?;
3137 let _ = h.shutdown();
3138 server.join().expect("File fixture server thread")?;
3139 }
3140 Ok(())
3141 }
3142
3143 /// Three-turn loopback fixture for the real screen acceptance path:
3144 /// `work_update` establishes canonical To-do/Work state, then an actual Bash
3145 /// call waits on a test-owned workspace sentinel before emitting enough exact
3146 /// output to exercise the bounded plain preview, then a long final answer makes
3147 /// transcript retention and scrolling observable.
3148 fn spawn_tool_lifecycle_screen_fixture(
3149 release_signal: &str,
3150 final_answer: String,
3151 ) -> anyhow::Result<(String, std::thread::JoinHandle<anyhow::Result<()>>)> {
3152 let listener = TcpListener::bind("127.0.0.1:0")?;
3153 listener.set_nonblocking(true)?;
3154 let address = listener.local_addr()?;
3155 // The Bash adapter self-bounds each stream to ~30 KB, so a single stream
3156 // would fit inside the hybrid 32 KiB + 8 KiB preview budget under the
3157 // 32_768-token evidence threshold. Fill stdout AND stderr (~60 KB
3158 // combined) so the envelope still omits a middle range; the sentinel
3159 // rides stderr at filler line 50 — inside the shell tool's own head bound
3160 // (so the artifact retains it) but beyond the preview's 32 KiB head (so
3161 // the model receipt omits it).
3162 //
3163 // The window is narrow and #5212 put the sentinel outside it: it wrote
3164 // line 100 against a "22 KB head bound" that does not exist.
3165 // `shell_output` keeps `TRUNCATED_HEAD_BYTES` = 30_000/5 = 6_000 bytes of
3166 // head plus 24_000 of tail, so line 100 (~8.7 KB into stderr) fell in the
3167 // stream's own omitted middle and the artifact never carried the
3168 // sentinel. Above the head bound sits ~line 68; below it, the bounded
3169 // stdout section (~30.1 KB) plus the `STDERR:` separator puts stderr line
3170 // *n* at roughly 30_115 + 87n bytes, so anything before ~line 31 is still
3171 // inside the preview's 32 KiB head. Line 50 is the middle of [31, 68].
3172 // `tests/adaptive_evidence_acceptance.rs` carries the same constant for
3173 // the same reason.
3174 let shell_command = format!(
3175 "printf 'PTY-TOOL-START\\n'; while [ ! -f {release_signal} ]; do sleep 0.05; done; i=0; while [ \"$i\" -lt 2800 ]; do printf 'PTY-EVIDENCE-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$i\"; i=$((i + 1)); done; {{ j=0; while [ \"$j\" -lt 2800 ]; do if [ \"$j\" -eq 50 ]; then printf 'PTY-EVIDENCE-DEEP-SENTINEL\\n'; fi; printf 'PTY-EVIDENCE-ERR-%04d-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\\n' \"$j\"; j=$((j + 1)); done; }} >&2; printf 'PTY-TOOL-END\\n'"
3176 );
3177 let replies = [
3178 pty_tool_call_sse(
3179 "call_work_pty",
3180 "work_update",
3181 serde_json::json!({
3182 "todos": [{
3183 "content": "PTY lifecycle acceptance",
3184 "status": "in_progress"
3185 }]
3186 }),
3187 ),
3188 pty_tool_call_sse(
3189 "call_bash_pty",
3190 "Bash",
3191 serde_json::json!({
3192 "action": "run",
3193 "command": shell_command,
3194 "timeout_ms": 60_000
3195 }),
3196 ),
3197 pty_text_sse(&final_answer),
3198 ];
3199
3200 let handle = std::thread::spawn(move || -> anyhow::Result<()> {
3201 let deadline = Instant::now() + Duration::from_secs(75);
3202 let mut chat_index = 0_usize;
3203 let mut contract_errors = Vec::new();
3204 let mut connection_errors = Vec::new();
3205 while chat_index < replies.len() && Instant::now() < deadline {
3206 let Ok((mut stream, _)) = listener.accept() else {
3207 std::thread::sleep(Duration::from_millis(10));
3208 continue;
3209 };
3210 let request = match read_http_request(&mut stream) {
3211 Ok(request) if !request.trim().is_empty() => request,
3212 Ok(_) => continue,
3213 Err(error) => {
3214 connection_errors.push(format!("request read failed: {error:#}"));
3215 continue;
3216 }
3217 };
3218 let request_line = request.lines().next().unwrap_or_default();
3219 let mut is_chat_response = false;
3220 let (content_type, body) = if request_line.starts_with("GET ")
3221 && request_line.contains("/models")
3222 {
3223 (
3224 "application/json",
3225 serde_json::json!({
3226 "object": "list",
3227 "data": [{"id": "deepseek-v4-pro", "object": "model"}]
3228 })
3229 .to_string(),
3230 )
3231 } else if request_line.starts_with("POST ")
3232 && request_line.contains("/chat/completions")
3233 {
3234 let request_body = request
3235 .split_once("\r\n\r\n")
3236 .map(|(_, body)| body)
3237 .unwrap_or_default();
3238 let request_json: serde_json::Value = match serde_json::from_str(request_body) {
3239 Ok(request_json) => request_json,
3240 Err(error) => {
3241 contract_errors.push(format!(
3242 "chat request JSON parse failed for {request_line}: {error}"
3243 ));
3244 let body = "invalid JSON".to_string();
3245 let response = format!(
3246 "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3247 body.len()
3248 );
3249 let _ = stream.write_all(response.as_bytes());
3250 let _ = stream.flush();
3251 continue;
3252 }
3253 };
3254 let request_contract = request_json.to_string();
3255 match chat_index {
3256 0 if !request_contract.contains("exercise the real PTY tool lifecycle") => {
3257 contract_errors.push("initial request omitted the user prompt".into());
3258 }
3259 1 if !(request_contract.contains("call_work_pty")
3260 && request_contract.contains("\"role\":\"tool\"")) =>
3261 {
3262 contract_errors
3263 .push("second request omitted the work_update result".into());
3264 }
3265 2 => {
3266 let bash_result = request_json
3267 .get("messages")
3268 .and_then(serde_json::Value::as_array)
3269 .and_then(|messages| {
3270 messages.iter().find(|message| {
3271 message.get("role").and_then(serde_json::Value::as_str)
3272 == Some("tool")
3273 && message
3274 .get("tool_call_id")
3275 .and_then(serde_json::Value::as_str)
3276 == Some("call_bash_pty")
3277 })
3278 })
3279 .and_then(|message| message.get("content"))
3280 .and_then(serde_json::Value::as_str)
3281 .unwrap_or_default();
3282 // Honest bounded-preview contract: the footer states
3283 // the omission, names the on-disk artifact, and names
3284 // `retrieve_tool_result` — the route the model can
3285 // actually take from this receipt to read the omitted
3286 // range back. The deep sentinel stays out of the
3287 // inline receipt. This used to assert the *absence*
3288 // of `retrieve_tool_result`, a leftover from #5018's
3289 // "no storage language" pass;
3290 // `tests/adaptive_evidence_acceptance.rs` proves end
3291 // to end that the named ref returns the omitted bytes.
3292 if !bash_result.contains("of output omitted")
3293 || !bash_result.contains("full output at")
3294 || !bash_result.contains("art_call_bash_pty.txt")
3295 || bash_result.contains("Exact evidence retained")
3296 || !bash_result.contains("retrieve_tool_result")
3297 || bash_result.contains("PTY-EVIDENCE-DEEP-SENTINEL")
3298 {
3299 contract_errors.push(format!(
3300 "final request violated the honest bounded Bash preview contract (omission={}, path_footer={}, artifact_path={}, legacy_receipt={}, retrieval_tool={}, deep_sentinel={})",
3301 bash_result.contains("of output omitted"),
3302 bash_result.contains("full output at"),
3303 bash_result.contains("art_call_bash_pty.txt"),
3304 bash_result.contains("Exact evidence retained"),
3305 bash_result.contains("retrieve_tool_result"),
3306 bash_result.contains("PTY-EVIDENCE-DEEP-SENTINEL"),
3307 ));
3308 }
3309 }
3310 0 | 1 => {}
3311 _ => unreachable!("bounded lifecycle fixture"),
3312 }
3313 let body = replies[chat_index].clone();
3314 is_chat_response = true;
3315 ("text/event-stream", body)
3316 } else {
3317 ("text/plain", "not found".to_string())
3318 };
3319 let response = format!(
3320 "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3321 body.len()
3322 );
3323 if let Err(error) = stream
3324 .write_all(response.as_bytes())
3325 .and_then(|()| stream.flush())
3326 {
3327 connection_errors
3328 .push(format!("response write failed for {request_line}: {error}"));
3329 continue;
3330 }
3331 if is_chat_response {
3332 chat_index += 1;
3333 }
3334 }
3335 anyhow::ensure!(
3336 chat_index == replies.len(),
3337 "fixture served {chat_index}/{} chat requests; ignored connection errors: {}",
3338 replies.len(),
3339 connection_errors.join(" | ")
3340 );
3341 if !contract_errors.is_empty() {
3342 anyhow::bail!(
3343 "tool lifecycle fixture contract errors:\n{}",
3344 contract_errors.join("\n")
3345 );
3346 }
3347 Ok(())
3348 });
3349 Ok((format!("http://{address}"), handle))
3350 }
3351
3352 /// Stream an explicit private reasoning delta, hold it until the PTY has
3353 /// captured the semantic `reasoning` phase, then issue a canonical File.read.
3354 /// Follow-up turns issue a real Bash wait and a final receipt. Every pause is
3355 /// controlled by a test-owned workspace primitive; no product frame is
3356 /// generated or reconstructed outside the real terminal parser.
3357 fn spawn_semantic_activity_motion_fixture(
3358 reasoning_release: std::path::PathBuf,
3359 fifo_name: &str,
3360 bash_release_name: &str,
3361 ) -> anyhow::Result<(String, std::thread::JoinHandle<anyhow::Result<()>>)> {
3362 let listener = TcpListener::bind("127.0.0.1:0")?;
3363 listener.set_nonblocking(true)?;
3364 let address = listener.local_addr()?;
3365 let reasoning_prefix = format!(
3366 "data: {}\n\n",
3367 serde_json::json!({
3368 "id": "chatcmpl-semantic-reasoning",
3369 "object": "chat.completion.chunk",
3370 "model": "deepseek-v4-pro",
3371 "choices": [{
3372 "index": 0,
3373 "delta": {
3374 "reasoning_content": "PRIVATE-MOTION-TRACE-MUST-STAY-HIDDEN"
3375 },
3376 "finish_reason": null
3377 }]
3378 })
3379 );
3380 let read_tail = pty_tool_call_sse(
3381 "call_read_motion",
3382 "File",
3383 // Pin the read to the streaming path. The small-file fast path opens
3384 // a FIFO once for metadata and then opens it again for content, which
3385 // makes a real PTY fixture depend on a race-prone third reader. An
3386 // explicit range keeps the content read on the already-open file.
3387 serde_json::json!({"action": "read", "path": fifo_name, "start_line": 1}),
3388 );
3389 let shell_command = format!(
3390 "printf 'MOTION-BASH-START\\n'; while [ ! -f {bash_release_name} ]; do sleep 0.05; done; printf 'MOTION-BASH-END\\n'"
3391 );
3392 let bash_reply = pty_tool_call_sse(
3393 "call_bash_motion",
3394 "Bash",
3395 serde_json::json!({
3396 "action": "run",
3397 "command": shell_command,
3398 "timeout_ms": 60_000
3399 }),
3400 );
3401 let final_reply = pty_text_sse("SEMANTIC-MOTION-DONE");
3402
3403 let handle = std::thread::spawn(move || -> anyhow::Result<()> {
3404 let deadline = Instant::now() + Duration::from_secs(90);
3405 let mut chat_index = 0_usize;
3406 let mut contract_errors = Vec::new();
3407 let mut connection_errors = Vec::new();
3408 while chat_index < 3 && Instant::now() < deadline {
3409 let Ok((mut stream, _)) = listener.accept() else {
3410 std::thread::sleep(Duration::from_millis(10));
3411 continue;
3412 };
3413 let request = match read_http_request(&mut stream) {
3414 Ok(request) if !request.trim().is_empty() => request,
3415 Ok(_) => continue,
3416 Err(error) => {
3417 connection_errors.push(format!("request read failed: {error:#}"));
3418 continue;
3419 }
3420 };
3421 let request_line = request.lines().next().unwrap_or_default();
3422 if request_line.starts_with("GET ") && request_line.contains("/models") {
3423 let body = serde_json::json!({
3424 "object": "list",
3425 "data": [{"id": "deepseek-v4-pro", "object": "model"}]
3426 })
3427 .to_string();
3428 let response = format!(
3429 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3430 body.len()
3431 );
3432 if let Err(error) = stream
3433 .write_all(response.as_bytes())
3434 .and_then(|()| stream.flush())
3435 {
3436 connection_errors.push(format!(
3437 "model response write failed for {request_line}: {error}"
3438 ));
3439 }
3440 continue;
3441 }
3442 if !(request_line.starts_with("POST ") && request_line.contains("/chat/completions")) {
3443 contract_errors.push(format!(
3444 "unexpected semantic activity fixture request: {request_line}"
3445 ));
3446 let body = "not found";
3447 let response = format!(
3448 "HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3449 body.len()
3450 );
3451 let _ = stream.write_all(response.as_bytes());
3452 let _ = stream.flush();
3453 continue;
3454 }
3455
3456 let request_body = request
3457 .split_once("\r\n\r\n")
3458 .map(|(_, body)| body)
3459 .unwrap_or_default();
3460 let request_json: serde_json::Value = match serde_json::from_str(request_body) {
3461 Ok(request_json) => request_json,
3462 Err(error) => {
3463 contract_errors.push(format!(
3464 "chat request JSON parse failed for {request_line}: {error}"
3465 ));
3466 let body = "invalid JSON";
3467 let response = format!(
3468 "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3469 body.len()
3470 );
3471 let _ = stream.write_all(response.as_bytes());
3472 let _ = stream.flush();
3473 continue;
3474 }
3475 };
3476 let request_contract = request_json.to_string();
3477 match chat_index {
3478 0 if !request_contract.contains("show semantic activity with less chrome") => {
3479 contract_errors.push("initial request omitted semantic activity prompt".into());
3480 }
3481 1 if !(request_contract.contains("call_read_motion")
3482 && request_contract.contains("MOTION-FIFO-CONTENT")
3483 && request_contract.contains("\"role\":\"tool\"")) =>
3484 {
3485 contract_errors.push(format!(
3486 "Bash request omitted completed File.read evidence (call={}, content={}, tool_role={})",
3487 request_contract.contains("call_read_motion"),
3488 request_contract.contains("MOTION-FIFO-CONTENT"),
3489 request_contract.contains("\"role\":\"tool\""),
3490 ));
3491 }
3492 2 if !(request_contract.contains("call_bash_motion")
3493 && request_contract.contains("MOTION-BASH-END")) =>
3494 {
3495 contract_errors.push(format!(
3496 "final request omitted completed Bash evidence (call={}, completion={})",
3497 request_contract.contains("call_bash_motion"),
3498 request_contract.contains("MOTION-BASH-END"),
3499 ));
3500 }
3501 0..=2 => {}
3502 _ => unreachable!("bounded semantic activity fixture"),
3503 }
3504
3505 if chat_index == 0 {
3506 let body_len = reasoning_prefix.len() + read_tail.len();
3507 let headers = format!(
3508 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n"
3509 );
3510 if let Err(error) = stream
3511 .write_all(headers.as_bytes())
3512 .and_then(|()| stream.write_all(reasoning_prefix.as_bytes()))
3513 .and_then(|()| stream.flush())
3514 {
3515 connection_errors.push(format!(
3516 "reasoning prefix write failed for {request_line}: {error}"
3517 ));
3518 continue;
3519 }
3520
3521 let release_deadline = Instant::now() + Duration::from_secs(30);
3522 while !reasoning_release.exists() && Instant::now() < release_deadline {
3523 std::thread::sleep(Duration::from_millis(10));
3524 }
3525 anyhow::ensure!(
3526 reasoning_release.exists(),
3527 "reasoning phase was never released by the PTY test"
3528 );
3529 if let Err(error) = stream
3530 .write_all(read_tail.as_bytes())
3531 .and_then(|()| stream.flush())
3532 {
3533 connection_errors.push(format!(
3534 "File.read tail write failed for {request_line}: {error}"
3535 ));
3536 continue;
3537 }
3538 } else {
3539 let body = if chat_index == 1 {
3540 &bash_reply
3541 } else {
3542 &final_reply
3543 };
3544 let response = format!(
3545 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
3546 body.len()
3547 );
3548 if let Err(error) = stream
3549 .write_all(response.as_bytes())
3550 .and_then(|()| stream.flush())
3551 {
3552 connection_errors.push(format!(
3553 "chat response write failed for {request_line}: {error}"
3554 ));
3555 continue;
3556 }
3557 }
3558 chat_index += 1;
3559 }
3560 anyhow::ensure!(
3561 chat_index == 3,
3562 "semantic activity fixture served {chat_index}/3 chat requests; ignored connection errors: {}",
3563 connection_errors.join(" | ")
3564 );
3565 if !contract_errors.is_empty() {
3566 anyhow::bail!(
3567 "semantic activity fixture contract errors:\n{}",
3568 contract_errors.join("\n")
3569 );
3570 }
3571 Ok(())
3572 });
3573 Ok((format!("http://{address}"), handle))
3574 }
3575
3576 fn open_semantic_fifo_writer(
3577 path: &std::path::Path,
3578 deadline: Instant,
3579 ) -> anyhow::Result<std::fs::File> {
3580 use std::os::unix::fs::OpenOptionsExt;
3581
3582 loop {
3583 match std::fs::OpenOptions::new()
3584 .write(true)
3585 .custom_flags(libc::O_NONBLOCK)
3586 .open(path)
3587 {
3588 Ok(writer) => return Ok(writer),
3589 Err(error)
3590 if matches!(error.raw_os_error(), Some(libc::ENXIO) | Some(libc::EINTR))
3591 && Instant::now() < deadline =>
3592 {
3593 std::thread::sleep(Duration::from_millis(10));
3594 }
3595 Err(error) if error.raw_os_error() == Some(libc::ENXIO) => {
3596 anyhow::bail!("timed out waiting for FIFO reader at {}", path.display());
3597 }
3598 Err(error) => return Err(error.into()),
3599 }
3600 }
3601 }
3602
3603 fn release_semantic_read_fifo(
3604 path: std::path::PathBuf,
3605 ) -> std::thread::JoinHandle<anyhow::Result<()>> {
3606 std::thread::spawn(move || -> anyhow::Result<()> {
3607 // ReadFileTool opens once to sniff PDF magic, then keeps the main
3608 // explicit-range read on one already-open descriptor. Move the FIFO
3609 // after the sniff writer and recreate it at the requested path so the
3610 // content writer cannot attach to the short-lived sniff reader. The
3611 // nonblocking, deadline-bounded opens turn product exit into a useful
3612 // test error instead of an unbounded join.
3613 let sniff_path = path.with_extension("sniff");
3614 let mut sniff_writer =
3615 open_semantic_fifo_writer(&path, Instant::now() + Duration::from_secs(20))?;
3616 std::fs::rename(&path, &sniff_path)?;
3617 let mkfifo = Command::new("mkfifo").arg(&path).status()?;
3618 anyhow::ensure!(
3619 mkfifo.success(),
3620 "mkfifo failed while rotating {}",
3621 path.display()
3622 );
3623 sniff_writer.write_all(b"TEXT")?;
3624 sniff_writer.flush()?;
3625 drop(sniff_writer);
3626
3627 let mut content_writer =
3628 open_semantic_fifo_writer(&path, Instant::now() + Duration::from_secs(20))?;
3629 content_writer.write_all(b"MOTION-FIFO-CONTENT\n")?;
3630 content_writer.flush()?;
3631 Ok(())
3632 })
3633 }
3634
3635 fn whale_ansi_signature(frame: &qa_harness::Frame) -> Vec<qa_harness::Color> {
3636 const WHALE_BACK: &str = "▗▄▄▄▄▄▄▄▄▄▄▄▖";
3637 let (row, mut col) = frame
3638 .find_text(WHALE_BACK)
3639 .unwrap_or_else(|| panic!("idle BlueWhale silhouette missing:\n{}", frame.debug_dump()));
3640 WHALE_BACK
3641 .chars()
3642 .filter_map(|ch| {
3643 let color = frame.colors_at(row, col).map(|colors| colors.0);
3644 col = col.saturating_add(
3645 u16::try_from(unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1)).unwrap_or(1),
3646 );
3647 color
3648 })
3649 .collect()
3650 }
3651
3652 fn colored_foreground(frame: &qa_harness::Frame, needle: &str) -> qa_harness::Color {
3653 let (row, col) = frame
3654 .find_text(needle)
3655 .unwrap_or_else(|| panic!("{needle:?} missing:\n{}", frame.debug_dump()));
3656 let foreground = frame
3657 .colors_at(row, col)
3658 .expect("rendered text cell should have ANSI colors")
3659 .0;
3660 assert_ne!(
3661 foreground,
3662 qa_harness::Color::Default,
3663 "{needle:?} lost its semantic foreground:\n{}",
3664 frame.debug_dump()
3665 );
3666 foreground
3667 }
3668
3669 fn phase_marker_for_label(frame: &qa_harness::Frame, label: &str) -> char {
3670 // Transcript cards may carry the same semantic word (for example the
3671 // collapsed `reasoning hidden` receipt). The phase strip is the lowest
3672 // matching row, immediately above the composer, so search bottom-up.
3673 let row = (0..frame.rows())
3674 .rev()
3675 .find(|&row| frame.row(row).contains(label))
3676 .unwrap_or_else(|| panic!("phase {label:?} missing:\n{}", frame.debug_dump()));
3677 let row_text = frame.row(row);
3678 let label_start = row_text
3679 .find(label)
3680 .expect("matched phase row should still contain its label");
3681 row_text[..label_start]
3682 .chars()
3683 .rev()
3684 .find(|ch| !ch.is_whitespace())
3685 .expect("phase row should contain a marker")
3686 }
3687
3688 fn maybe_transcript_marker_before_icon(
3689 frame: &qa_harness::Frame,
3690 needle: &str,
3691 icon: &str,
3692 ) -> Option<char> {
3693 let row = visible_row_with_text(frame, needle)?;
3694 frame
3695 .row(row)
3696 .split_once(icon)?
3697 .0
3698 .chars()
3699 .rev()
3700 .find(|ch| !ch.is_whitespace())
3701 }
3702
3703 fn wait_for_transcript_marker_before_icon(
3704 h: &mut Harness,
3705 needle: &str,
3706 icon: &str,
3707 timeout: Duration,
3708 ) -> anyhow::Result<char> {
3709 let mut captured = None;
3710 h.wait_for(
3711 |frame| {
3712 captured = maybe_transcript_marker_before_icon(frame, needle, icon);
3713 captured.is_some()
3714 },
3715 timeout,
3716 )?;
3717 captured.ok_or_else(|| {
3718 anyhow::anyhow!(
3719 "transcript marker before {icon:?} on row {needle:?} missing after wait:\n{}",
3720 h.frame().debug_dump()
3721 )
3722 })
3723 }
3724
3725 fn horizontal_rule_fills(frame: &qa_harness::Frame, row: u16, cols: u16) -> bool {
3726 let text = frame.row(row);
3727 UnicodeWidthStr::width(text.as_str()) == usize::from(cols)
3728 && (text.chars().all(|ch| ch == '─') || text.chars().all(|ch| ch == '-'))
3729 }
3730
3731 /// `Harness::resize` updates the parser dimensions immediately, before the
3732 /// child has emitted its resized composition. Require the product's full-width
3733 /// header and composer rules before accepting the frame so a preserved old
3734 /// frame (or the clear between frames) cannot masquerade as a settled resize.
3735 fn resize_and_wait_for_composition<F>(
3736 h: &mut Harness,
3737 rows: u16,
3738 cols: u16,
3739 mut predicate: F,
3740 timeout: Duration,
3741 ) -> anyhow::Result<()>
3742 where
3743 F: FnMut(&qa_harness::Frame) -> bool,
3744 {
3745 let already_sized = {
3746 let frame = h.frame();
3747 frame.rows() == rows && frame.cols() == cols
3748 };
3749 if !already_sized {
3750 h.resize(rows, cols)?;
3751 }
3752 h.wait_for(
3753 |frame| {
3754 let full_width_rules = (0..rows)
3755 .filter(|&row| horizontal_rule_fills(frame, row, cols))
3756 .count();
3757 // Brand-aware header: `cw 🐳` (emoji chip) or legacy `cw ` spacing.
3758 let header = frame.row(0);
3759 let brand_header =
3760 header.contains("cw ") || header.contains("cw 🐳") || header.starts_with("cw ");
3761 frame.rows() == rows
3762 && frame.cols() == cols
3763 && brand_header
3764 && horizontal_rule_fills(frame, 1, cols)
3765 && full_width_rules >= 2
3766 && frame.contains(COMPOSER_READY_TEXT)
3767 && predicate(frame)
3768 },
3769 timeout,
3770 )
3771 }
3772
3773 fn assert_running_tool_lifecycle_frame(
3774 frame: &qa_harness::Frame,
3775 cols: u16,
3776 rows: u16,
3777 ) -> (qa_harness::Color, qa_harness::Color) {
3778 assert_real_pty_frame_geometry(frame, cols, rows);
3779 let dump = frame.debug_dump();
3780 assert!(
3781 frame.row(0).contains("act"),
3782 "Act missing from header:\n{dump}"
3783 );
3784 assert!(
3785 frame.row(0).contains("Full Access"),
3786 "effective Full Access missing from header:\n{dump}"
3787 );
3788 assert!(
3789 frame.contains("To-do ·"),
3790 "active To-do chrome missing:\n{dump}"
3791 );
3792 assert!(
3793 frame.contains("PTY lifecycle"),
3794 "canonical Work item missing:\n{dump}"
3795 );
3796 assert!(
3797 frame.contains("using tool"),
3798 "statusline did not name live tool use:\n{dump}"
3799 );
3800 assert!(
3801 frame.contains("run running"),
3802 "real Bash card did not remain live:\n{dump}"
3803 );
3804 assert!(
3805 frame.find_text("▗▄▄▄▄▄▄▄▄▄▄▄▖").is_none(),
3806 "idle BlueWhale should yield to functional transcript activity:\n{dump}"
3807 );
3808 let tool_row = visible_row_with_text(frame, "run running").expect("live Bash row");
3809 let tool_running = foreground_at_text(frame, tool_row, "running");
3810 assert_ne!(
3811 tool_running,
3812 qa_harness::Color::Default,
3813 "Bash running state lost its semantic foreground:\n{dump}"
3814 );
3815 (colored_foreground(frame, "using tool"), tool_running)
3816 }
3817
3818 enum ScrollDir {
3819 Up,
3820 Down,
3821 }
3822
3823 /// Scroll the transcript one step at a time — letting each step settle past the
3824 /// input-coalescing/redraw throttle — until `needle` is on-screen. Each step
3825 /// sends both a page key and a wheel event so it works regardless of which the
3826 /// transcript honors. Returns whether the needle became visible.
3827 fn scroll_until(h: &mut Harness, dir: ScrollDir, needle: &str) -> bool {
3828 if h.frame().contains(needle) {
3829 return true;
3830 }
3831 for _ in 0..50 {
3832 match dir {
3833 ScrollDir::Up => {
3834 let _ = h.send(keys::key::page_up());
3835 let _ = h.send(keys::mouse::wheel_up(10, 10));
3836 }
3837 ScrollDir::Down => {
3838 let _ = h.send(keys::mouse::wheel_down(10, 10));
3839 let _ = h.send(keys::mouse::wheel_down(10, 10));
3840 }
3841 }
3842 let _ = h.wait_for_idle(Duration::from_millis(60), Duration::from_millis(400));
3843 if h.frame().contains(needle) {
3844 return true;
3845 }
3846 }
3847 false
3848 }
3849
3850 /// Motion-enabled companion to [`scroll_until`]. The underwater field keeps
3851 /// emitting real frames while browsing history, so "no PTY bytes arrived" is
3852 /// not a valid settle signal. Poll the desired rendered state directly after
3853 /// each bounded input instead.
3854 fn scroll_until_with_motion(h: &mut Harness, dir: ScrollDir, needle: &str) -> bool {
3855 if h.frame().contains(needle) {
3856 return true;
3857 }
3858 for _ in 0..50 {
3859 match dir {
3860 ScrollDir::Up => {
3861 let _ = h.send(keys::key::page_up());
3862 let _ = h.send(keys::mouse::wheel_up(10, 10));
3863 }
3864 ScrollDir::Down => {
3865 let _ = h.send(keys::mouse::wheel_down(10, 10));
3866 let _ = h.send(keys::mouse::wheel_down(10, 10));
3867 }
3868 }
3869 if h.wait_for(|frame| frame.contains(needle), Duration::from_millis(160))
3870 .is_ok()
3871 {
3872 return true;
3873 }
3874 }
3875 false
3876 }
3877
3878 /// #4603: long transcript output must be retained beyond the viewport and
3879 /// remain reviewable by scrolling, with follow-tail restored on return to the
3880 /// bottom. Provider-free: the reply is a sealed loopback SSE fixture.
3881 #[test]
3882 fn long_output_scrolls_and_restores_follow_tail() -> anyhow::Result<()> {
3883 let _guard = qa_pty_test_lock();
3884
3885 // A reply well over three 24-row viewports: a head marker, ~90 numbered
3886 // lines, a very wide line (horizontal overflow), and a tail marker.
3887 let mut lines = vec!["SCROLLPROBE-HEAD".to_string()];
3888 for i in 1..=90 {
3889 lines.push(format!("SCROLLPROBE-LINE-{i:03}"));
3890 }
3891 lines.push(format!("SCROLLPROBE-WIDE-START{}WIDE-END", "x".repeat(200)));
3892 lines.push("SCROLLPROBE-TAIL".to_string());
3893 let content = lines.join("\n");
3894
3895 let (base_url, server) = spawn_long_reply_fixture(content)?;
3896 let ws = make_sealed_workspace()?;
3897 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
3898 .cwd(ws.workspace())
3899 .clear_env()
3900 .seal_home(ws.home())
3901 .env("DEEPSEEK_API_KEY", "ci-test-key-not-real")
3902 .env("DEEPSEEK_BASE_URL", &base_url)
3903 .env("NO_ANIMATIONS", "1")
3904 .env("RUST_LOG", "warn")
3905 .args([
3906 "--workspace",
3907 ws.workspace().to_str().expect("utf-8 workspace path"),
3908 "--no-project-config",
3909 "--skip-onboarding",
3910 "--mouse-capture",
3911 ])
3912 .size(24, 100)
3913 .spawn()?;
3914 enter_launch_session(&mut h)?;
3915
3916 // One turn that produces the long reply.
3917 let prompt = "Emit the long scroll probe.";
3918 h.paste(prompt)?;
3919 h.wait_for_text(prompt, KEY_TIMEOUT)?;
3920 h.wait_for_idle(Duration::from_millis(100), Duration::from_secs(2))?;
3921 h.send(keys::key::enter())?;
3922
3923 // The tail lands in view (follow-tail) and the head has scrolled off:
3924 // the content exists beyond the viewport rather than being truncated away.
3925 h.wait_for_text("SCROLLPROBE-TAIL", Duration::from_secs(10))?;
3926 assert!(
3927 !h.frame().contains("SCROLLPROBE-HEAD"),
3928 "head should be above the viewport once the long reply settles:\n{}",
3929 h.frame().debug_dump()
3930 );
3931
3932 // Scroll up: the retained head becomes reviewable and the tail leaves view.
3933 // Scroll incrementally, letting each step settle — the TUI coalesces a
3934 // rapid input burst, so one page/wheel event at a time is what a real user
3935 // (and a reliable test) applies.
3936 assert!(
3937 scroll_until(&mut h, ScrollDir::Up, "SCROLLPROBE-HEAD"),
3938 "head must be reachable by scrolling up:\n{}",
3939 h.frame().debug_dump()
3940 );
3941 assert!(
3942 !h.frame().contains("SCROLLPROBE-TAIL"),
3943 "scrolled away from the tail, so the tail marker should be gone:\n{}",
3944 h.frame().debug_dump()
3945 );
3946
3947 // Resize (reflow) preserves the ability to review earlier content.
3948 h.resize(30, 80)?;
3949 h.wait_for(|f| f.rows() == 30 && f.cols() == 80, KEY_TIMEOUT)?;
3950 h.wait_for_idle(Duration::from_millis(200), Duration::from_secs(3))?;
3951 assert!(
3952 h.frame().contains("SCROLLPROBE-HEAD")
3953 || scroll_until(&mut h, ScrollDir::Up, "SCROLLPROBE-HEAD"),
3954 "head must stay reviewable after a reflow:\n{}",
3955 h.frame().debug_dump()
3956 );
3957
3958 // Returning to the bottom restores follow-tail.
3959 assert!(
3960 scroll_until(&mut h, ScrollDir::Down, "SCROLLPROBE-TAIL"),
3961 "follow-tail must be restorable by scrolling back to the bottom:\n{}",
3962 h.frame().debug_dump()
3963 );
3964
3965 let _ = h.shutdown();
3966 server.join().expect("scroll fixture server thread");
3967 Ok(())
3968 }
3969
3970 /// #2886: drive the actual shipped TUI through a Unix PTY and a sealed
3971 /// provider fixture. The first real tool establishes canonical To-do state;
3972 /// the second is a real Bash process held live by a workspace sentinel so the
3973 /// running card and statusline can be inspected without a timing race.
3974 /// Captures, when requested, are parsed PTY frames emitted by the product.
3975 #[test]
3976 fn real_tool_lifecycle_crosses_work_status_resize_and_scroll_in_a_unix_pty() -> anyhow::Result<()> {
3977 let _guard = qa_pty_test_lock();
3978 const RELEASE_SIGNAL: &str = "pty-tool-release.signal";
3979
3980 let mut answer_lines = vec!["PTY-LIFECYCLE-HEAD".to_string()];
3981 for line in 1..=72 {
3982 answer_lines.push(format!("PTY-LIFECYCLE-LINE-{line:03}"));
3983 }
3984 answer_lines.push("PTY-LIFECYCLE-TAIL".to_string());
3985 let (base_url, server) =
3986 spawn_tool_lifecycle_screen_fixture(RELEASE_SIGNAL, answer_lines.join("\n"))?;
3987
3988 let ws = make_sealed_workspace()?;
3989 let codewhale_home = ws.home().join(".codewhale");
3990 let codex_home = ws.home().join(".codex");
3991 std::fs::create_dir_all(&codex_home)?;
3992 std::fs::write(
3993 codewhale_home.join("config.toml"),
3994 "allow_shell = true\nreasoning_effort = \"low\"\n\n[retry]\nenabled = false\n\n[update]\ncheck_for_updates = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
3995 )?;
3996 std::fs::write(
3997 codewhale_home.join("settings.toml"),
3998 "theme = \"dark\"\nlocale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"full-access\"\nlow_motion = false\nfancy_animations = true\ncomposer_border = true\n",
3999 )?;
4000 std::fs::write(
4001 codex_home.join("models_cache.json"),
4002 serde_json::to_vec_pretty(&serde_json::json!({
4003 "fetched_at": chrono::Utc::now(),
4004 "models": [{"slug": "deepseek-v4-pro", "priority": 1}]
4005 }))?,
4006 )?;
4007
4008 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
4009 .cwd(ws.workspace())
4010 .clear_env()
4011 .seal_home(ws.home())
4012 .env("CODEWHALE_HOME", codewhale_home.to_string_lossy())
4013 .env(
4014 "DEEPSEEK_CONFIG_PATH",
4015 codewhale_home.join("config.toml").to_string_lossy(),
4016 )
4017 .env("CODEX_HOME", codex_home.to_string_lossy())
4018 .env("CODEWHALE_PROVIDER", "deepseek")
4019 .env("DEEPSEEK_API_KEY", "deepseek-local-test-key")
4020 .env("DEEPSEEK_BASE_URL", &base_url)
4021 .env("CODEWHALE_BASE_URL", &base_url)
4022 .env("DEEPSEEK_MODEL", "deepseek-v4-pro")
4023 .env("CODEWHALE_MODEL", "deepseek-v4-pro")
4024 .env("RUST_LOG", "warn")
4025 .args([
4026 "--workspace",
4027 ws.workspace().to_str().expect("utf-8 workspace path"),
4028 "--no-project-config",
4029 "--skip-onboarding",
4030 "--mouse-capture",
4031 "--yolo",
4032 ])
4033 .size(24, 80)
4034 .spawn()?;
4035 enter_launch_session(&mut h)?;
4036
4037 // The authored BlueWhale is part of the real idle composition, with ANSI
4038 // ink emitted by the terminal renderer. Its text silhouette is stable;
4039 // the opt-in caustic shimmer changes cell colors without moving the mark.
4040 let initial_whale = whale_ansi_signature(h.frame());
4041 assert!(
4042 initial_whale
4043 .iter()
4044 .any(|color| *color != qa_harness::Color::Default),
4045 "idle BlueWhale lost its ANSI ink:\n{}",
4046 h.frame().debug_dump()
4047 );
4048 let shimmer_deadline = Instant::now() + Duration::from_secs(5);
4049 let mut shimmer_observed = false;
4050 while Instant::now() < shimmer_deadline {
4051 std::thread::sleep(Duration::from_millis(80));
4052 h.pump();
4053 if whale_ansi_signature(h.frame()) != initial_whale {
4054 shimmer_observed = true;
4055 break;
4056 }
4057 }
4058 assert!(
4059 shimmer_observed,
4060 "animated idle BlueWhale never changed ANSI cells:\n{}",
4061 h.frame().debug_dump()
4062 );
4063
4064 for (cols, rows) in [(80_u16, 24_u16), (100, 30), (140, 40)] {
4065 resize_and_wait_for_composition(
4066 &mut h,
4067 rows,
4068 cols,
4069 |frame| {
4070 frame.rows() == rows
4071 && frame.cols() == cols
4072 && frame.find_text("▗▄▄▄▄▄▄▄▄▄▄▄▖").is_some()
4073 },
4074 KEY_TIMEOUT,
4075 )?;
4076 let frame = h.frame();
4077 assert_real_pty_frame_geometry(frame, cols, rows);
4078 assert_empty_state_hierarchy(frame, false);
4079 assert!(
4080 whale_ansi_signature(frame)
4081 .iter()
4082 .any(|color| *color != qa_harness::Color::Default),
4083 "BlueWhale ANSI ink missing at {cols}x{rows}:\n{}",
4084 frame.debug_dump()
4085 );
4086 write_real_pty_evidence(
4087 &format!("tool-lifecycle-idle-{cols}x{rows}"),
4088 &format!(
4089 "size={cols}x{rows}\nphase=idle\nreal_pty=true\nprovider=loopback\nbluewhale=true"
4090 ),
4091 frame,
4092 )?;
4093 }
4094
4095 resize_and_wait_for_composition(
4096 &mut h,
4097 24,
4098 80,
4099 |frame| frame.rows() == 24 && frame.cols() == 80,
4100 KEY_TIMEOUT,
4101 )?;
4102 let prompt = "exercise the real PTY tool lifecycle";
4103 h.paste(prompt)?;
4104 h.wait_for_text(prompt, KEY_TIMEOUT)?;
4105 std::thread::sleep(Duration::from_millis(180));
4106 h.pump();
4107 h.send(keys::key::enter())?;
4108 h.wait_for(
4109 |frame| {
4110 frame.contains("using tool")
4111 && frame.contains("run running")
4112 && frame.contains("PTY lifecycle")
4113 },
4114 Duration::from_secs(15),
4115 )?;
4116
4117 // Typed tool liveness belongs to the phase strip once transcript activity
4118 // replaces the idle BlueWhale. Prove the actual emitted marker advances;
4119 // do not relabel the decorative idle silhouette as a running tool row.
4120 let initial_tool_marker = phase_marker_for_label(h.frame(), "using tool");
4121 let marker_deadline = Instant::now() + Duration::from_secs(2);
4122 let mut tool_marker_moved = false;
4123 while Instant::now() < marker_deadline {
4124 std::thread::sleep(Duration::from_millis(80));
4125 h.pump();
4126 if phase_marker_for_label(h.frame(), "using tool") != initial_tool_marker {
4127 tool_marker_moved = true;
4128 break;
4129 }
4130 }
4131 assert!(
4132 tool_marker_moved,
4133 "using-tool phase marker never advanced in the real PTY:\n{}",
4134 h.frame().debug_dump()
4135 );
4136
4137 let mut live_colors = None;
4138 for (cols, rows) in [(80_u16, 24_u16), (100, 30), (140, 40)] {
4139 resize_and_wait_for_composition(
4140 &mut h,
4141 rows,
4142 cols,
4143 |frame| {
4144 frame.rows() == rows
4145 && frame.cols() == cols
4146 && frame.contains("using tool")
4147 && frame.contains("run running")
4148 && frame.contains("PTY lifecycle")
4149 },
4150 KEY_TIMEOUT,
4151 )?;
4152 let frame = h.frame();
4153 let colors = assert_running_tool_lifecycle_frame(frame, cols, rows);
4154 if let Some(expected) = live_colors {
4155 assert_eq!(
4156 colors,
4157 expected,
4158 "using-tool/transcript ANSI roles changed at {cols}x{rows}:\n{}",
4159 frame.debug_dump()
4160 );
4161 } else {
4162 live_colors = Some(colors);
4163 }
4164 write_real_pty_evidence(
4165 &format!("tool-lifecycle-running-{cols}x{rows}"),
4166 &format!("size={cols}x{rows}\nphase=using-tool\nreal_tool=Bash\nlive_chrome=To-do"),
4167 frame,
4168 )?;
4169 }
4170
4171 // Release the real shell process only after every live-size assertion.
4172 std::fs::write(ws.workspace().join(RELEASE_SIGNAL), "release\n")?;
4173 h.wait_for_text("PTY-LIFECYCLE-TAIL", Duration::from_secs(20))?;
4174 h.wait_for(
4175 |frame| frame.contains("✓ done") && !frame.contains("run running"),
4176 Duration::from_secs(10),
4177 )?;
4178 {
4179 let frame = h.frame();
4180 let dump = frame.debug_dump();
4181 assert_real_pty_frame_geometry(frame, 140, 40);
4182 assert!(
4183 frame.contains("PTY-LIFECYCLE-TAIL"),
4184 "tail not followed:\n{dump}"
4185 );
4186 assert!(
4187 !frame.contains("PTY-LIFECYCLE-HEAD"),
4188 "long settled transcript did not exceed the viewport:\n{dump}"
4189 );
4190 assert!(
4191 frame.contains("To-do ·"),
4192 "active To-do vanished after settlement:\n{dump}"
4193 );
4194 let done_row = visible_row_with_text(frame, "✓ done").expect("done phase row");
4195 let done_color = foreground_at_text(frame, done_row, "done");
4196 assert_ne!(
4197 done_color,
4198 qa_harness::Color::Default,
4199 "done lost ANSI role"
4200 );
4201 assert_ne!(
4202 done_color,
4203 live_colors.expect("live colors").0,
4204 "done and live tool use collapsed to one ANSI role"
4205 );
4206 write_real_pty_evidence(
4207 "tool-lifecycle-settled-140x40",
4208 "size=140x40\nphase=done\ntranscript=settled\nfollow_tail=true",
4209 frame,
4210 )?;
4211 }
4212
4213 assert!(
4214 scroll_until_with_motion(&mut h, ScrollDir::Up, "PTY-LIFECYCLE-HEAD"),
4215 "settled transcript head is not reviewable at 140x40:\n{}",
4216 h.frame().debug_dump()
4217 );
4218 for (cols, rows) in [(100_u16, 30_u16), (80, 24)] {
4219 resize_and_wait_for_composition(
4220 &mut h,
4221 rows,
4222 cols,
4223 |frame| frame.rows() == rows && frame.cols() == cols,
4224 KEY_TIMEOUT,
4225 )?;
4226 assert!(
4227 h.frame().contains("PTY-LIFECYCLE-HEAD")
4228 || scroll_until_with_motion(&mut h, ScrollDir::Up, "PTY-LIFECYCLE-HEAD"),
4229 "transcript head was lost after reflow to {cols}x{rows}:\n{}",
4230 h.frame().debug_dump()
4231 );
4232 assert_real_pty_frame_geometry(h.frame(), cols, rows);
4233 }
4234 assert!(
4235 scroll_until_with_motion(&mut h, ScrollDir::Up, "run done"),
4236 "settled real Bash card is not retained in the transcript:\n{}",
4237 h.frame().debug_dump()
4238 );
4239 assert!(
4240 !h.frame().contains("run running"),
4241 "settled Bash card reverted to live state:\n{}",
4242 h.frame().debug_dump()
4243 );
4244
4245 // The ordinary shortened-output hint is a real transcript row at every
4246 // release geometry. Select it with terminal mouse bytes, then exercise the
4247 // shipped Alt/Option+V detail shortcut; screenshots remain genuine PTY
4248 // frames and never include a fabricated product surface.
4249 for (cols, rows) in [(80_u16, 24_u16), (100, 30), (140, 40)] {
4250 resize_and_wait_for_composition(
4251 &mut h,
4252 rows,
4253 cols,
4254 |frame| frame.rows() == rows && frame.cols() == cols,
4255 KEY_TIMEOUT,
4256 )?;
4257 let hint_visible = h.frame().contains("Output shortened")
4258 || scroll_until_with_motion(&mut h, ScrollDir::Up, "Output shortened")
4259 || scroll_until_with_motion(&mut h, ScrollDir::Down, "Output shortened");
4260 assert!(
4261 hint_visible,
4262 "shortened-output hint is not reviewable at {cols}x{rows}:\n{}",
4263 h.frame().debug_dump()
4264 );
4265 let (row, col) = h
4266 .frame()
4267 .find_text("Output shortened")
4268 .expect("visible selectable shortened-output hint");
4269 h.send(keys::mouse::click(row, col))?;
4270 h.send(keys::key::alt('v'))?;
4271 h.wait_for(
4272 |frame| {
4273 frame.contains("Raw detail — Bash")
4274 && frame.contains("Raw detail for the selected item")
4275 },
4276 KEY_TIMEOUT,
4277 )?;
4278 let frame = h.frame();
4279 assert_real_pty_frame_geometry(frame, cols, rows);
4280 assert!(!frame.contains("/artifacts/"));
4281 assert!(!frame.contains(".codewhale/sessions"));
4282 assert!(!frame.contains(ws.home().to_string_lossy().as_ref()));
4283 write_real_pty_evidence(
4284 &format!("tool-lifecycle-output-detail-{cols}x{rows}"),
4285 &format!(
4286 "size={cols}x{rows}\nreal_pty=true\npreview=selected\nshortcut=Alt+V\npath_leak=false"
4287 ),
4288 frame,
4289 )?;
4290 h.send(keys::key::ch('q'))?;
4291 h.wait_for(|frame| !frame.contains("Raw detail — Bash"), KEY_TIMEOUT)?;
4292 }
4293
4294 resize_and_wait_for_composition(
4295 &mut h,
4296 24,
4297 80,
4298 |frame| frame.rows() == 24 && frame.cols() == 80,
4299 KEY_TIMEOUT,
4300 )?;
4301 assert!(
4302 scroll_until_with_motion(&mut h, ScrollDir::Down, "PTY-LIFECYCLE-TAIL"),
4303 "follow-tail is not restorable at 80x24:\n{}",
4304 h.frame().debug_dump()
4305 );
4306
4307 for (cols, rows) in [(100_u16, 30_u16), (140, 40)] {
4308 resize_and_wait_for_composition(
4309 &mut h,
4310 rows,
4311 cols,
4312 |frame| frame.rows() == rows && frame.cols() == cols,
4313 KEY_TIMEOUT,
4314 )?;
4315 assert!(
4316 h.frame().contains("PTY-LIFECYCLE-TAIL")
4317 || scroll_until_with_motion(&mut h, ScrollDir::Down, "PTY-LIFECYCLE-TAIL"),
4318 "follow-tail was lost after reflow to {cols}x{rows}:\n{}",
4319 h.frame().debug_dump()
4320 );
4321 let frame = h.frame();
4322 assert_real_pty_frame_geometry(frame, cols, rows);
4323 assert!(
4324 frame.contains("To-do ·"),
4325 "active To-do missing at {cols}x{rows}"
4326 );
4327 }
4328
4329 let artifact_dir = std::fs::read_dir(ws.home().join(".codewhale/sessions"))?
4330 .filter_map(Result::ok)
4331 .map(|entry| entry.path().join("artifacts"))
4332 .find(|path| path.join("art_call_bash_pty.txt").is_file())
4333 .ok_or_else(|| anyhow::anyhow!("real PTY Bash evidence artifact was not retained"))?;
4334 let exact = std::fs::read(artifact_dir.join("art_call_bash_pty.txt"))?;
4335 assert!(
4336 String::from_utf8_lossy(&exact).contains("PTY-EVIDENCE-DEEP-SENTINEL"),
4337 "real PTY artifact lost its deep sentinel"
4338 );
4339 let metadata: serde_json::Value = serde_json::from_slice(&std::fs::read(
4340 artifact_dir.join("art_call_bash_pty.evidence.json"),
4341 )?)?;
4342 assert_eq!(metadata["handle"], "art_call_bash_pty");
4343 assert_eq!(metadata["call_id"], "call_bash_pty");
4344 assert_eq!(metadata["tool_name"], "Bash");
4345 assert_eq!(metadata["size_bytes"], exact.len() as u64);
4346 let digest = Sha256::digest(&exact)
4347 .iter()
4348 .map(|byte| format!("{byte:02x}"))
4349 .collect::<String>();
4350 assert_eq!(
4351 metadata["digest"], digest,
4352 "real PTY metadata digest must bind the exact bytes"
4353 );
4354
4355 let _ = h.shutdown();
4356 server
4357 .join()
4358 .expect("tool lifecycle fixture server thread")?;
4359 Ok(())
4360 }
4361
4362 /// The semantic phase strip is acceptance-tested against the shipped binary,
4363 /// not a synthetic widget. A sealed loopback stream holds explicit private
4364 /// reasoning, canonical File.read on a FIFO, and Bash on a sentinel long
4365 /// enough for the real PTY to capture each truthful one-row label.
4366 #[test]
4367 fn semantic_activity_motion_crosses_reasoning_reading_and_tool_use_in_a_real_unix_pty()
4368 -> anyhow::Result<()> {
4369 let _guard = qa_pty_test_lock();
4370
4371 #[derive(Clone, Copy)]
4372 struct Case {
4373 name: &'static str,
4374 theme: &'static str,
4375 motion_mode: &'static str,
4376 reduced_motion: bool,
4377 fancy_animations: bool,
4378 expect_motion: bool,
4379 static_marker: Option<char>,
4380 ascii_safe: bool,
4381 reasoning_size: (u16, u16),
4382 reading_size: (u16, u16),
4383 tool_size: (u16, u16),
4384 }
4385
4386 let cases = [
4387 Case {
4388 name: "dark-motion",
4389 theme: "dark",
4390 motion_mode: "full",
4391 reduced_motion: false,
4392 fancy_animations: true,
4393 expect_motion: true,
4394 static_marker: None,
4395 ascii_safe: false,
4396 reasoning_size: (100, 32),
4397 reading_size: (50, 16),
4398 tool_size: (140, 40),
4399 },
4400 Case {
4401 name: "light-reduced",
4402 theme: "light",
4403 motion_mode: "reduced",
4404 reduced_motion: true,
4405 fancy_animations: false,
4406 expect_motion: false,
4407 static_marker: Some('⣤'),
4408 ascii_safe: false,
4409 reasoning_size: (100, 32),
4410 reading_size: (80, 24),
4411 tool_size: (100, 32),
4412 },
4413 Case {
4414 name: "dark-ascii",
4415 theme: "dark",
4416 motion_mode: "full",
4417 reduced_motion: false,
4418 fancy_animations: true,
4419 expect_motion: true,
4420 static_marker: None,
4421 ascii_safe: true,
4422 reasoning_size: (80, 24),
4423 reading_size: (80, 24),
4424 tool_size: (80, 24),
4425 },
4426 Case {
4427 name: "dark-still",
4428 theme: "dark",
4429 motion_mode: "still",
4430 reduced_motion: false,
4431 fancy_animations: false,
4432 expect_motion: false,
4433 static_marker: Some('›'),
4434 ascii_safe: false,
4435 reasoning_size: (100, 32),
4436 reading_size: (80, 24),
4437 tool_size: (100, 32),
4438 },
4439 ];
4440
4441 for case in cases {
4442 const FIFO_NAME: &str = "semantic-motion-read.fifo";
4443 const REASONING_RELEASE: &str = "semantic-motion-reasoning.release";
4444 const BASH_RELEASE: &str = "semantic-motion-bash.release";
4445
4446 let ws = make_sealed_workspace()?;
4447 let fifo_path = ws.workspace().join(FIFO_NAME);
4448 let mkfifo = Command::new("mkfifo").arg(&fifo_path).status()?;
4449 anyhow::ensure!(
4450 mkfifo.success(),
4451 "mkfifo failed for {}",
4452 fifo_path.display()
4453 );
4454
4455 let reasoning_release = ws.workspace().join(REASONING_RELEASE);
4456 let (base_url, server) = spawn_semantic_activity_motion_fixture(
4457 reasoning_release.clone(),
4458 FIFO_NAME,
4459 BASH_RELEASE,
4460 )?;
4461
4462 let codewhale_home = ws.home().join(".codewhale");
4463 let codex_home = ws.home().join(".codex");
4464 std::fs::create_dir_all(&codex_home)?;
4465 std::fs::write(
4466 codewhale_home.join("config.toml"),
4467 "allow_shell = true\nreasoning_effort = \"low\"\n\n[retry]\nenabled = false\n\n[update]\ncheck_for_updates = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
4468 )?;
4469 std::fs::write(
4470 codewhale_home.join("settings.toml"),
4471 format!(
4472 "theme = \"{}\"\nlocale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"full-access\"\nshow_thinking = false\nlow_motion = {}\nfancy_animations = {}\ncomposer_border = true\n",
4473 case.theme, case.reduced_motion, case.fancy_animations,
4474 ),
4475 )?;
4476 std::fs::write(
4477 codex_home.join("models_cache.json"),
4478 serde_json::to_vec_pretty(&serde_json::json!({
4479 "fetched_at": chrono::Utc::now(),
4480 "models": [{"slug": "deepseek-v4-pro", "priority": 1}]
4481 }))?,
4482 )?;
4483
4484 let mut builder = Harness::builder(Harness::cargo_bin("codewhale-tui"))
4485 .cwd(ws.workspace())
4486 .clear_env()
4487 .seal_home(ws.home())
4488 .env("CODEWHALE_HOME", codewhale_home.to_string_lossy())
4489 .env(
4490 "DEEPSEEK_CONFIG_PATH",
4491 codewhale_home.join("config.toml").to_string_lossy(),
4492 )
4493 .env("CODEX_HOME", codex_home.to_string_lossy())
4494 .env("CODEWHALE_PROVIDER", "deepseek")
4495 .env("DEEPSEEK_API_KEY", "deepseek-local-test-key")
4496 .env("DEEPSEEK_BASE_URL", &base_url)
4497 .env("CODEWHALE_BASE_URL", &base_url)
4498 .env("DEEPSEEK_MODEL", "deepseek-v4-pro")
4499 .env("CODEWHALE_MODEL", "deepseek-v4-pro")
4500 .env("RUST_LOG", "warn")
4501 .args([
4502 "--workspace",
4503 ws.workspace().to_str().expect("utf-8 workspace path"),
4504 "--no-project-config",
4505 "--skip-onboarding",
4506 "--mouse-capture",
4507 "--yolo",
4508 ])
4509 .size(case.reasoning_size.1, case.reasoning_size.0);
4510 if case.reduced_motion {
4511 builder = builder.env("NO_ANIMATIONS", "1");
4512 }
4513 if case.ascii_safe {
4514 builder = builder.env("CODEWHALE_ASCII_SAFE", "1");
4515 }
4516 let mut h = builder.spawn()?;
4517 enter_launch_session(&mut h)?;
4518
4519 let prompt = "show semantic activity with less chrome";
4520 h.paste(prompt)?;
4521 h.wait_for_text(prompt, KEY_TIMEOUT)?;
4522 h.send(keys::key::enter())?;
4523 h.wait_for(|frame| frame.contains("reasoning"), Duration::from_secs(15))?;
4524
4525 let reasoning_marker = phase_marker_for_label(h.frame(), "reasoning");
4526 if !case.expect_motion {
4527 assert_eq!(
4528 Some(reasoning_marker),
4529 case.static_marker,
4530 "wrong semantic fallback marker in {}:\n{}",
4531 case.name,
4532 h.frame().debug_dump()
4533 );
4534 std::thread::sleep(Duration::from_millis(320));
4535 h.pump();
4536 assert_eq!(
4537 phase_marker_for_label(h.frame(), "reasoning"),
4538 reasoning_marker,
4539 "static reasoning marker moved in {}:\n{}",
4540 case.name,
4541 h.frame().debug_dump()
4542 );
4543 } else {
4544 let static_marker = if case.ascii_safe { '>' } else { '›' };
4545 let deadline = Instant::now() + Duration::from_secs(2);
4546 let mut first_animated =
4547 (reasoning_marker != static_marker).then_some(reasoning_marker);
4548 while Instant::now() < deadline && first_animated.is_none() {
4549 std::thread::sleep(Duration::from_millis(80));
4550 h.pump();
4551 let marker = phase_marker_for_label(h.frame(), "reasoning");
4552 if marker != static_marker {
4553 first_animated = Some(marker);
4554 }
4555 }
4556 let first_animated = first_animated.unwrap_or_else(|| {
4557 panic!(
4558 "semantic reasoning marker never crossed its earned-motion delay in {}:\n{}",
4559 case.name,
4560 h.frame().debug_dump()
4561 )
4562 });
4563
4564 let deadline = Instant::now() + Duration::from_secs(2);
4565 let mut advanced_after_delay = false;
4566 while Instant::now() < deadline {
4567 std::thread::sleep(Duration::from_millis(80));
4568 h.pump();
4569 let marker = phase_marker_for_label(h.frame(), "reasoning");
4570 if marker != static_marker && marker != first_animated {
4571 advanced_after_delay = true;
4572 break;
4573 }
4574 }
4575 assert!(
4576 advanced_after_delay,
4577 "semantic reasoning marker froze after its earned-motion delay in {}:\n{}",
4578 case.name,
4579 h.frame().debug_dump()
4580 );
4581 }
4582 {
4583 let frame = h.frame();
4584 let dump = frame.debug_dump();
4585 assert_real_pty_frame_geometry(frame, case.reasoning_size.0, case.reasoning_size.1);
4586 assert!(
4587 !frame.contains("PRIVATE-MOTION-TRACE-MUST-STAY-HIDDEN"),
4588 "private reasoning leaked into the product UI:\n{dump}"
4589 );
4590 colored_foreground(frame, "reasoning");
4591 if case.ascii_safe {
4592 assert!(
4593 frame.text().is_ascii(),
4594 "ASCII-safe reasoning frame:\n{dump}"
4595 );
4596 }
4597 write_real_pty_evidence(
4598 &format!(
4599 "semantic-{}-reasoning-{}x{}",
4600 case.name, case.reasoning_size.0, case.reasoning_size.1
4601 ),
4602 &format!(
4603 "theme={}\nphase=reasoning\nmotion_mode={}\nreduced_motion={}\nfancy_animations={}\nascii_safe={}\nprivate_reasoning_visible=false",
4604 case.theme,
4605 case.motion_mode,
4606 case.reduced_motion,
4607 case.fancy_animations,
4608 case.ascii_safe
4609 ),
4610 frame,
4611 )?;
4612 }
4613
4614 std::fs::write(&reasoning_release, "release\n")?;
4615 h.wait_for(|frame| frame.contains("reading"), Duration::from_secs(15))?;
4616 resize_and_wait_for_composition(
4617 &mut h,
4618 case.reading_size.1,
4619 case.reading_size.0,
4620 |frame| frame.contains("reading"),
4621 KEY_TIMEOUT,
4622 )?;
4623 {
4624 let frame = h.frame();
4625 let dump = frame.debug_dump();
4626 let row = visible_row_with_text(frame, "reading").expect("reading phase row");
4627 let row_text = frame.row(row);
4628 assert_real_pty_frame_geometry(frame, case.reading_size.0, case.reading_size.1);
4629 assert!(
4630 !frame.contains("PRIVATE-MOTION-TRACE-MUST-STAY-HIDDEN"),
4631 "private reasoning leaked during File.read:\n{dump}"
4632 );
4633 assert!(
4634 frame.contains("read running") && frame.contains("live: Reading"),
4635 "File.read transcript spacing collapsed:\n{dump}"
4636 );
4637 if case.reading_size.0 < 60 {
4638 assert!(
4639 !row_text.contains('×') && !row_text.contains('s'),
4640 "compact semantic row carried detail: {row_text:?}"
4641 );
4642 }
4643 colored_foreground(frame, "reading");
4644 if case.ascii_safe {
4645 assert!(frame.text().is_ascii(), "ASCII-safe reading frame:\n{dump}");
4646 }
4647 write_real_pty_evidence(
4648 &format!(
4649 "semantic-{}-reading-{}x{}",
4650 case.name, case.reading_size.0, case.reading_size.1
4651 ),
4652 &format!(
4653 "theme={}\nphase=reading\nreal_tool=File.read\nsize={}x{}\nmotion_mode={}\nreduced_motion={}\nfancy_animations={}\nascii_safe={}\nprivate_reasoning_visible=false",
4654 case.theme,
4655 case.reading_size.0,
4656 case.reading_size.1,
4657 case.motion_mode,
4658 case.reduced_motion,
4659 case.fancy_animations,
4660 case.ascii_safe
4661 ),
4662 frame,
4663 )?;
4664 }
4665
4666 release_semantic_read_fifo(fifo_path)
4667 .join()
4668 .expect("FIFO release thread")?;
4669 h.wait_for(
4670 |frame| frame.contains("using tool") && frame.contains("run running"),
4671 Duration::from_secs(15),
4672 )?;
4673 resize_and_wait_for_composition(
4674 &mut h,
4675 case.tool_size.1,
4676 case.tool_size.0,
4677 |frame| frame.contains("using tool") && frame.contains("run running"),
4678 KEY_TIMEOUT,
4679 )?;
4680 {
4681 let frame = h.frame();
4682 let dump = frame.debug_dump();
4683 let row = visible_row_with_text(frame, "using tool").expect("tool phase row");
4684 let row_text = frame.row(row);
4685 assert_real_pty_frame_geometry(frame, case.tool_size.0, case.tool_size.1);
4686 assert!(
4687 frame.contains("read done")
4688 && frame.contains("done: Reading")
4689 && frame.contains("run running"),
4690 "completed/read and live/Bash transcript spacing collapsed:\n{dump}"
4691 );
4692 if case.tool_size.0 >= 60 {
4693 let count = if case.ascii_safe { "X1" } else { "×1" };
4694 assert!(
4695 row_text.contains(count),
4696 "bounded tool count missing: {row_text:?}"
4697 );
4698 }
4699 assert!(
4700 !row_text.contains("run ×1"),
4701 "tool verb repeated: {row_text:?}"
4702 );
4703 colored_foreground(frame, "using tool");
4704 if case.ascii_safe {
4705 assert!(frame.text().is_ascii(), "ASCII-safe tool frame:\n{dump}");
4706 }
4707 write_real_pty_evidence(
4708 &format!(
4709 "semantic-{}-using-tool-{}x{}",
4710 case.name, case.tool_size.0, case.tool_size.1
4711 ),
4712 &format!(
4713 "theme={}\nphase=using-tool\nreal_tool=Bash.run\nsize={}x{}\nmotion_mode={}\nreduced_motion={}\nfancy_animations={}\nascii_safe={}\nprivate_reasoning_visible=false",
4714 case.theme,
4715 case.tool_size.0,
4716 case.tool_size.1,
4717 case.motion_mode,
4718 case.reduced_motion,
4719 case.fancy_animations,
4720 case.ascii_safe
4721 ),
4722 frame,
4723 )?;
4724 }
4725
4726 if case.expect_motion && !case.ascii_safe {
4727 let deadline = Instant::now() + Duration::from_secs(2);
4728 let mut first_animated = None;
4729 while Instant::now() < deadline && first_animated.is_none() {
4730 std::thread::sleep(Duration::from_millis(80));
4731 h.pump();
4732 if let Some(marker) =
4733 maybe_transcript_marker_before_icon(h.frame(), "run running", "▶")
4734 && marker != '›'
4735 {
4736 first_animated = Some(marker);
4737 }
4738 }
4739 let first_animated = first_animated.unwrap_or_else(|| {
4740 panic!(
4741 "full-motion transcript tool marker never crossed its earned-motion delay in {}:\n{}",
4742 case.name,
4743 h.frame().debug_dump()
4744 )
4745 });
4746
4747 let deadline = Instant::now() + Duration::from_secs(2);
4748 let mut advanced_after_delay = false;
4749 while Instant::now() < deadline {
4750 std::thread::sleep(Duration::from_millis(80));
4751 h.pump();
4752 if let Some(marker) =
4753 maybe_transcript_marker_before_icon(h.frame(), "run running", "▶")
4754 && marker != '›'
4755 && marker != first_animated
4756 {
4757 advanced_after_delay = true;
4758 break;
4759 }
4760 }
4761 assert!(
4762 advanced_after_delay,
4763 "full-motion transcript tool marker froze after its earned-motion delay in {}:\n{}",
4764 case.name,
4765 h.frame().debug_dump()
4766 );
4767 } else if !case.ascii_safe {
4768 let initial_tool_marker =
4769 wait_for_transcript_marker_before_icon(&mut h, "run running", "▶", KEY_TIMEOUT)?;
4770 assert_eq!(
4771 Some(initial_tool_marker),
4772 case.static_marker,
4773 "wrong transcript fallback marker in {}:\n{}",
4774 case.name,
4775 h.frame().debug_dump()
4776 );
4777 std::thread::sleep(Duration::from_millis(320));
4778 h.pump();
4779 let marker_after_delay =
4780 wait_for_transcript_marker_before_icon(&mut h, "run running", "▶", KEY_TIMEOUT)?;
4781 assert_eq!(
4782 marker_after_delay,
4783 initial_tool_marker,
4784 "static transcript tool marker moved in {}:\n{}",
4785 case.name,
4786 h.frame().debug_dump()
4787 );
4788 resize_and_wait_for_composition(
4789 &mut h,
4790 case.tool_size.1,
4791 case.tool_size.0 + 1,
4792 |frame| frame.contains("using tool") && frame.contains("run running"),
4793 KEY_TIMEOUT,
4794 )?;
4795 resize_and_wait_for_composition(
4796 &mut h,
4797 case.tool_size.1,
4798 case.tool_size.0,
4799 |frame| frame.contains("using tool") && frame.contains("run running"),
4800 KEY_TIMEOUT,
4801 )?;
4802 let marker_after_resize =
4803 wait_for_transcript_marker_before_icon(&mut h, "run running", "▶", KEY_TIMEOUT)?;
4804 assert_eq!(
4805 marker_after_resize,
4806 initial_tool_marker,
4807 "state-change redraw moved a static transcript marker in {}:\n{}",
4808 case.name,
4809 h.frame().debug_dump()
4810 );
4811 }
4812
4813 std::fs::write(ws.workspace().join(BASH_RELEASE), "release\n")?;
4814 h.wait_for_text("SEMANTIC-MOTION-DONE", Duration::from_secs(20))?;
4815 h.wait_for(
4816 |frame| frame.contains("done") && !frame.contains("run running"),
4817 Duration::from_secs(10),
4818 )?;
4819 let _ = h.shutdown();
4820 server
4821 .join()
4822 .expect("semantic activity fixture server thread")?;
4823 }
4824
4825 Ok(())
4826 }
4827
4828 /// SSE fixture for the transcript-rhythm probe: one turn that reasons, says
4829 /// something, and runs a shell command; then a closing turn.
4830 fn spawn_transcript_rhythm_fixture(
4831 shell_command: String,
4832 ) -> anyhow::Result<(String, std::thread::JoinHandle<()>)> {
4833 let listener = TcpListener::bind("127.0.0.1:0")?;
4834 listener.set_nonblocking(true)?;
4835 let address = listener.local_addr()?;
4836
4837 let reasoning = format!(
4838 "data: {}\n\n",
4839 serde_json::json!({
4840 "id": "chatcmpl-rhythm",
4841 "object": "chat.completion.chunk",
4842 "model": "deepseek-v4-pro",
4843 "choices": [{
4844 "index": 0,
4845 "delta": {"reasoning_content":
4846 "PROBEREASONING the listing command is the one to run here."},
4847 "finish_reason": null
4848 }]
4849 })
4850 );
4851 let prose = format!(
4852 "data: {}\n\n",
4853 serde_json::json!({
4854 "id": "chatcmpl-rhythm",
4855 "object": "chat.completion.chunk",
4856 "model": "deepseek-v4-pro",
4857 "choices": [{
4858 "index": 0,
4859 "delta": {"content": "PROBEANSWERA Running the listing now."},
4860 "finish_reason": null
4861 }]
4862 })
4863 );
4864 let call = pty_tool_call_sse(
4865 "call_rhythm_probe",
4866 "Bash",
4867 serde_json::json!({
4868 "action": "run",
4869 "command": shell_command,
4870 "timeout_ms": 60_000
4871 }),
4872 );
4873 let first = format!("{reasoning}{prose}{call}");
4874 let second = pty_text_sse("PROBEANSWERB That is the full listing.");
4875
4876 let handle = std::thread::spawn(move || {
4877 let deadline = Instant::now() + Duration::from_secs(60);
4878 let mut chat_index = 0usize;
4879 while chat_index < 2 && Instant::now() < deadline {
4880 let Ok((mut stream, _)) = listener.accept() else {
4881 std::thread::sleep(Duration::from_millis(10));
4882 continue;
4883 };
4884 let Ok(request) = read_http_request(&mut stream) else {
4885 continue;
4886 };
4887 let request_line = request.lines().next().unwrap_or_default();
4888 if request_line.starts_with("GET ") && request_line.contains("/models") {
4889 let body = serde_json::json!({
4890 "object": "list",
4891 "data": [{"id": "deepseek-v4-pro", "object": "model"}]
4892 })
4893 .to_string();
4894 let _ = stream.write_all(
4895 format!(
4896 "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
4897 body.len()
4898 )
4899 .as_bytes(),
4900 );
4901 let _ = stream.flush();
4902 continue;
4903 }
4904 if !(request_line.starts_with("POST ") && request_line.contains("/chat/completions")) {
4905 continue;
4906 }
4907 let body = if chat_index == 0 { &first } else { &second };
4908 let _ = stream.write_all(
4909 format!(
4910 "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
4911 body.len()
4912 )
4913 .as_bytes(),
4914 );
4915 let _ = stream.flush();
4916 chat_index += 1;
4917 }
4918 });
4919 Ok((format!("http://{address}"), handle))
4920 }
4921
4922 /// Boot a probe session against the rhythm fixture and return the settled
4923 /// frame rows plus its debug dump.
4924 fn run_transcript_rhythm_probe(
4925 show_tool_details: bool,
4926 total_rows: usize,
4927 ) -> anyhow::Result<(Vec<String>, String, Vec<String>)> {
4928 let ws = make_sealed_workspace()?;
4929 let shell_command = format!(
4930 "for i in $(seq 0 {}); do printf 'row %02d plain content\\n' \"$i\"; done",
4931 total_rows - 1
4932 );
4933 let (base_url, server) = spawn_transcript_rhythm_fixture(shell_command)?;
4934
4935 let codewhale_home = ws.home().join(".codewhale");
4936 let codex_home = ws.home().join(".codex");
4937 std::fs::create_dir_all(&codex_home)?;
4938 std::fs::write(
4939 codewhale_home.join("config.toml"),
4940 "allow_shell = true\n\n[retry]\nenabled = false\n\n[update]\ncheck_for_updates = false\n\n[notifications]\nmethod = \"off\"\ncompletion_sound = \"off\"\n",
4941 )?;
4942 std::fs::write(
4943 codewhale_home.join("settings.toml"),
4944 format!(
4945 // `show_thinking = true` is the shape the complaint was made
4946 // about: the owner could see the reasoning body, and it ran
4947 // straight into the answer underneath it.
4948 "locale = \"en\"\ndefault_mode = \"agent\"\npermission_posture = \"full-access\"\nshow_thinking = true\nshow_tool_details = {show_tool_details}\ntranscript_spacing = \"comfortable\"\ncomposer_border = true\n"
4949 ),
4950 )?;
4951 std::fs::write(
4952 codex_home.join("models_cache.json"),
4953 serde_json::to_vec_pretty(&serde_json::json!({
4954 "fetched_at": chrono::Utc::now(),
4955 "models": [{"slug": "deepseek-v4-pro", "priority": 1}]
4956 }))?,
4957 )?;
4958
4959 let mut h = Harness::builder(Harness::cargo_bin("codewhale-tui"))
4960 .cwd(ws.workspace())
4961 .clear_env()
4962 .seal_home(ws.home())
4963 .env("CODEWHALE_HOME", codewhale_home.to_string_lossy())
4964 .env(
4965 "DEEPSEEK_CONFIG_PATH",
4966 codewhale_home.join("config.toml").to_string_lossy(),
4967 )
4968 .env("CODEX_HOME", codex_home.to_string_lossy())
4969 .env("CODEWHALE_PROVIDER", "deepseek")
4970 .env("DEEPSEEK_API_KEY", "deepseek-local-test-key")
4971 .env("DEEPSEEK_BASE_URL", &base_url)
4972 .env("CODEWHALE_BASE_URL", &base_url)
4973 .env("DEEPSEEK_MODEL", "deepseek-v4-pro")
4974 .env("CODEWHALE_MODEL", "deepseek-v4-pro")
4975 .env("NO_ANIMATIONS", "1")
4976 .env("RUST_LOG", "warn")
4977 .args([
4978 "--workspace",
4979 ws.workspace().to_str().expect("utf-8 workspace path"),
4980 "--no-project-config",
4981 "--skip-onboarding",
4982 "--yolo",
4983 ])
4984 // Tall enough that the whole probe turn lands on one frame; the
4985 // assertions are about the rows between blocks, not about scrolling.
4986 .size(64, 100)
4987 .spawn()?;
4988 enter_launch_session(&mut h)?;
4989
4990 h.paste(TRANSCRIPT_RHYTHM_PROMPT)?;
4991 h.wait_for_text(TRANSCRIPT_RHYTHM_PROMPT, KEY_TIMEOUT)?;
4992 h.send(keys::key::enter())?;
4993 let dump = wait_for_frame_dump(
4994 &mut h,
4995 |frame| frame.contains("PROBEANSWERB"),
4996 Duration::from_secs(30),
4997 )?;
4998 let frame = h.frame();
4999 write_real_pty_evidence(
5000 &format!("transcript-rhythm-details-{show_tool_details}"),
5001 "size=64x100 spacing=comfortable show_thinking=true",
5002 frame,
5003 )?;
5004 if std::env::var_os("CODEWHALE_QA_PRINT_FRAME").is_some() {
5005 println!("--- show_tool_details={show_tool_details}\n{dump}");
5006 }
5007 let rows: Vec<String> = (0..frame.rows()).map(|y| frame.row(y)).collect();
5008 let painted: Vec<String> = rows.clone();
5009 let _ = h.shutdown();
5010 drop(server);
5011 Ok((rows, dump, painted))
5012 }
5013
5014 const TRANSCRIPT_RHYTHM_PROMPT: &str = "list the rows";
5015
5016 /// Transcript vertical rhythm + live run-card budget, measured on real
5017 /// terminal output rather than on a renderer unit test.
5018 ///
5019 /// The owner's complaint had two halves and both are properties of painted
5020 /// rows, so both are asserted on a parsed PTY frame:
5021 ///
5022 /// * consecutive blocks ran together with no blank row — a reasoning block
5023 /// flowing straight into the answer that followed it;
5024 /// * the run cards showed so little of their output that even the truncated
5025 /// view could not tell you what happened. A *successful* run showed its
5026 /// header and nothing else at all.
5027 ///
5028 /// The shell command here really runs (`--yolo`), so the card under assertion
5029 /// is a real one carrying real output.
5030 ///
5031 /// Set `CODEWHALE_QA_EVIDENCE_DIR` to capture the frame dumps,
5032 /// `CODEWHALE_QA_PRINT_FRAME=1` to print them.
5033 #[test]
5034 fn transcript_blocks_are_separated_and_run_cards_show_real_output() -> anyhow::Result<()> {
5035 let _guard = qa_pty_test_lock();
5036 // Twenty-four rows of unremarkable output: nothing an importance filter
5037 // would rescue, which is the case the head/tail split served worst.
5038 let total_rows = 24usize;
5039
5040 // Leg A — shipped defaults (`show_tool_details = false`). This is what
5041 // almost every user sees, and it is the frame the complaint was about.
5042 let (rows, dump, painted) = run_transcript_rhythm_probe(false, total_rows)?;
5043 let painted_contains =
5044 |needle: &str, rows: &[String]| rows.iter().any(|row| row.contains(needle));
5045 let row_of = |needle: &str| {
5046 rows.iter()
5047 .position(|row| row.contains(needle))
5048 .unwrap_or_else(|| panic!("missing {needle} in frame:\n{dump}"))
5049 };
5050 let blank_between = |a: usize, b: usize| {
5051 rows[a.min(b) + 1..a.max(b)]
5052 .iter()
5053 .any(|row| row.trim().is_empty())
5054 };
5055
5056 // 1. Reasoning must not run straight into the answer that follows it.
5057 // This is the specific seam the owner pointed at.
5058 let reasoning = row_of("PROBEREASONING");
5059 let answer_a = row_of("PROBEANSWERA");
5060 assert!(
5061 answer_a > reasoning,
5062 "the answer should follow the reasoning:\n{dump}"
5063 );
5064 assert!(
5065 blank_between(reasoning, answer_a),
5066 "a reasoning block and the answer after it need a blank row between \
5067 them:\n{dump}"
5068 );
5069
5070 // 2. Assistant prose must not run straight into the tool card.
5071 let card = row_of("row 00 plain content");
5072 assert!(
5073 blank_between(answer_a, card),
5074 "assistant prose and the tool card below it need a blank row:\n{dump}"
5075 );
5076
5077 // 3. The user's own turn stays a visible seam.
5078 let user = row_of(TRANSCRIPT_RHYTHM_PROMPT);
5079 assert!(
5080 blank_between(user, reasoning),
5081 "the user turn needs a visible seam:\n{dump}"
5082 );
5083
5084 // 4. Nowhere does a second separator row stack on the first. A scrolling
5085 // terminal cannot afford a two-row gap and it reads as a hole.
5086 let last = row_of("PROBEANSWERB");
5087 assert!(
5088 !rows[user..=last]
5089 .windows(2)
5090 .any(|pair| pair[0].trim().is_empty() && pair[1].trim().is_empty()),
5091 "no two separator rows may stack:\n{dump}"
5092 );
5093
5094 // 5. A *successful* run card used to paint its header and nothing else.
5095 // On shipped defaults it must now carry real output rows.
5096 let shown = (0..total_rows)
5097 .filter(|i| painted_contains(&format!("row {i:02} plain content"), &painted))
5098 .count();
5099 assert!(
5100 shown >= 4,
5101 "a successful run card on shipped defaults painted {shown} output \
5102 rows; it used to paint none and must now show enough to tell what \
5103 happened:\n{dump}"
5104 );
5105
5106 // Leg B — `show_tool_details = true`, where the card spends the full
5107 // output budget rather than the summary cap.
5108 let (_rows, detail_dump, detail_painted) = run_transcript_rhythm_probe(true, total_rows)?;
5109 let detailed = (0..total_rows)
5110 .filter(|i| painted_contains(&format!("row {i:02} plain content"), &detail_painted))
5111 .count();
5112 assert!(
5113 detailed > shown,
5114 "show_tool_details must reveal more than the summary card \
5115 ({detailed} vs {shown}):\n{detail_dump}"
5116 );
5117 assert!(
5118 detailed >= 6,
5119 "a detailed run card painted only {detailed} output rows:\n{detail_dump}"
5120 );
5121
5122 Ok(())
5123 }
5124
5125 /// Two of the owner's reported display defects, both measured on real
5126 /// terminal output.
5127 ///
5128 /// 1. **The composer looked like it was cutting text off.** It was not losing
5129 /// anything — it broke lines on whatever grapheme crossed the margin, so a
5130 /// wrapped sentence split mid-word (`…Write the file onl` / `y after…`),
5131 /// which reads exactly like truncation. Assert that no wrapped composer
5132 /// line ends inside a word and that every word survives.
5133 ///
5134 /// 2. **A one-row toast was cut to uselessness.** Opening a second CodeWhale
5135 /// in the same workspace loses the coordination flock and raises a sticky
5136 /// warning. At a flat 40-column budget it painted `Delegated coordination
5137 /// unavailable — an…`. Here a real second session is booted against the
5138 /// same workspace and the strip must actually say what happened.
5139 #[test]
5140 fn composer_wraps_between_words_and_the_lock_toast_stays_legible() -> anyhow::Result<()> {
5141 let _guard = qa_pty_test_lock();
5142
5143 // --- Leg 1: composer wrapping.
5144 let (ws, mut h) = boot_minimal()?;
5145 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
5146 let typed = "Mark inferences as inferences. A short PRD where each section decides something beats a long one that merely describes. Write the file only after the outline is agreed.";
5147 h.paste(typed)?;
5148 h.wait_for_text("outline is agreed", KEY_TIMEOUT)?;
5149 let composer_dump = h.frame().debug_dump();
5150 write_real_pty_evidence_dump("composer-wrap", "size=40x140", &composer_dump)?;
5151 if std::env::var_os("CODEWHALE_QA_PRINT_FRAME").is_some() {
5152 println!("--- composer wrap\n{composer_dump}");
5153 }
5154
5155 let frame = h.frame();
5156 let rows: Vec<String> = (0..frame.rows()).map(|y| frame.row(y)).collect();
5157 // The composer rows are the ones carrying the typed text.
5158 let composer_rows: Vec<&String> = rows
5159 .iter()
5160 .filter(|row| {
5161 typed
5162 .split(' ')
5163 .any(|word| word.len() > 6 && row.contains(word))
5164 })
5165 .collect();
5166 assert!(
5167 composer_rows.len() > 1,
5168 "the probe text must wrap to more than one row:\n{composer_dump}"
5169 );
5170 // Every word of the input survives somewhere on the frame, whole.
5171 for word in typed.split(' ').filter(|word| word.len() > 3) {
5172 let word = word.trim_end_matches(['.', ',']);
5173 assert!(
5174 rows.iter().any(|row| row.contains(word)),
5175 "word {word:?} was split across the wrap and no row holds it \
5176 whole:\n{composer_dump}"
5177 );
5178 }
5179 let _ = h.shutdown();
5180 drop(ws);
5181
5182 // --- Leg 2: the one-row status toast budget.
5183 //
5184 // The coordination-lock warning the owner hit is one instance of a
5185 // general defect: every sticky toast was truncated to a flat 40 columns
5186 // no matter how wide the terminal was. A failed `/load` raises a long
5187 // sticky warning through the same strip, and reproduces it in two
5188 // keystrokes without needing two sessions racing a flock. The lock
5189 // warning's own copy is pinned in
5190 // `tui::ui::tests::coordination_lock_loss_warns_only_for_a_foreign_owner`.
5191 let ws = make_sealed_workspace()?;
5192 let session_path = ws.workspace().join("broken-session.json");
5193 std::fs::write(
5194 &session_path,
5195 serde_json::to_vec_pretty(&serde_json::json!({
5196 "schema_version": 1,
5197 "metadata": {
5198 "id": "pty-broken",
5199 "title": "Broken",
5200 "created_at": "2026-08-04T00:00:00Z",
5201 "updated_at": "2026-08-04T00:00:00Z",
5202 "message_count": 0,
5203 "total_tokens": 0,
5204 "model": "deepseek-v4-pro",
5205 "model_provider": "deepseek",
5206 "workspace": ws.workspace(),
5207 "mode": "agent",
5208 "cost": {},
5209 "cumulative_turn_secs": 0
5210 },
5211 "messages": [],
5212 "system_prompt": null,
5213 // A non-empty legacy Work view with no graph fails validation and
5214 // raises a long, explanatory warning — exactly the class of
5215 // message a 40-column budget destroys.
5216 "work_state": {
5217 "todos": {"items": [], "completion_pct": 0, "in_progress_id": null},
5218 "plan": {"objective": "", "items": []}
5219 }
5220 }))?,
5221 )?;
5222 let (_ws2, mut h) = spawn_minimal_with_env(ws, &[])?;
5223 h.wait_for_text(COMPOSER_READY_TEXT, BOOT_TIMEOUT)?;
5224 enter_launch_session(&mut h)?;
5225 h.send(keys::key::text(&format!(
5226 "/load {}",
5227 session_path.to_string_lossy()
5228 )))?;
5229 h.wait_for_idle(Duration::from_millis(150), Duration::from_secs(2))?;
5230 h.send(keys::key::enter())?;
5231 let toast_dump = wait_for_frame_dump(
5232 &mut h,
5233 |frame| frame.contains("Failed to restore session"),
5234 Duration::from_secs(10),
5235 )?;
5236 write_real_pty_evidence_dump("status-toast-budget", "size=40x140", &toast_dump)?;
5237 if std::env::var_os("CODEWHALE_QA_PRINT_FRAME").is_some() {
5238 println!("--- status toast\n{toast_dump}");
5239 }
5240
5241 let frame = h.frame();
5242 let strip = (0..frame.rows())
5243 .map(|y| frame.row(y))
5244 .find(|row| row.contains("Failed to restore session"))
5245 .expect("the strip must carry the warning");
5246 let warning = strip
5247 .split_once("Failed to restore session")
5248 .map(|(_, tail)| format!("Failed to restore session{tail}"))
5249 .unwrap_or_default();
5250 let warning = warning
5251 .split(" ")
5252 .next()
5253 .unwrap_or_default()
5254 .trim()
5255 .to_string();
5256 assert!(
5257 warning.chars().count() > 40,
5258 "the strip painted {} columns of a long warning on a 140-column \
5259 terminal; the flat 40-column budget was the defect:\n{toast_dump}",
5260 warning.chars().count()
5261 );
5262 assert!(
5263 warning.contains("Work Graph"),
5264 "the truncated warning must still reach the part that explains it: \
5265 {warning:?}\n{toast_dump}"
5266 );
5267
5268 let _ = h.shutdown();
5269 Ok(())
5270 }
5271
5271 lines RUST