| 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 |