| 1 | |
| 2 | #[test] |
| 3 | fn exec_only_installs_an_explicit_headless_turn_cap() { |
| 4 | let cli = parse_cli(&["codewhale", "exec", "--auto", "benchmark this"]); |
| 5 | let Some(Commands::Exec(args)) = cli.command else { |
| 6 | panic!("expected exec command"); |
| 7 | }; |
| 8 | |
| 9 | assert_eq!(args.max_turns, None); |
| 10 | let defaulted = exec_max_steps(args.max_turns); |
| 11 | assert_eq!( |
| 12 | defaulted, |
| 13 | crate::core::engine::turn_budget::DEFAULT_MAX_MODEL_STEPS |
| 14 | ); |
| 15 | assert_eq!( |
| 16 | crate::core::turn::TurnContext::new(defaulted).step_limit(), |
| 17 | None |
| 18 | ); |
| 19 | assert_eq!(exec_max_steps(Some(7)), 7); |
| 20 | let mut capped = crate::core::turn::TurnContext::new(exec_max_steps(Some(7))); |
| 21 | for _ in 0..7 { |
| 22 | capped.next_step(); |
| 23 | } |
| 24 | assert!(capped.at_max_steps()); |
| 25 | assert_eq!(capped.stop_diagnostics.effective_max_steps, Some(7)); |
| 26 | assert_eq!( |
| 27 | exec_max_steps(Some(u32::MAX)), |
| 28 | crate::core::engine::turn_budget::MAX_MAX_MODEL_STEPS, |
| 29 | "even the largest override stays finite" |
| 30 | ); |
| 31 | } |
| 32 | |
| 33 | #[test] |
| 34 | fn exec_accepts_continue_for_latest_workspace_session() { |
| 35 | let cli = parse_cli(&["codewhale", "exec", "--continue", "follow up"]); |
| 36 | let Some(Commands::Exec(args)) = cli.command else { |
| 37 | panic!("expected exec command"); |
| 38 | }; |
| 39 | |
| 40 | assert!(args.continue_session); |
| 41 | } |
| 42 | |
| 43 | #[test] |
| 44 | fn sessions_footer_points_to_resume_subcommand() { |
| 45 | let cli = parse_cli(&["codewhale", "resume", "abc123"]); |
| 46 | let Some(Commands::Resume { session_id, last }) = cli.command else { |
| 47 | panic!("expected resume command"); |
| 48 | }; |
| 49 | |
| 50 | assert_eq!(session_id.as_deref(), Some("abc123")); |
| 51 | assert!(!last); |
| 52 | assert_eq!(sessions_resume_command(), "codewhale resume"); |
| 53 | assert!(!sessions_resume_command().contains("--resume")); |
| 54 | } |
| 55 | |
| 56 | #[test] |
| 57 | fn plugin_registry_initialization_precedes_dotenv_for_all_launch_paths() { |
| 58 | use std::cell::Cell; |
| 59 | |
| 60 | #[derive(Clone, Copy)] |
| 61 | enum Expected { |
| 62 | Plain, |
| 63 | Resume, |
| 64 | Fork, |
| 65 | Exec, |
| 66 | Serve, |
| 67 | } |
| 68 | |
| 69 | let cases: &[(&[&str], Expected)] = &[ |
| 70 | (&["codewhale"], Expected::Plain), |
| 71 | (&["codewhale", "resume", "--last"], Expected::Resume), |
| 72 | (&["codewhale", "fork", "--last"], Expected::Fork), |
| 73 | (&["codewhale", "exec", "probe"], Expected::Exec), |
| 74 | (&["codewhale", "serve", "--mcp"], Expected::Serve), |
| 75 | ]; |
| 76 | |
| 77 | for (args, expected) in cases { |
| 78 | let phase = Cell::new(0); |
| 79 | let (_cli, command) = prepare_cli_startup( |
| 80 | parse_cli(args), |
| 81 | || { |
| 82 | assert_eq!(phase.get(), 0, "plugin init order for {args:?}"); |
| 83 | phase.set(1); |
| 84 | }, |
| 85 | || { |
| 86 | assert_eq!(phase.get(), 1, "dotenv load order for {args:?}"); |
| 87 | phase.set(2); |
| 88 | }, |
| 89 | ); |
| 90 | |
| 91 | assert_eq!(phase.get(), 2, "startup phases for {args:?}"); |
| 92 | let correct_variant = matches!( |
| 93 | (expected, command.as_ref()), |
| 94 | (Expected::Plain, None) |
| 95 | | (Expected::Resume, Some(Commands::Resume { .. })) |
| 96 | | (Expected::Fork, Some(Commands::Fork { .. })) |
| 97 | | (Expected::Exec, Some(Commands::Exec(_))) |
| 98 | | (Expected::Serve, Some(Commands::Serve(_))) |
| 99 | ); |
| 100 | assert!(correct_variant, "unexpected command for {args:?}"); |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | #[test] |
| 105 | fn scorecard_rejects_a_threshold_that_cannot_gate() { |
| 106 | for bad in ["NaN", "inf", "-inf"] { |
| 107 | assert!( |
| 108 | Cli::try_parse_from([ |
| 109 | "codewhale", |
| 110 | "scorecard", |
| 111 | "--input", |
| 112 | "t.json", |
| 113 | "--threshold", |
| 114 | bad |
| 115 | ]) |
| 116 | .is_err(), |
| 117 | "--threshold {bad} must be refused" |
| 118 | ); |
| 119 | } |
| 120 | assert!( |
| 121 | Cli::try_parse_from([ |
| 122 | "codewhale", |
| 123 | "scorecard", |
| 124 | "--input", |
| 125 | "t.json", |
| 126 | "--threshold", |
| 127 | "2.5" |
| 128 | ]) |
| 129 | .is_ok() |
| 130 | ); |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn workspace_dotenv_is_found_from_the_launch_workspace_not_the_process_directory() { |
| 135 | let workspace = tempfile::tempdir().expect("workspace tempdir"); |
| 136 | let nested = workspace.path().join("crates/app"); |
| 137 | std::fs::create_dir_all(&nested).expect("mkdir nested"); |
| 138 | std::fs::create_dir_all(workspace.path().join(".git")).expect("mkdir .git"); |
| 139 | std::fs::write(workspace.path().join(".env"), "OPENAI_API_KEY=workspace\n") |
| 140 | .expect("write .env"); |
| 141 | |
| 142 | // `--workspace <dir>` from a process directory in another tree. |
| 143 | assert_eq!( |
| 144 | find_workspace_dotenv(workspace.path()).expect("search"), |
| 145 | Some(workspace.path().join(".env")) |
| 146 | ); |
| 147 | // Nested launch directory walks up to the repository root. |
| 148 | assert_eq!( |
| 149 | find_workspace_dotenv(&nested).expect("search"), |
| 150 | Some(workspace.path().join(".env")) |
| 151 | ); |
| 152 | } |
| 153 | |
| 154 | #[test] |
| 155 | fn workspace_dotenv_loads_only_provider_credentials_and_preserves_shell_values() { |
| 156 | let _lock = crate::test_support::lock_test_env(); |
| 157 | let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY"); |
| 158 | let _nvidia = crate::test_support::EnvVarGuard::set("NVIDIA_API_KEY", "shell-key"); |
| 159 | let _home = crate::test_support::EnvVarGuard::remove("CODEWHALE_HOME"); |
| 160 | let _config = crate::test_support::EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"); |
| 161 | let _shell = crate::test_support::EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL"); |
| 162 | let tmp = tempfile::TempDir::new().expect("temp workspace"); |
| 163 | let dotenv = tmp.path().join(".env"); |
| 164 | std::fs::write( |
| 165 | &dotenv, |
| 166 | "DEEPSEEK_API_KEY=workspace-key\n\ |
| 167 | NVIDIA_API_KEY=repo-must-not-override-shell\n\ |
| 168 | CODEWHALE_HOME=./attacker-home\n\ |
| 169 | CODEWHALE_CONFIG_PATH=./attacker.toml\n\ |
| 170 | DEEPSEEK_ALLOW_SHELL=true\n", |
| 171 | ) |
| 172 | .expect("write dotenv"); |
| 173 | |
| 174 | let report = load_workspace_dotenv_credentials_from_path(&dotenv).expect("safe load"); |
| 175 | |
| 176 | assert_eq!( |
| 177 | std::env::var("DEEPSEEK_API_KEY").as_deref(), |
| 178 | Ok("workspace-key") |
| 179 | ); |
| 180 | assert_eq!(std::env::var("NVIDIA_API_KEY").as_deref(), Ok("shell-key")); |
| 181 | assert!(std::env::var_os("CODEWHALE_HOME").is_none()); |
| 182 | assert!(std::env::var_os("CODEWHALE_CONFIG_PATH").is_none()); |
| 183 | assert!(std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_none()); |
| 184 | assert_eq!( |
| 185 | report.loaded, |
| 186 | BTreeSet::from(["DEEPSEEK_API_KEY".to_string()]) |
| 187 | ); |
| 188 | assert_eq!( |
| 189 | report.ignored, |
| 190 | BTreeSet::from([ |
| 191 | "CODEWHALE_CONFIG_PATH".to_string(), |
| 192 | "CODEWHALE_HOME".to_string(), |
| 193 | "DEEPSEEK_ALLOW_SHELL".to_string(), |
| 194 | ]) |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | #[test] |
| 199 | fn workspace_dotenv_rejects_ambient_variable_substitution() { |
| 200 | let _lock = crate::test_support::lock_test_env(); |
| 201 | let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY"); |
| 202 | let _ambient = crate::test_support::EnvVarGuard::set( |
| 203 | "CODEWHALE_JS_SECRET_LEAK_TEST", |
| 204 | "ambient-secret-must-not-expand", |
| 205 | ); |
| 206 | let tmp = tempfile::TempDir::new().expect("temp workspace"); |
| 207 | let dotenv = tmp.path().join(".env"); |
| 208 | std::fs::write( |
| 209 | &dotenv, |
| 210 | "DEEPSEEK_API_KEY=${CODEWHALE_JS_SECRET_LEAK_TEST}\n", |
| 211 | ) |
| 212 | .expect("write dotenv"); |
| 213 | |
| 214 | let error = load_workspace_dotenv_credentials_from_path(&dotenv) |
| 215 | .expect_err("expansion must fail closed") |
| 216 | .to_string(); |
| 217 | |
| 218 | assert!(error.contains("variable expansion")); |
| 219 | assert!(!error.contains("ambient-secret-must-not-expand")); |
| 220 | assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none()); |
| 221 | } |
| 222 | |
| 223 | #[test] |
| 224 | fn workspace_dotenv_rejects_multiline_ambient_variable_substitution() { |
| 225 | let _lock = crate::test_support::lock_test_env(); |
| 226 | let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY"); |
| 227 | let _ambient = crate::test_support::EnvVarGuard::set( |
| 228 | "CODEWHALE_JS_SECRET_LEAK_TEST", |
| 229 | "ambient-secret-must-not-expand", |
| 230 | ); |
| 231 | let tmp = tempfile::TempDir::new().expect("temp workspace"); |
| 232 | let dotenv = tmp.path().join(".env"); |
| 233 | std::fs::write( |
| 234 | &dotenv, |
| 235 | "DEEPSEEK_API_KEY=\"prefix\n$CODEWHALE_JS_SECRET_LEAK_TEST=bar\nsuffix\"\n", |
| 236 | ) |
| 237 | .expect("write dotenv"); |
| 238 | |
| 239 | let error = load_workspace_dotenv_credentials_from_path(&dotenv) |
| 240 | .expect_err("multiline expansion must fail closed") |
| 241 | .to_string(); |
| 242 | |
| 243 | assert!(error.contains("variable expansion")); |
| 244 | assert!(!error.contains("ambient-secret-must-not-expand")); |
| 245 | assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none()); |
| 246 | } |
| 247 | |
| 248 | #[test] |
| 249 | fn workspace_dotenv_comment_quote_cannot_hide_later_expansion() { |
| 250 | let _lock = crate::test_support::lock_test_env(); |
| 251 | let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY"); |
| 252 | let _ambient = crate::test_support::EnvVarGuard::set( |
| 253 | "CODEWHALE_JS_SECRET_LEAK_TEST", |
| 254 | "ambient-secret-must-not-expand", |
| 255 | ); |
| 256 | let tmp = tempfile::TempDir::new().expect("temp workspace"); |
| 257 | let dotenv = tmp.path().join(".env"); |
| 258 | std::fs::write( |
| 259 | &dotenv, |
| 260 | "# unmatched quote in ignored comment: '\n\ |
| 261 | DEEPSEEK_API_KEY=$CODEWHALE_JS_SECRET_LEAK_TEST\n", |
| 262 | ) |
| 263 | .expect("write dotenv"); |
| 264 | |
| 265 | let error = load_workspace_dotenv_credentials_from_path(&dotenv) |
| 266 | .expect_err("comment quote must not hide expansion") |
| 267 | .to_string(); |
| 268 | |
| 269 | assert!(error.contains("variable expansion")); |
| 270 | assert!(!error.contains("ambient-secret-must-not-expand")); |
| 271 | assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none()); |
| 272 | } |
| 273 | |
| 274 | #[test] |
| 275 | fn workspace_dotenv_allows_single_quoted_literal_dollar() { |
| 276 | let _lock = crate::test_support::lock_test_env(); |
| 277 | let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY"); |
| 278 | let tmp = tempfile::TempDir::new().expect("temp workspace"); |
| 279 | let dotenv = tmp.path().join(".env"); |
| 280 | std::fs::write(&dotenv, "DEEPSEEK_API_KEY='$literal-value'\n").expect("write dotenv"); |
| 281 | |
| 282 | load_workspace_dotenv_credentials_from_path(&dotenv).expect("literal dollar load"); |
| 283 | |
| 284 | assert_eq!( |
| 285 | std::env::var("DEEPSEEK_API_KEY").as_deref(), |
| 286 | Ok("$literal-value") |
| 287 | ); |
| 288 | } |
| 289 | |
| 290 | #[test] |
| 291 | fn workspace_dotenv_parse_failure_applies_no_earlier_credentials() { |
| 292 | let _lock = crate::test_support::lock_test_env(); |
| 293 | let _deepseek = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY"); |
| 294 | let tmp = tempfile::TempDir::new().expect("temp workspace"); |
| 295 | let dotenv = tmp.path().join(".env"); |
| 296 | std::fs::write( |
| 297 | &dotenv, |
| 298 | "DEEPSEEK_API_KEY=must-not-survive\nBROKEN=\"unterminated\n", |
| 299 | ) |
| 300 | .expect("write dotenv"); |
| 301 | |
| 302 | let error = load_workspace_dotenv_credentials_from_path(&dotenv) |
| 303 | .expect_err("parse failure must be transactional") |
| 304 | .to_string(); |
| 305 | |
| 306 | assert!(error.contains("could not be parsed safely"), "{error}"); |
| 307 | assert!(!error.contains("must-not-survive")); |
| 308 | assert!(std::env::var_os("DEEPSEEK_API_KEY").is_none()); |
| 309 | } |
| 310 | |
| 311 | #[test] |
| 312 | fn workspace_dotenv_credential_allowlist_excludes_control_plane_names() { |
| 313 | for provider in codewhale_config::provider::providers_sorted_for_display() { |
| 314 | for key in provider.env_vars() { |
| 315 | assert!( |
| 316 | is_workspace_dotenv_credential_key(key), |
| 317 | "provider credential {key} must remain supported" |
| 318 | ); |
| 319 | } |
| 320 | } |
| 321 | for key in [ |
| 322 | "CODEWHALE_HOME", |
| 323 | "CODEWHALE_CONFIG_PATH", |
| 324 | "DEEPSEEK_CONFIG_PATH", |
| 325 | "DEEPSEEK_PROFILE", |
| 326 | "DEEPSEEK_MANAGED_CONFIG_PATH", |
| 327 | "DEEPSEEK_REQUIREMENTS_PATH", |
| 328 | "DEEPSEEK_PROVIDER", |
| 329 | "DEEPSEEK_BASE_URL", |
| 330 | "DEEPSEEK_MODEL", |
| 331 | "DEEPSEEK_APPROVAL_POLICY", |
| 332 | "DEEPSEEK_SANDBOX_MODE", |
| 333 | "DEEPSEEK_ALLOW_SHELL", |
| 334 | "DEEPSEEK_YOLO", |
| 335 | "DEEPSEEK_MCP_CONFIG", |
| 336 | "CODEWHALE_RUNTIME_TOKEN", |
| 337 | "PATH", |
| 338 | "NODE_OPTIONS", |
| 339 | "PYTHONPATH", |
| 340 | "LD_PRELOAD", |
| 341 | "DYLD_INSERT_LIBRARIES", |
| 342 | ] { |
| 343 | assert!( |
| 344 | !is_workspace_dotenv_credential_key(key), |
| 345 | "control-plane variable {key} must not load from a workspace" |
| 346 | ); |
| 347 | } |
| 348 | } |
| 349 | |
| 350 | #[cfg(unix)] |
| 351 | #[test] |
| 352 | fn workspace_dotenv_does_not_follow_symbolic_links() { |
| 353 | use std::os::unix::fs::symlink; |
| 354 | |
| 355 | let tmp = tempfile::TempDir::new().expect("temp workspace"); |
| 356 | let external = tmp.path().join("external-credentials"); |
| 357 | let dotenv = tmp.path().join(".env"); |
| 358 | std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n") |
| 359 | .expect("write external fixture"); |
| 360 | symlink(&external, &dotenv).expect("create dotenv symlink"); |
| 361 | |
| 362 | let error = load_workspace_dotenv_credentials_from_path(&dotenv) |
| 363 | .expect_err("symlink must fail closed") |
| 364 | .to_string(); |
| 365 | |
| 366 | assert!(error.contains("securely open"), "{error}"); |
| 367 | assert!(!error.contains("external-secret")); |
| 368 | } |
| 369 | |
| 370 | #[cfg(unix)] |
| 371 | #[test] |
| 372 | fn workspace_dotenv_rejects_hard_links_to_external_files() { |
| 373 | let tmp = tempfile::TempDir::new().expect("temp workspace"); |
| 374 | let external = tmp.path().join("external-credentials"); |
| 375 | let dotenv = tmp.path().join(".env"); |
| 376 | std::fs::write(&external, "DEEPSEEK_API_KEY=external-secret\n") |
| 377 | .expect("write external fixture"); |
| 378 | std::fs::hard_link(&external, &dotenv).expect("create dotenv hard link"); |
| 379 | |
| 380 | let error = load_workspace_dotenv_credentials_from_path(&dotenv) |
| 381 | .expect_err("hard link must fail closed") |
| 382 | .to_string(); |
| 383 | |
| 384 | assert!(error.contains("multiple filesystem links"), "{error}"); |
| 385 | assert!(!error.contains("external-secret")); |
| 386 | } |
| 387 | |
| 388 | #[cfg(unix)] |
| 389 | #[test] |
| 390 | fn workspace_dotenv_rejects_fifo_without_blocking_startup() { |
| 391 | use std::ffi::CString; |
| 392 | use std::os::unix::ffi::OsStrExt; |
| 393 | use std::sync::mpsc; |
| 394 | use std::time::Duration; |
| 395 | |
| 396 | let tmp = tempfile::TempDir::new().expect("temp workspace"); |
| 397 | let dotenv = tmp.path().join(".env"); |
| 398 | let c_path = CString::new(dotenv.as_os_str().as_bytes()).expect("fifo path"); |
| 399 | // SAFETY: `c_path` is a live, NUL-terminated path and the requested |
| 400 | // mode grants access only to the current user. |
| 401 | let result = unsafe { libc::mkfifo(c_path.as_ptr(), libc::S_IRUSR | libc::S_IWUSR) }; |
| 402 | assert_eq!(result, 0, "mkfifo failed: {}", io::Error::last_os_error()); |
| 403 | |
| 404 | let (tx, rx) = mpsc::channel(); |
| 405 | let worker_path = dotenv.clone(); |
| 406 | let worker = std::thread::spawn(move || { |
| 407 | let result = load_workspace_dotenv_credentials_from_path(&worker_path) |
| 408 | .map(|_| "unexpected success".to_string()) |
| 409 | .unwrap_or_else(|error| error.to_string()); |
| 410 | tx.send(result).expect("send loader result"); |
| 411 | }); |
| 412 | |
| 413 | let error = match rx.recv_timeout(Duration::from_secs(1)) { |
| 414 | Ok(error) => error, |
| 415 | Err(timeout) => { |
| 416 | // Release a regressed blocking reader so the test can fail |
| 417 | // promptly instead of leaving a stuck process behind. |
| 418 | let _writer = std::fs::OpenOptions::new() |
| 419 | .write(true) |
| 420 | .open(&dotenv) |
| 421 | .expect("open fifo writer to release blocked reader"); |
| 422 | let _ = rx.recv_timeout(Duration::from_secs(1)); |
| 423 | worker.join().expect("join released loader"); |
| 424 | panic!("workspace .env FIFO blocked startup: {timeout}"); |
| 425 | } |
| 426 | }; |
| 427 | worker.join().expect("join loader"); |
| 428 | |
| 429 | assert!(error.contains("not a regular file"), "{error}"); |
| 430 | } |
| 431 | |
| 432 | #[test] |
| 433 | fn exec_json_conflicts_with_stream_json_output() { |
| 434 | let err = Cli::try_parse_from([ |
| 435 | "codewhale", |
| 436 | "exec", |
| 437 | "--json", |
| 438 | "--output-format", |
| 439 | "stream-json", |
| 440 | "hello", |
| 441 | ]) |
| 442 | .expect_err("json summary and stream-json must not mix"); |
| 443 | |
| 444 | assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); |
| 445 | } |
| 446 | |
| 447 | #[test] |
| 448 | fn exec_stream_turn_usage_event_serializes_reported_fields() { |
| 449 | let event = ExecStreamEvent::TurnUsage { |
| 450 | turn: 2, |
| 451 | input_tokens: 1200, |
| 452 | output_tokens: 180, |
| 453 | reasoning_tokens: Some(90), |
| 454 | prompt_cache_hit_tokens: Some(900), |
| 455 | prompt_cache_miss_tokens: Some(300), |
| 456 | prompt_cache_write_tokens: Some(0), |
| 457 | reasoning_replay_tokens: Some(40), |
| 458 | duration_ms: 1834, |
| 459 | }; |
| 460 | |
| 461 | let value = exec_stream_value(&event).expect("serializes"); |
| 462 | let json = serde_json::to_string(&value).expect("serializes"); |
| 463 | assert!(!json.contains('\n')); |
| 464 | let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); |
| 465 | assert_eq!(parsed["type"], "turn_usage"); |
| 466 | assert_eq!(parsed["schema"], "codewhale.exec-stream"); |
| 467 | assert_eq!(parsed["schema_version"], 1); |
| 468 | assert_eq!(parsed["turn"], 2); |
| 469 | assert_eq!(parsed["input_tokens"], 1200); |
| 470 | assert_eq!(parsed["output_tokens"], 180); |
| 471 | assert_eq!(parsed["reasoning_tokens"], 90); |
| 472 | assert_eq!(parsed["prompt_cache_hit_tokens"], 900); |
| 473 | assert_eq!(parsed["prompt_cache_miss_tokens"], 300); |
| 474 | assert_eq!(parsed["prompt_cache_write_tokens"], 0); |
| 475 | assert_eq!(parsed["reasoning_replay_tokens"], 40); |
| 476 | assert_eq!(parsed["duration_ms"], 1834); |
| 477 | } |
| 478 | |
| 479 | #[test] |
| 480 | fn exec_stream_turn_usage_event_omits_unreported_fields() { |
| 481 | // Honest absence: optional token fields the provider did not report |
| 482 | // are dropped from the object entirely — never emitted as null and |
| 483 | // never backfilled with fabricated zeros. |
| 484 | let event = ExecStreamEvent::TurnUsage { |
| 485 | turn: 1, |
| 486 | input_tokens: 11, |
| 487 | output_tokens: 3, |
| 488 | reasoning_tokens: None, |
| 489 | prompt_cache_hit_tokens: None, |
| 490 | prompt_cache_miss_tokens: None, |
| 491 | prompt_cache_write_tokens: None, |
| 492 | reasoning_replay_tokens: None, |
| 493 | duration_ms: 250, |
| 494 | }; |
| 495 | |
| 496 | let value = exec_stream_value(&event).expect("serializes"); |
| 497 | let parsed = value; |
| 498 | assert_eq!(parsed["type"], "turn_usage"); |
| 499 | assert_eq!(parsed["input_tokens"], 11); |
| 500 | assert_eq!(parsed["output_tokens"], 3); |
| 501 | assert_eq!(parsed["duration_ms"], 250); |
| 502 | let object = parsed.as_object().expect("event object"); |
| 503 | for absent in [ |
| 504 | "reasoning_tokens", |
| 505 | "prompt_cache_hit_tokens", |
| 506 | "prompt_cache_miss_tokens", |
| 507 | "prompt_cache_write_tokens", |
| 508 | "reasoning_replay_tokens", |
| 509 | ] { |
| 510 | assert!(!object.contains_key(absent), "{absent} leaked: {parsed}"); |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | #[test] |
| 515 | fn exec_stream_pre_existing_event_type_tags_are_unchanged() { |
| 516 | // Contract guard for existing stream consumers (bench harness, fleet |
| 517 | // ledger): the pre-turn_usage event vocabulary keeps its exact tags. |
| 518 | let cases: Vec<(ExecStreamEvent, &str)> = vec![ |
| 519 | ( |
| 520 | ExecStreamEvent::Content { |
| 521 | content: "hi".to_string(), |
| 522 | }, |
| 523 | "content", |
| 524 | ), |
| 525 | ( |
| 526 | ExecStreamEvent::ToolUse { |
| 527 | name: "read_file".to_string(), |
| 528 | id: "call_1".to_string(), |
| 529 | input: serde_json::json!({}), |
| 530 | started_at: "2026-08-03T00:00:00Z".to_string(), |
| 531 | }, |
| 532 | "tool_use", |
| 533 | ), |
| 534 | ( |
| 535 | ExecStreamEvent::ToolResult { |
| 536 | id: "call_1".to_string(), |
| 537 | name: "read_file".to_string(), |
| 538 | output: "ok".to_string(), |
| 539 | status: "success".to_string(), |
| 540 | started_at: "2026-08-03T00:00:00Z".to_string(), |
| 541 | completed_at: "2026-08-03T00:00:01Z".to_string(), |
| 542 | duration_ms: 1, |
| 543 | side_effect_status: "unknown".to_string(), |
| 544 | error_category: None, |
| 545 | truncated: None, |
| 546 | artifact: None, |
| 547 | result_metadata: None, |
| 548 | }, |
| 549 | "tool_result", |
| 550 | ), |
| 551 | ( |
| 552 | ExecStreamEvent::SandboxDenied { |
| 553 | tool_id: "call_1".to_string(), |
| 554 | tool_name: "exec_shell".to_string(), |
| 555 | reason: "denied".to_string(), |
| 556 | outcome: "approval_required".to_string(), |
| 557 | }, |
| 558 | "sandbox_denied", |
| 559 | ), |
| 560 | ( |
| 561 | ExecStreamEvent::WorkflowEvent { |
| 562 | run_id: "workflow_1".to_string(), |
| 563 | event: serde_json::json!({"type": "task_completed"}), |
| 564 | }, |
| 565 | "workflow_event", |
| 566 | ), |
| 567 | ( |
| 568 | ExecStreamEvent::SessionCapture { |
| 569 | content: "x".to_string(), |
| 570 | saved_session_id: "session-x".to_string(), |
| 571 | }, |
| 572 | "session_capture", |
| 573 | ), |
| 574 | ( |
| 575 | ExecStreamEvent::Error { |
| 576 | error: "boom".to_string(), |
| 577 | }, |
| 578 | "error", |
| 579 | ), |
| 580 | (ExecStreamEvent::Done, "done"), |
| 581 | ]; |
| 582 | |
| 583 | for (event, expected_type) in cases { |
| 584 | let value = exec_stream_value(&event).expect("serializes"); |
| 585 | assert_eq!(value["type"], expected_type, "event tag drifted"); |
| 586 | assert_eq!(value["schema"], "codewhale.exec-stream"); |
| 587 | assert_eq!(value["schema_version"], 1); |
| 588 | } |
| 589 | } |
| 590 | |
| 591 | #[test] |
| 592 | fn exec_stream_events_are_json_lines() { |
| 593 | let event = ExecStreamEvent::ToolResult { |
| 594 | id: "call_1".to_string(), |
| 595 | name: "read_file".to_string(), |
| 596 | output: "line 1\nline 2".to_string(), |
| 597 | status: "success".to_string(), |
| 598 | started_at: "2026-07-13T00:00:00Z".to_string(), |
| 599 | completed_at: "2026-07-13T00:00:01Z".to_string(), |
| 600 | duration_ms: 1000, |
| 601 | side_effect_status: "not_started".to_string(), |
| 602 | error_category: None, |
| 603 | truncated: Some(false), |
| 604 | artifact: None, |
| 605 | result_metadata: None, |
| 606 | }; |
| 607 | |
| 608 | let value = exec_stream_value(&event).expect("serializes"); |
| 609 | let json = serde_json::to_string(&value).expect("serializes"); |
| 610 | assert!(!json.contains('\n')); |
| 611 | let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); |
| 612 | assert_eq!(parsed["type"], "tool_result"); |
| 613 | assert_eq!(parsed["schema"], "codewhale.exec-stream"); |
| 614 | assert_eq!(parsed["schema_version"], 1); |
| 615 | assert_eq!(parsed["duration_ms"], 1000); |
| 616 | assert_eq!(parsed["side_effect_status"], "not_started"); |
| 617 | } |
| 618 | |
| 619 | #[test] |
| 620 | fn workflow_receipt_stream_event_is_one_json_line() { |
| 621 | let event = ExecStreamEvent::WorkflowEvent { |
| 622 | run_id: "workflow_1234".to_string(), |
| 623 | event: serde_json::json!({ |
| 624 | "type": "handoff_promoted", |
| 625 | "artifact_id": "workflow_1234:agent_1:review-gate:review_report", |
| 626 | "gate_id": "review-gate", |
| 627 | "kind": "review_report", |
| 628 | "from_role": "reviewer", |
| 629 | "to_role": "verifier", |
| 630 | "producer_task_id": "agent_1" |
| 631 | }), |
| 632 | }; |
| 633 | |
| 634 | let value = exec_stream_value(&event).expect("serializes"); |
| 635 | let json = serde_json::to_string(&value).expect("serializes"); |
| 636 | assert!(!json.contains('\n')); |
| 637 | let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); |
| 638 | assert_eq!(parsed["type"], "workflow_event"); |
| 639 | assert_eq!(parsed["schema"], "codewhale.exec-stream"); |
| 640 | assert_eq!(parsed["schema_version"], 1); |
| 641 | assert_eq!(parsed["run_id"], "workflow_1234"); |
| 642 | assert_eq!(parsed["event"]["type"], "handoff_promoted"); |
| 643 | assert_eq!( |
| 644 | parsed["event"]["artifact_id"], |
| 645 | "workflow_1234:agent_1:review-gate:review_report" |
| 646 | ); |
| 647 | assert_eq!(parsed["event"]["gate_id"], "review-gate"); |
| 648 | assert_eq!(parsed["event"]["kind"], "review_report"); |
| 649 | assert_eq!(parsed["event"]["from_role"], "reviewer"); |
| 650 | assert_eq!(parsed["event"]["to_role"], "verifier"); |
| 651 | assert_eq!(parsed["event"]["producer_task_id"], "agent_1"); |
| 652 | assert!(parsed["event"].get("payload").is_none(), "{parsed}"); |
| 653 | |
| 654 | let consumed = ExecStreamEvent::WorkflowEvent { |
| 655 | run_id: "workflow_1234".to_string(), |
| 656 | event: serde_json::json!({ |
| 657 | "type": "handoff_consumed", |
| 658 | "artifact_id": "workflow_1234:agent_1:review-gate:review_report", |
| 659 | "kind": "review_report", |
| 660 | "from_role": "reviewer", |
| 661 | "to_role": "verifier", |
| 662 | "consumer_task_id": "agent_2" |
| 663 | }), |
| 664 | }; |
| 665 | let consumed = exec_stream_value(&consumed).expect("serializes consumed receipt"); |
| 666 | assert_eq!(consumed["type"], "workflow_event"); |
| 667 | assert_eq!(consumed["schema"], "codewhale.exec-stream"); |
| 668 | assert_eq!(consumed["schema_version"], 1); |
| 669 | assert_eq!(consumed["event"]["type"], "handoff_consumed"); |
| 670 | assert_eq!( |
| 671 | consumed["event"]["artifact_id"], |
| 672 | "workflow_1234:agent_1:review-gate:review_report" |
| 673 | ); |
| 674 | assert_eq!(consumed["event"]["consumer_task_id"], "agent_2"); |
| 675 | assert!(consumed["event"].get("payload").is_none(), "{consumed}"); |
| 676 | } |
| 677 | |
| 678 | #[test] |
| 679 | fn exec_stream_metadata_redacts_resume_breadcrumbs() { |
| 680 | let raw_session_id = "abc123fullsecret"; |
| 681 | let event = ExecStreamEvent::Metadata { |
| 682 | meta: Box::new(ExecStreamMeta { |
| 683 | receipt_kind: "terminal", |
| 684 | provider: "deepseek".to_string(), |
| 685 | provider_id: None, |
| 686 | model: "deepseek-v4-flash".to_string(), |
| 687 | route_source: "explicit_or_configured".to_string(), |
| 688 | input_tokens: Some(123), |
| 689 | output_tokens: Some(45), |
| 690 | prompt_cache_hit_tokens: Some(10), |
| 691 | prompt_cache_miss_tokens: None, |
| 692 | prompt_cache_write_tokens: None, |
| 693 | reasoning_tokens: Some(3), |
| 694 | codewhale_max_output_tokens: Some(384_000), |
| 695 | codewhale_max_output_tokens_source: Some("documented"), |
| 696 | duration_ms: 2500, |
| 697 | retry_count: None, |
| 698 | approval_posture: "ask".to_string(), |
| 699 | sandbox_posture: "configured_default".to_string(), |
| 700 | binary_sha256: Some("sha256:binary".to_string()), |
| 701 | config_sha256: None, |
| 702 | prompt_sha256: "sha256:prompt".to_string(), |
| 703 | tool_catalog_sha256: Some("sha256:tools".to_string()), |
| 704 | input_analysis: ExecStreamInputAnalysis::default(), |
| 705 | visible_final_answer_chars: 17, |
| 706 | visible_final_answer_excerpt: "the visible reply".to_string(), |
| 707 | session_id: exec_stream_session_ref(raw_session_id), |
| 708 | resume_command: exec_stream_resume_hint(raw_session_id), |
| 709 | workspace: "/tmp/work".to_string(), |
| 710 | message_count: 4, |
| 711 | status: Some("completed".to_string()), |
| 712 | termination_reason: Some("resolved".to_string()), |
| 713 | error_category: None, |
| 714 | error: None, |
| 715 | }), |
| 716 | }; |
| 717 | |
| 718 | let json = serde_json::to_string(&event).expect("serializes"); |
| 719 | assert!(!json.contains('\n')); |
| 720 | assert!(!json.contains(raw_session_id)); |
| 721 | let parsed: serde_json::Value = serde_json::from_str(&json).expect("valid json"); |
| 722 | assert_eq!(parsed["type"], "metadata"); |
| 723 | assert_ne!(parsed["meta"]["session_id"], raw_session_id); |
| 724 | assert!( |
| 725 | parsed["meta"]["session_id"] |
| 726 | .as_str() |
| 727 | .unwrap() |
| 728 | .starts_with("<redacted:") |
| 729 | ); |
| 730 | assert_eq!( |
| 731 | parsed["meta"]["resume_command"], |
| 732 | "codewhale exec --resume <session_capture.saved_session_id>" |
| 733 | ); |
| 734 | assert_eq!(parsed["meta"]["workspace"], "/tmp/work"); |
| 735 | assert_eq!(parsed["meta"]["message_count"], 4); |
| 736 | assert_eq!(parsed["meta"]["visible_final_answer_chars"], 17); |
| 737 | assert_eq!( |
| 738 | parsed["meta"]["visible_final_answer_excerpt"], |
| 739 | "the visible reply" |
| 740 | ); |
| 741 | |
| 742 | // Contract (#5946): the raw saved-session id is carried by exactly one |
| 743 | // field, `session_capture.saved_session_id`. The `metadata` receipt |
| 744 | // above stays fingerprint-only, and the capture's own `content` keeps |
| 745 | // the same fingerprint so both surfaces can be correlated in a log. |
| 746 | let capture = ExecStreamEvent::SessionCapture { |
| 747 | content: exec_stream_session_ref(raw_session_id), |
| 748 | saved_session_id: raw_session_id.to_string(), |
| 749 | }; |
| 750 | let capture_json = serde_json::to_string(&capture).expect("serializes"); |
| 751 | let parsed_capture: serde_json::Value = |
| 752 | serde_json::from_str(&capture_json).expect("valid json"); |
| 753 | assert_eq!(parsed_capture["type"], "session_capture"); |
| 754 | assert_eq!(parsed_capture["content"], parsed["meta"]["session_id"]); |
| 755 | assert_ne!(parsed_capture["content"], raw_session_id); |
| 756 | assert_eq!(parsed_capture["saved_session_id"], raw_session_id); |
| 757 | assert!(parsed_capture.get("session_id").is_none(), "{capture_json}"); |
| 758 | } |
| 759 | |
| 760 | #[test] |
| 761 | fn exec_stream_final_answer_excerpt_is_bounded_and_redacted() { |
| 762 | assert_eq!( |
| 763 | exec_stream_final_answer_excerpt(" short reply \n"), |
| 764 | "short reply" |
| 765 | ); |
| 766 | let long = "x".repeat(EXEC_STREAM_FINAL_ANSWER_EXCERPT_CHARS + 5); |
| 767 | let excerpt = exec_stream_final_answer_excerpt(&long); |
| 768 | assert_eq!( |
| 769 | excerpt.chars().count(), |
| 770 | EXEC_STREAM_FINAL_ANSWER_EXCERPT_CHARS + 3 |
| 771 | ); |
| 772 | assert!(excerpt.ends_with("...")); |
| 773 | let leaked = exec_stream_final_answer_excerpt("token: sk-ant-must-not-leak-1234567890"); |
| 774 | assert!(!leaked.contains("sk-ant-must-not-leak"), "{leaked}"); |
| 775 | } |
| 776 | |
| 777 | #[test] |
| 778 | fn exec_stream_final_answer_text_is_the_last_assistant_reply() { |
| 779 | // Multi-step turn: pre-tool commentary, a tool result, then a |
| 780 | // distinct final answer. The receipt must carry only the final |
| 781 | // reply, not the cumulative stream output. |
| 782 | let messages = vec![ |
| 783 | Message { |
| 784 | role: Role::User, |
| 785 | content: vec![ContentBlock::Text { |
| 786 | text: "write the report".to_string(), |
| 787 | cache_control: None, |
| 788 | }], |
| 789 | }, |
| 790 | Message { |
| 791 | role: Role::Assistant, |
| 792 | content: vec![ContentBlock::Text { |
| 793 | text: "let me check the workspace first".to_string(), |
| 794 | cache_control: None, |
| 795 | }], |
| 796 | }, |
| 797 | Message { |
| 798 | role: Role::User, |
| 799 | content: vec![ContentBlock::ToolResult { |
| 800 | execution_id: None, |
| 801 | tool_use_id: "call-1".to_string(), |
| 802 | content: "listed files".to_string(), |
| 803 | is_error: Some(false), |
| 804 | content_blocks: None, |
| 805 | }], |
| 806 | }, |
| 807 | Message { |
| 808 | role: Role::Assistant, |
| 809 | content: vec![ |
| 810 | ContentBlock::thinking("final reasoning"), |
| 811 | ContentBlock::Text { |
| 812 | text: "the final report".to_string(), |
| 813 | cache_control: None, |
| 814 | }, |
| 815 | ], |
| 816 | }, |
| 817 | ]; |
| 818 | assert_eq!( |
| 819 | exec_stream_final_answer_text(&messages, true), |
| 820 | Some("the final report".to_string()) |
| 821 | ); |
| 822 | } |
| 823 | |
| 824 | #[test] |
| 825 | fn exec_stream_final_answer_text_never_reuses_a_resumed_turn_reply() { |
| 826 | let mut messages = vec![ |
| 827 | Message { |
| 828 | role: Role::User, |
| 829 | content: vec![ContentBlock::Text { |
| 830 | text: "old prompt".into(), |
| 831 | cache_control: None, |
| 832 | }], |
| 833 | }, |
| 834 | Message { |
| 835 | role: Role::Assistant, |
| 836 | content: vec![ContentBlock::Text { |
| 837 | text: "old reply must not be delivered again".into(), |
| 838 | cache_control: None, |
| 839 | }], |
| 840 | }, |
| 841 | ]; |
| 842 | // The synchronization event can precede acceptance of the new prompt. |
| 843 | assert_eq!(exec_stream_final_answer_text(&messages, false), None); |
| 844 | messages.push(Message { |
| 845 | role: Role::User, |
| 846 | content: vec![ContentBlock::Text { |
| 847 | text: "new prompt that fails before an answer".into(), |
| 848 | cache_control: None, |
| 849 | }], |
| 850 | }); |
| 851 | assert_eq!(exec_stream_final_answer_text(&messages, true), None); |
| 852 | messages.push(Message { |
| 853 | role: Role::InterruptedAssistant, |
| 854 | content: vec![ContentBlock::Text { |
| 855 | text: "current partial reply".into(), |
| 856 | cache_control: None, |
| 857 | }], |
| 858 | }); |
| 859 | assert_eq!( |
| 860 | exec_stream_final_answer_text(&messages, true).as_deref(), |
| 861 | Some("current partial reply") |
| 862 | ); |
| 863 | // Tool results are user-role records inside this same turn. |
| 864 | messages.push(Message { |
| 865 | role: Role::User, |
| 866 | content: vec![ContentBlock::ToolResult { |
| 867 | execution_id: None, |
| 868 | tool_use_id: "call-current".into(), |
| 869 | content: "result".into(), |
| 870 | is_error: None, |
| 871 | content_blocks: None, |
| 872 | }], |
| 873 | }); |
| 874 | assert_eq!( |
| 875 | exec_stream_final_answer_text(&messages, true).as_deref(), |
| 876 | Some("current partial reply") |
| 877 | ); |
| 878 | } |
| 879 | |
| 880 | #[test] |
| 881 | fn exec_stream_final_answer_text_requires_assistant_text() { |
| 882 | assert_eq!(exec_stream_final_answer_text(&[], true), None); |
| 883 | let user_only = vec![Message { |
| 884 | role: Role::User, |
| 885 | content: vec![ContentBlock::Text { |
| 886 | text: "prompt".to_string(), |
| 887 | cache_control: None, |
| 888 | }], |
| 889 | }]; |
| 890 | assert_eq!(exec_stream_final_answer_text(&user_only, true), None); |
| 891 | let textless_assistant = vec![Message { |
| 892 | role: Role::Assistant, |
| 893 | content: vec![ContentBlock::thinking("reasoning only")], |
| 894 | }]; |
| 895 | assert_eq!( |
| 896 | exec_stream_final_answer_text(&textless_assistant, true), |
| 897 | None |
| 898 | ); |
| 899 | } |
| 900 | |
| 901 | #[test] |
| 902 | fn exec_stream_input_analysis_reports_prompt_composition() { |
| 903 | let system = SystemPrompt::Text("system rules".to_string()); |
| 904 | let messages = vec![ |
| 905 | Message { |
| 906 | role: Role::User, |
| 907 | content: vec![ContentBlock::Text { |
| 908 | text: "run tests".to_string(), |
| 909 | cache_control: None, |
| 910 | }], |
| 911 | }, |
| 912 | Message { |
| 913 | role: Role::Assistant, |
| 914 | content: vec![ |
| 915 | ContentBlock::thinking("checking context"), |
| 916 | ContentBlock::Text { |
| 917 | text: "working".to_string(), |
| 918 | cache_control: None, |
| 919 | }, |
| 920 | ContentBlock::ToolUse { |
| 921 | execution_id: None, |
| 922 | id: "call-1".to_string(), |
| 923 | name: "exec_shell".to_string(), |
| 924 | input: serde_json::json!({"command": "cargo test"}), |
| 925 | caller: None, |
| 926 | thought_signature: None, |
| 927 | }, |
| 928 | ], |
| 929 | }, |
| 930 | Message { |
| 931 | role: Role::User, |
| 932 | content: vec![ContentBlock::ToolResult { |
| 933 | execution_id: None, |
| 934 | tool_use_id: "call-1".to_string(), |
| 935 | content: "stdout line\nstderr line".to_string(), |
| 936 | is_error: Some(false), |
| 937 | content_blocks: Some(vec![serde_json::json!({ |
| 938 | "type": "text", |
| 939 | "text": "structured output" |
| 940 | })]), |
| 941 | }], |
| 942 | }, |
| 943 | ]; |
| 944 | |
| 945 | let analysis = exec_stream_input_analysis(&messages, Some(&system)); |
| 946 | |
| 947 | assert_eq!(analysis.user_message_count, 2); |
| 948 | assert_eq!(analysis.assistant_message_count, 1); |
| 949 | assert_eq!(analysis.tool_message_count, 0); |
| 950 | assert_eq!(analysis.tool_use_count, 1); |
| 951 | assert_eq!(analysis.tool_result_count, 1); |
| 952 | assert_eq!(analysis.thinking_chars, "checking context".chars().count()); |
| 953 | assert!(analysis.text_chars >= "run testsworking".chars().count()); |
| 954 | assert!(analysis.tool_use_input_chars > 0); |
| 955 | assert!(analysis.tool_result_chars >= "stdout line\nstderr line".chars().count()); |
| 956 | assert!(analysis.estimated_system_tokens > 0); |
| 957 | assert!(analysis.estimated_message_content_tokens > 0); |
| 958 | assert!( |
| 959 | analysis.estimated_request_tokens |
| 960 | >= analysis.estimated_system_tokens |
| 961 | + analysis.estimated_message_content_tokens |
| 962 | + analysis.estimated_framing_tokens |
| 963 | ); |
| 964 | } |
| 965 | |
| 966 | #[test] |
| 967 | fn review_receipt_check_public_json_omits_private_details() { |
| 968 | let validation = crate::tools::review::ReviewReceiptValidation { |
| 969 | passed: false, |
| 970 | reason: "secret reason with /tmp/private/receipt.json".to_string(), |
| 971 | diff_fingerprint: "sha256:current".to_string(), |
| 972 | receipt_fingerprint: Some("sha256:current".to_string()), |
| 973 | receipt_path: Some(PathBuf::from("/tmp/private/receipt.json")), |
| 974 | unresolved_risk: Some(crate::tools::review::ReviewReceiptRisk { |
| 975 | unresolved: true, |
| 976 | level: "error".to_string(), |
| 977 | summary: "secret summary".to_string(), |
| 978 | }), |
| 979 | }; |
| 980 | |
| 981 | let public = review_receipt_validation_public_json(&validation); |
| 982 | let encoded = serde_json::to_string(&public).expect("public json"); |
| 983 | |
| 984 | assert_eq!(public["passed"], false); |
| 985 | assert_eq!(public["status"], "unresolved_risk"); |
| 986 | assert_eq!(public["risk_level"], "error"); |
| 987 | assert!(!encoded.contains("secret")); |
| 988 | assert!(!encoded.contains("/tmp/private")); |
| 989 | } |
| 990 | |
| 991 | #[test] |
| 992 | fn exec_text_session_breadcrumbs_use_compact_ids() { |
| 993 | let session_id = "1234567890abcdef"; |
| 994 | |
| 995 | assert_eq!(exec_saved_session_line(session_id), "session: 12345678"); |
| 996 | assert_eq!( |
| 997 | exec_resumed_session_line(session_id), |
| 998 | "resumed session: 12345678" |
| 999 | ); |
| 1000 | assert!(!exec_saved_session_line(session_id).contains(session_id)); |
| 1001 | assert!(!exec_resumed_session_line(session_id).contains(session_id)); |
| 1002 | } |
| 1003 | |
| 1004 | #[test] |
| 1005 | fn alternate_screen_defaults_on_in_auto_mode() { |
| 1006 | let cli = parse_cli(&["codewhale"]); |
| 1007 | let config = Config::default(); |
| 1008 | |
| 1009 | assert_eq!(startup_screen_mode(&cli, &config), ScreenMode::Fullscreen); |
| 1010 | } |
| 1011 | |
| 1012 | #[test] |
| 1013 | fn screen_mode_round_trips_through_its_own_vocabulary() { |
| 1014 | for mode in [ScreenMode::Fullscreen, ScreenMode::Inline] { |
| 1015 | assert_eq!(ScreenMode::parse(mode.as_str()), Some(mode)); |
| 1016 | } |
| 1017 | // The `tui.alternate_screen` words the config file already documents. |
| 1018 | assert_eq!(ScreenMode::parse("auto"), Some(ScreenMode::Fullscreen)); |
| 1019 | assert_eq!(ScreenMode::parse("always"), Some(ScreenMode::Fullscreen)); |
| 1020 | assert_eq!(ScreenMode::parse("never"), Some(ScreenMode::Inline)); |
| 1021 | assert_eq!(ScreenMode::parse(" INLINE "), Some(ScreenMode::Inline)); |
| 1022 | assert_eq!(ScreenMode::parse("sideways"), None); |
| 1023 | assert!(ScreenMode::Fullscreen.uses_alt_screen()); |
| 1024 | assert!(!ScreenMode::Inline.uses_alt_screen()); |
| 1025 | } |
| 1026 | |
| 1027 | #[test] |
| 1028 | fn removed_no_alt_screen_flag_is_rejected() { |
| 1029 | // Negative test: the retired compatibility flag must not be silently |
| 1030 | // accepted and must not reach the alternate-screen decision at all. |
| 1031 | let error = Cli::try_parse_from(["codewhale", "--no-alt-screen"]) |
| 1032 | .expect_err("--no-alt-screen must no longer parse"); |
| 1033 | assert_eq!( |
| 1034 | error.kind(), |
| 1035 | clap::error::ErrorKind::UnknownArgument, |
| 1036 | "retired flag should fail as an unknown argument, not be absorbed" |
| 1037 | ); |
| 1038 | } |
| 1039 | |
| 1040 | #[test] |
| 1041 | fn config_never_selects_the_inline_screen() { |
| 1042 | let cli = parse_cli(&["codewhale"]); |
| 1043 | let config = Config { |
| 1044 | tui: Some(crate::config::TuiConfig { |
| 1045 | alternate_screen: Some("never".to_string()), |
| 1046 | mouse_capture: None, |
| 1047 | selection_copy_markdown: None, |
| 1048 | stream_chunk_timeout_secs: None, |
| 1049 | max_model_steps: None, |
| 1050 | turn_wall_clock_secs: None, |
| 1051 | stream_max_content_mb: None, |
| 1052 | stream_max_duration_secs: None, |
| 1053 | stream_max_resumes: None, |
| 1054 | stream_max_transparent_retries: None, |
| 1055 | stream_max_errors: None, |
| 1056 | stream_open_timeout_secs: None, |
| 1057 | connect_timeout_secs: None, |
| 1058 | force_http1: None, |
| 1059 | status_items: None, |
| 1060 | posture_bar: None, |
| 1061 | metrics_line: None, |
| 1062 | osc8_links: None, |
| 1063 | composer_arrows_scroll: None, |
| 1064 | notification_condition: None, |
| 1065 | }), |
| 1066 | ..Config::default() |
| 1067 | }; |
| 1068 | |
| 1069 | assert_eq!(startup_screen_mode(&cli, &config), ScreenMode::Inline); |
| 1070 | } |
| 1071 | |
| 1072 | #[test] |
| 1073 | #[cfg(not(windows))] |
| 1074 | fn mouse_capture_defaults_on_when_alternate_screen_is_active() { |
| 1075 | let cli = parse_cli(&["codewhale"]); |
| 1076 | let config = Config::default(); |
| 1077 | |
| 1078 | assert!(should_use_mouse_capture_with( |
| 1079 | &cli, &config, true, None, None, None |
| 1080 | )); |
| 1081 | } |
| 1082 | |
| 1083 | #[test] |
| 1084 | #[cfg(windows)] |
| 1085 | fn mouse_capture_defaults_off_on_legacy_windows_console() { |
| 1086 | // Legacy conhost (no `WT_SESSION` and no `ConEmuPID`) keeps the |
| 1087 | // v0.8.x default-off behavior: mouse-mode reporting on legacy console |
| 1088 | // can leak SGR escapes into the composer. |
| 1089 | let cli = parse_cli(&["codewhale"]); |
| 1090 | let config = Config::default(); |
| 1091 | |
| 1092 | assert!(!should_use_mouse_capture_with( |
| 1093 | &cli, &config, true, None, None, None |
| 1094 | )); |
| 1095 | } |
| 1096 | |
| 1097 | // #1169: Windows Terminal sets `WT_SESSION` and handles mouse-mode |
| 1098 | // reporting cleanly, so default-on there gives users in-app text |
| 1099 | // selection (and the side-effect of clamping selection to the |
| 1100 | // transcript region instead of the terminal painting across the |
| 1101 | // sidebar via native selection). |
| 1102 | #[test] |
| 1103 | #[cfg(windows)] |
| 1104 | fn mouse_capture_defaults_on_in_windows_terminal() { |
| 1105 | let cli = parse_cli(&["codewhale"]); |
| 1106 | let config = Config::default(); |
| 1107 | |
| 1108 | assert!(should_use_mouse_capture_with( |
| 1109 | &cli, |
| 1110 | &config, |
| 1111 | true, |
| 1112 | None, |
| 1113 | Some("{a3a3b3a8-aa00-0000-0000-000000000000}"), |
| 1114 | None, |
| 1115 | )); |
| 1116 | } |
| 1117 | |
| 1118 | // ConEmu/Cmder sets `ConEmuPID` and handles VT mouse-mode reporting |
| 1119 | // cleanly; default mouse capture on there so users get in-app scrolling. |
| 1120 | #[test] |
| 1121 | #[cfg(windows)] |
| 1122 | fn mouse_capture_defaults_on_in_conemu() { |
| 1123 | let cli = parse_cli(&["codewhale"]); |
| 1124 | let config = Config::default(); |
| 1125 | |
| 1126 | assert!(should_use_mouse_capture_with( |
| 1127 | &cli, |
| 1128 | &config, |
| 1129 | true, |
| 1130 | None, |
| 1131 | None, |
| 1132 | Some("12345"), |
| 1133 | )); |
| 1134 | } |
| 1135 | |
| 1136 | #[test] |
| 1137 | fn no_mouse_capture_flag_disables_mouse_capture() { |
| 1138 | let cli = parse_cli(&["codewhale", "--no-mouse-capture"]); |
| 1139 | let config = Config::default(); |
| 1140 | |
| 1141 | assert!(!should_use_mouse_capture_with( |
| 1142 | &cli, &config, true, None, None, None |
| 1143 | )); |
| 1144 | } |
| 1145 | |
| 1146 | #[test] |
| 1147 | fn config_can_disable_default_mouse_capture() { |
| 1148 | let cli = parse_cli(&["codewhale"]); |
| 1149 | let config = Config { |
| 1150 | tui: Some(crate::config::TuiConfig { |
| 1151 | alternate_screen: None, |
| 1152 | mouse_capture: Some(false), |
| 1153 | selection_copy_markdown: None, |
| 1154 | stream_chunk_timeout_secs: None, |
| 1155 | max_model_steps: None, |
| 1156 | turn_wall_clock_secs: None, |
| 1157 | stream_max_content_mb: None, |
| 1158 | stream_max_duration_secs: None, |
| 1159 | stream_max_resumes: None, |
| 1160 | stream_max_transparent_retries: None, |
| 1161 | stream_max_errors: None, |
| 1162 | stream_open_timeout_secs: None, |
| 1163 | connect_timeout_secs: None, |
| 1164 | force_http1: None, |
| 1165 | status_items: None, |
| 1166 | posture_bar: None, |
| 1167 | metrics_line: None, |
| 1168 | osc8_links: None, |
| 1169 | composer_arrows_scroll: None, |
| 1170 | notification_condition: None, |
| 1171 | }), |
| 1172 | ..Config::default() |
| 1173 | }; |
| 1174 | |
| 1175 | assert!(!should_use_mouse_capture_with( |
| 1176 | &cli, &config, true, None, None, None |
| 1177 | )); |
| 1178 | } |
| 1179 | |
| 1180 | #[test] |
| 1181 | fn mouse_capture_flag_enables_mouse_capture() { |
| 1182 | let cli = parse_cli(&["codewhale", "--mouse-capture"]); |
| 1183 | let config = Config::default(); |
| 1184 | |
| 1185 | assert!(should_use_mouse_capture_with( |
| 1186 | &cli, &config, true, None, None, None |
| 1187 | )); |
| 1188 | } |
| 1189 | |
| 1190 | #[test] |
| 1191 | fn config_can_enable_mouse_capture() { |
| 1192 | let cli = parse_cli(&["codewhale"]); |
| 1193 | let config = Config { |
| 1194 | tui: Some(crate::config::TuiConfig { |
| 1195 | alternate_screen: None, |
| 1196 | mouse_capture: Some(true), |
| 1197 | selection_copy_markdown: None, |
| 1198 | stream_chunk_timeout_secs: None, |
| 1199 | max_model_steps: None, |
| 1200 | turn_wall_clock_secs: None, |
| 1201 | stream_max_content_mb: None, |
| 1202 | stream_max_duration_secs: None, |
| 1203 | stream_max_resumes: None, |
| 1204 | stream_max_transparent_retries: None, |
| 1205 | stream_max_errors: None, |
| 1206 | stream_open_timeout_secs: None, |
| 1207 | connect_timeout_secs: None, |
| 1208 | force_http1: None, |
| 1209 | status_items: None, |
| 1210 | posture_bar: None, |
| 1211 | metrics_line: None, |
| 1212 | osc8_links: None, |
| 1213 | composer_arrows_scroll: None, |
| 1214 | notification_condition: None, |
| 1215 | }), |
| 1216 | ..Config::default() |
| 1217 | }; |
| 1218 | |
| 1219 | assert!(should_use_mouse_capture_with( |
| 1220 | &cli, &config, true, None, None, None |
| 1221 | )); |
| 1222 | } |
| 1223 | |
| 1224 | #[test] |
| 1225 | fn mouse_capture_is_off_without_alternate_screen() { |
| 1226 | let cli = parse_cli(&["codewhale", "--mouse-capture"]); |
| 1227 | let config = Config::default(); |
| 1228 | |
| 1229 | assert!(!should_use_mouse_capture_with( |
| 1230 | &cli, &config, false, None, None, None |
| 1231 | )); |
| 1232 | } |
| 1233 | |
| 1234 | // Issue #878 / #898: JetBrains JediTerm advertises mouse support but |
| 1235 | // forwards SGR mouse-event escapes as raw input characters, producing |
| 1236 | // the "input box auto-fills with garbled characters when I move the |
| 1237 | // mouse" failure mode in PyCharm/IDEA terminals. Default the capture |
| 1238 | // off when we see TERMINAL_EMULATOR=JetBrains-JediTerm; explicit |
| 1239 | // config / --mouse-capture still wins. |
| 1240 | |
| 1241 | #[test] |
| 1242 | fn mouse_capture_defaults_off_in_jetbrains_jediterm() { |
| 1243 | let cli = parse_cli(&["codewhale"]); |
| 1244 | let config = Config::default(); |
| 1245 | |
| 1246 | assert!(!should_use_mouse_capture_with( |
| 1247 | &cli, |
| 1248 | &config, |
| 1249 | true, |
| 1250 | Some("JetBrains-JediTerm"), |
| 1251 | None, |
| 1252 | None, |
| 1253 | )); |
| 1254 | } |
| 1255 | |
| 1256 | #[test] |
| 1257 | fn jetbrains_default_off_is_case_insensitive() { |
| 1258 | let cli = parse_cli(&["codewhale"]); |
| 1259 | let config = Config::default(); |
| 1260 | |
| 1261 | // JetBrains has occasionally varied the casing across releases; |
| 1262 | // a case-insensitive match keeps the protection in place. |
| 1263 | assert!(!should_use_mouse_capture_with( |
| 1264 | &cli, |
| 1265 | &config, |
| 1266 | true, |
| 1267 | Some("jetbrains-jediterm"), |
| 1268 | None, |
| 1269 | None, |
| 1270 | )); |
| 1271 | } |
| 1272 | |
| 1273 | #[test] |
| 1274 | fn mouse_capture_flag_overrides_jetbrains_default() { |
| 1275 | let cli = parse_cli(&["codewhale", "--mouse-capture"]); |
| 1276 | let config = Config::default(); |
| 1277 | |
| 1278 | assert!(should_use_mouse_capture_with( |
| 1279 | &cli, |
| 1280 | &config, |
| 1281 | true, |
| 1282 | Some("JetBrains-JediTerm"), |
| 1283 | None, |
| 1284 | None, |
| 1285 | )); |
| 1286 | } |
| 1287 | |
| 1288 | #[test] |
| 1289 | fn config_mouse_capture_true_overrides_jetbrains_default() { |
| 1290 | let cli = parse_cli(&["codewhale"]); |
| 1291 | let config = Config { |
| 1292 | tui: Some(crate::config::TuiConfig { |
| 1293 | alternate_screen: None, |
| 1294 | mouse_capture: Some(true), |
| 1295 | selection_copy_markdown: None, |
| 1296 | stream_chunk_timeout_secs: None, |
| 1297 | max_model_steps: None, |
| 1298 | turn_wall_clock_secs: None, |
| 1299 | stream_max_content_mb: None, |
| 1300 | stream_max_duration_secs: None, |
| 1301 | stream_max_resumes: None, |
| 1302 | stream_max_transparent_retries: None, |
| 1303 | stream_max_errors: None, |
| 1304 | stream_open_timeout_secs: None, |
| 1305 | connect_timeout_secs: None, |
| 1306 | force_http1: None, |
| 1307 | status_items: None, |
| 1308 | posture_bar: None, |
| 1309 | metrics_line: None, |
| 1310 | osc8_links: None, |
| 1311 | composer_arrows_scroll: None, |
| 1312 | notification_condition: None, |
| 1313 | }), |
| 1314 | ..Config::default() |
| 1315 | }; |
| 1316 | |
| 1317 | assert!(should_use_mouse_capture_with( |
| 1318 | &cli, |
| 1319 | &config, |
| 1320 | true, |
| 1321 | Some("JetBrains-JediTerm"), |
| 1322 | None, |
| 1323 | None, |
| 1324 | )); |
| 1325 | } |
| 1326 |