返回 CodeWhale
terminal_mode_test_cases_01.rs
根目录 / crates / tui / src / lib / terminal_mode_test_cases_01.rs
1 fn parse_cli(args: &[&str]) -> Cli {
2 Cli::try_parse_from(args).expect("CLI args should parse")
3 }
4
5 #[test]
6 fn worker_command_policy_prompt_that_looks_like_a_flag_parses() {
7 use crate::fleet::executor::build_worker_exec_command;
8 use codewhale_config::FleetExecConfig;
9 use codewhale_protocol::fleet::FleetTaskSpec;
10
11 let task: FleetTaskSpec = serde_json::from_value(serde_json::json!({
12 "id": "t1",
13 "name": "Smoke",
14 "objective": "prove it runs",
15 "instructions": "audit",
16 "worker": { "role": "reviewer", "tool_profile": "read-only" }
17 }))
18 .unwrap();
19
20 // A Markdown bullet list, and a policy that reads exactly like one of
21 // exec's own flags; the latter makes clap reject a split
22 // `--append-system-prompt <value>` pair.
23 for policy in ["- Never push to main\n- Never touch .git/config", "--hooks"] {
24 let exec = FleetExecConfig {
25 append_system_prompt: policy.to_string(),
26 ..FleetExecConfig::default()
27 };
28 let cmd = build_worker_exec_command("codewhale", &task, &exec, None);
29 let cli = Cli::try_parse_from(std::iter::once("codewhale".to_string()).chain(cmd.args))
30 .unwrap_or_else(|e| panic!("{policy:?}: {e}"));
31 let Some(Commands::Exec(args)) = cli.command else {
32 panic!("expected exec command");
33 };
34 assert_eq!(args.append_system_prompt.as_deref(), Some(policy));
35 assert!(
36 args.prompt.last().is_some_and(|p| p.contains("audit")),
37 "{policy}"
38 );
39 assert!(!args.hooks, "{policy:?} must stay text, not a flag");
40 }
41 }
42
43 #[test]
44 fn sessions_archive_cli_keeps_legacy_listing_and_export_options() {
45 let legacy = parse_cli(&["codewhale", "sessions", "--limit", "7", "--search", "work"]);
46 assert!(
47 matches!(legacy.command, Some(Commands::Sessions { limit: 7, search: Some(ref s), command: None }) if s == "work")
48 );
49 let list = parse_cli(&["codewhale", "sessions", "list", "--limit", "4"]);
50 assert!(matches!(
51 list.command,
52 Some(Commands::Sessions {
53 command: Some(SessionsCommand::List { limit: 4, .. }),
54 ..
55 })
56 ));
57 let export = parse_cli(&[
58 "codewhale",
59 "sessions",
60 "export",
61 "abc123",
62 "--output",
63 "session.tar.xz",
64 "--skip-artifacts",
65 "--force",
66 "--compression",
67 "0",
68 ]);
69 assert!(
70 matches!(export.command, Some(Commands::Sessions { command: Some(SessionsCommand::Export { ref id, output: Some(ref output), skip_artifacts: true, compression: 0, force: true }), .. }) if id == "abc123" && output == Path::new("session.tar.xz"))
71 );
72 }
73
74 #[test]
75 fn headless_consultant_authority_overrides_network_allow_and_disables_web_search() {
76 let config = Config {
77 network: Some(crate::config::NetworkPolicyToml {
78 default: "allow".to_string(),
79 audit: false,
80 ..crate::config::NetworkPolicyToml::default()
81 }),
82 ..Config::default()
83 };
84 let authority = crate::tools::spec::ToolAuthorityEnvelope {
85 schema_version: 1,
86 owner: "consultant-1".to_string(),
87 authority: crate::tools::spec::ToolMutationAuthority::ReadOnly,
88 network_access: Some(false),
89 shell: crate::tools::spec::ToolShellAuthority::None,
90 verification: crate::tools::spec::ToolVerificationAuthority::None,
91 writable_roots: Vec::new(),
92 writable_files: Vec::new(),
93 coordination_contracts: Vec::new(),
94 }
95 .normalized()
96 .expect("Consultant authority");
97
98 let policy = exec_network_policy(&config, authority.network_access)
99 .expect("explicit network=false always installs a policy");
100 assert_eq!(
101 policy.evaluate("example.com", "web_search"),
102 crate::network_policy::Decision::Deny,
103 "the permissive user config must not widen Consultant network authority"
104 );
105 let mut features = crate::features::Features::default();
106 features.enable(crate::features::Feature::ShellTool);
107 features.enable(crate::features::Feature::WebSearch);
108 apply_fleet_engine_feature_caps(
109 &mut features,
110 true,
111 authority.network_access,
112 authority.shell,
113 );
114 assert!(!features.enabled(crate::features::Feature::WebSearch));
115 assert!(!features.enabled(crate::features::Feature::ShellTool));
116
117 let worker_policy = exec_network_policy(&config, Some(true)).expect("configured policy");
118 assert_eq!(
119 worker_policy.evaluate("example.com", "web_search"),
120 crate::network_policy::Decision::Allow,
121 "a network-capable role keeps the configured policy"
122 );
123 }
124 #[test]
125 fn hidden_remote_control_flag_starts_the_interactive_handoff() {
126 let cli = parse_cli(&["codewhale-tui", "--remote-control"]);
127 assert!(cli.remote_control);
128 }
129
130 #[test]
131 fn plugin_registry_discovery_is_route_independent_and_read_only() {
132 let _env_lock = crate::test_support::lock_test_env();
133 let temp = tempfile::tempdir().unwrap();
134 let workspace = temp.path().join("workspace");
135 let codewhale_home = temp.path().join("home");
136 std::fs::create_dir_all(&workspace).unwrap();
137 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
138 let workspace_arg = workspace.to_string_lossy().into_owned();
139
140 for route in [
141 Vec::<&str>::new(),
142 vec!["resume", "--last"],
143 vec!["fork", "--last"],
144 vec!["exec", "hello"],
145 vec!["serve", "--mcp"],
146 ] {
147 let mut args = vec![
148 "codewhale-tui".to_string(),
149 "--workspace".to_string(),
150 workspace_arg.clone(),
151 ];
152 args.extend(route.into_iter().map(str::to_string));
153 let cli = Cli::try_parse_from(args).expect("route should parse");
154 let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
155 let registry = discovery
156 .registry_for_workspace(cli.workspace.as_deref().unwrap_or(workspace.as_path()));
157 assert_eq!(registry.workspace(), workspace.as_path());
158 assert!(
159 !codewhale_home.join("plugins/state.json").exists(),
160 "startup discovery must remain read-only"
161 );
162 }
163 }
164
165 fn custom_exec_config(active: &str) -> Config {
166 let mut custom = std::collections::HashMap::new();
167 for (name, base_url, model) in [
168 (
169 "custom-a",
170 "http://127.0.0.1:18181/v1",
171 crate::config::ZAI_GLM_5_2_MODEL,
172 ),
173 ("custom-b", "http://127.0.0.1:18182/v1", "model-b"),
174 ] {
175 custom.insert(
176 name.to_string(),
177 crate::config::ProviderConfig {
178 kind: Some("openai-compatible".to_string()),
179 base_url: Some(base_url.to_string()),
180 model: Some(model.to_string()),
181 api_key: Some("local-test-key".to_string()),
182 ..Default::default()
183 },
184 );
185 }
186 Config {
187 provider: Some(active.to_string()),
188 providers: Some(crate::config::ProvidersConfig {
189 custom,
190 ..Default::default()
191 }),
192 ..Default::default()
193 }
194 }
195
196 #[test]
197 fn doctor_json_surfaces_keep_exact_named_custom_provider() {
198 let config = custom_exec_config("custom-a");
199 let workspace = tempfile::tempdir().expect("doctor workspace");
200
201 let operate = doctor_operate_fleet_report_json(&config, workspace.path());
202 let provider_model = doctor_provider_model_report_json(&config);
203 let capability = provider_capability_report(&config);
204 let route = doctor_route_report(&config);
205
206 assert_eq!(operate["provider"]["id"], "custom-a");
207 assert_eq!(provider_model["provider"]["id"], "custom-a");
208 assert_eq!(capability["resolved_provider"], "custom-a");
209 assert_eq!(route["provider"], "custom-a");
210 assert_eq!(route["provider_config_table"], "providers.custom-a");
211 let serialized = serde_json::to_string(&serde_json::json!({
212 "operate": operate,
213 "provider_model": provider_model,
214 "capability": capability,
215 "route": route,
216 }))
217 .expect("doctor JSON");
218 assert!(!serialized.contains("local-test-key"));
219 }
220
221 #[test]
222 fn doctor_operate_fleet_json_lists_multi_layer_profile_paths() {
223 // #5098: doctor must name the winning layer and every losing path
224 // when project and personal both define the same id.
225 let _env_lock = crate::test_support::lock_test_env();
226 let tmp = tempfile::TempDir::new().expect("tempdir");
227 let home = tmp.path().join("home");
228 let workspace = tmp.path().join("workspace");
229 let personal = home.join("agents");
230 let project = workspace.join(".codewhale").join("agents");
231 std::fs::create_dir_all(&personal).expect("personal agents");
232 std::fs::create_dir_all(&project).expect("project agents");
233 std::fs::write(
234 personal.join("builder.toml"),
235 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-flash\"\n",
236 )
237 .expect("personal builder");
238 std::fs::write(
239 project.join("builder.toml"),
240 "id = \"builder\"\nrole_hint = \"builder\"\nmodel = \"deepseek-v4-pro\"\n",
241 )
242 .expect("project builder");
243 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
244
245 let operate = doctor_operate_fleet_report_json(&Config::default(), &workspace);
246 let layers = operate["roster"]["multi_layer"]
247 .as_array()
248 .expect("multi_layer array");
249 let builder = layers
250 .iter()
251 .find(|entry| entry["id"] == "builder")
252 .expect("builder multi-layer entry");
253 assert_eq!(builder["effective"], "project");
254 let paths: Vec<&str> = builder["layers"]
255 .as_array()
256 .expect("layers")
257 .iter()
258 .filter_map(|layer| layer["path"].as_str())
259 .collect();
260 assert!(
261 paths.iter().any(|path| path.ends_with("builder.toml")),
262 "layer paths include the profile files: {builder}"
263 );
264 assert!(
265 builder["layers"]
266 .as_array()
267 .expect("layers")
268 .iter()
269 .any(|layer| layer["origin"] == "personal" && layer["wins"] == false),
270 "personal layer is listed as ignored: {builder}"
271 );
272 assert!(
273 builder["layers"]
274 .as_array()
275 .expect("layers")
276 .iter()
277 .any(|layer| layer["origin"] == "project" && layer["wins"] == true),
278 "project layer wins: {builder}"
279 );
280 }
281
282 #[test]
283 fn doctor_fleet_report_flags_pins_absent_from_fresh_live_roster() {
284 // #6035: a pin that vanished from the provider's current live roster
285 // is drift the report must name — warning only, never a rewrite.
286 let _env_lock = crate::test_support::lock_test_env();
287 let _live = crate::provider_lake::lock_live_snapshot();
288 let tmp = tempfile::TempDir::new().expect("tempdir");
289 let home = tmp.path().join("home");
290 let workspace = tmp.path().join("workspace");
291 std::fs::create_dir_all(&workspace).expect("workspace");
292 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
293 let fleets = home.join("fleets");
294 std::fs::create_dir_all(&fleets).expect("fleets dir");
295 std::fs::write(
296 fleets.join("default.toml"),
297 "schema = \"fleet\"\nschema_revision = 2\nname = \"default\"\n\
298 [operator]\nprovider = \"deepseek\"\nmodel = \"deepseek-flash\"\n\
299 [[members]]\nid = \"builder\"\nprovider = \"deepseek\"\nmodel = \"deepseek-v4-flash\"\n",
300 )
301 .expect("fleet file");
302
303 let config = Config {
304 provider: Some("deepseek".to_string()),
305 // A fresh one-row roster must not replace the normal DeepSeek
306 // endpoint's process-wide catalog for unrelated route tests.
307 ..Default::default()
308 }
309 .with_legacy_root(
310 None,
311 Some("https://api.deepseek.com/v1/doctor-roster-fixture".to_string()),
312 );
313 let base_url = config.base_url_for_route(
314 &config
315 .resolve_provider_selection_identity("deepseek")
316 .unwrap(),
317 );
318 let fetched_at = std::time::SystemTime::now()
319 .duration_since(std::time::UNIX_EPOCH)
320 .expect("clock")
321 .as_secs();
322 let fingerprint = codewhale_config::catalog::base_url_fingerprint(&base_url);
323 assert_eq!(
324 crate::provider_catalog_live::record_success(
325 codewhale_config::catalog::ProviderCatalogDelta {
326 provider: "deepseek".to_string(),
327 base_url_fingerprint: fingerprint.clone(),
328 fetched_at,
329 offerings: vec![codewhale_config::catalog::CatalogOffering {
330 provider: "deepseek".to_string(),
331 wire_model_id: "deepseek-flash".to_string(),
332 endpoint_key: "chat".to_string(),
333 source: codewhale_config::catalog::CatalogSource::Live {
334 base_url_fingerprint: fingerprint,
335 fetched_at,
336 },
337 ..Default::default()
338 }],
339 }
340 ),
341 codewhale_config::catalog::CatalogStatus::Fresh
342 );
343
344 let operate = doctor_operate_fleet_report_json(&config, &workspace);
345 let drift = &operate["model_pin_drift"];
346 let drifted = drift["drifted"].as_array().expect("drifted array");
347 let row = drifted
348 .iter()
349 .find(|row| row["model"] == "deepseek-v4-flash")
350 .expect("member pin flagged: {drift}");
351 assert_eq!(row["provider"], "deepseek");
352 assert!(
353 row["owners"]
354 .as_array()
355 .expect("owners")
356 .iter()
357 .any(|owner| owner.as_str().is_some_and(|o| o.contains("member:builder"))),
358 "the fleet member pin is named: {row}"
359 );
360 // The operator pin moved to the listed id — not drift.
361 assert!(
362 !drifted.iter().any(|row| row["model"] == "deepseek-flash"),
363 "listed pin must not be flagged: {drifted:?}"
364 );
365 }
366
367 fn saved_exec_session(provider: &str, model: &str) -> session_manager::SavedSession {
368 let mut saved = session_manager::create_saved_session_with_mode(
369 &[],
370 model,
371 Path::new("/tmp/exec-resume"),
372 0,
373 None,
374 Some("exec"),
375 );
376 let kind = crate::config::ProviderKind::parse(provider)
377 .unwrap_or(crate::config::ProviderKind::Custom)
378 .as_str();
379 let exact_id = (!provider.eq_ignore_ascii_case(crate::config::ProviderKind::Custom.as_str()))
380 .then_some(provider);
381 saved.metadata.set_model_provider_route(kind, exact_id);
382 saved
383 }
384
385 #[test]
386 fn prompt_flag_accepts_split_prompt_words_for_windows_cmd_shims() {
387 let cli = parse_cli(&["codewhale", "-p", "hello", "world"]);
388
389 assert_eq!(cli.prompt, vec!["hello", "world"]);
390 }
391
392 #[test]
393 fn prompt_flag_starts_interactive_submit_input() {
394 let cli = parse_cli(&["codewhale", "-p", "read", "the", "project"]);
395
396 assert_eq!(
397 top_level_prompt_initial_input(&cli.prompt),
398 Some(tui::InitialInput::Submit("read the project".to_string()))
399 );
400 }
401
402 #[test]
403 fn runtime_decoder_uses_the_canonical_command_name() {
404 assert_eq!(Cli::command().get_name(), "codewhale");
405 }
406
407 #[test]
408 fn usage_errors_name_the_codewhale_command() {
409 let error = Cli::try_parse_from(["codewhale-tui", "doctor", "--bogus"])
410 .expect_err("an unknown doctor flag must not parse");
411 let rendered = error.render().to_string();
412 assert!(
413 rendered.contains("codewhale doctor"),
414 "usage should name `codewhale doctor`: {rendered}"
415 );
416 assert!(
417 !rendered.contains("codewhale-tui"),
418 "usage must not name the retired binary: {rendered}"
419 );
420 }
421
422 #[test]
423 fn xai_device_auth_subcommand_parses() {
424 let cli = parse_cli(&["codewhale-tui", "auth", "xai-device"]);
425 assert!(matches!(
426 cli.command,
427 Some(Commands::Auth(TuiAuthArgs {
428 command: TuiAuthCommand::XaiDevice
429 }))
430 ));
431 }
432
433 #[test]
434 fn chatgpt_auth_subcommand_parses() {
435 let cli = parse_cli(&["codewhale-tui", "auth", "chatgpt"]);
436 assert!(matches!(
437 cli.command,
438 Some(Commands::Auth(TuiAuthArgs {
439 command: TuiAuthCommand::Chatgpt
440 }))
441 ));
442 let cli = parse_cli(&["codewhale-tui", "auth", "chatgpt-revoke"]);
443 assert!(matches!(
444 cli.command,
445 Some(Commands::Auth(TuiAuthArgs {
446 command: TuiAuthCommand::ChatgptRevoke
447 }))
448 ));
449 }
450
451 #[test]
452 fn workflow_tool_internal_subcommand_parses_exact_json() {
453 let cli = parse_cli(&[
454 "codewhale-tui",
455 "workflow-tool",
456 "--approval-source",
457 "explicit-workflow-command",
458 "--input-json",
459 r#"{"action":"run","source_path":"workflows/demo.js"}"#,
460 ]);
461 let Some(Commands::WorkflowTool(args)) = cli.command else {
462 panic!("expected workflow-tool command");
463 };
464 assert!(args.input_json.contains("\"action\":\"run\""));
465 }
466
467 #[tokio::test]
468 async fn direct_workflow_tool_runs_without_an_operator_model_turn() {
469 use crate::tools::spec::ToolSpec;
470
471 let workspace = tempfile::tempdir().expect("workspace");
472 let config = Config {
473 provider: Some("vllm".to_string()),
474 mcp_config_path: Some(
475 workspace
476 .path()
477 .join("missing-mcp.json")
478 .display()
479 .to_string(),
480 ),
481 providers: Some(crate::config::ProvidersConfig {
482 vllm: crate::config::ProviderConfig {
483 base_url: Some("http://127.0.0.1:9/v1".to_string()),
484 model: Some("offline-test-model".to_string()),
485 ..Default::default()
486 },
487 ..Default::default()
488 }),
489 ..Default::default()
490 };
491 let route = CliAutoRoute {
492 provider: config.test_identity_for_kind(crate::config::ProviderKind::Vllm),
493 model: "offline-test-model".to_string(),
494 reasoning_effort: None,
495 auto_controls_reasoning: false,
496 auto_model: false,
497 };
498 let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(64);
499 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
500 let (tool, context) =
501 build_direct_workflow_tool(&config, &route, workspace.path(), event_tx, plugins)
502 .await
503 .expect("build direct workflow runtime");
504
505 let result = tool
506 .execute(
507 serde_json::json!({
508 "action": "run",
509 "script": "phase('offline'); return { ok: true };",
510 "token_budget": 1_000_000
511 }),
512 &context,
513 )
514 .await
515 .expect("model-free workflow run");
516 let payload: serde_json::Value = serde_json::from_str(&result.content).expect("workflow JSON");
517
518 assert_eq!(payload["status"], "completed");
519 assert_eq!(payload["result"]["ok"], true);
520 assert_eq!(payload["child_ids"].as_array().map(Vec::len), Some(0));
521 assert_eq!(
522 payload["plan_approval"]["decision"],
523 "approved_explicit_cli_command"
524 );
525 assert!(!context.auto_approve);
526 assert!(!context.trust_mode);
527 assert_eq!(
528 context.shell_policy,
529 crate::worker_profile::ShellPolicy::None
530 );
531 assert!(matches!(
532 context.elevated_sandbox_policy,
533 Some(crate::sandbox::SandboxPolicy::WorkspaceWrite { .. })
534 ));
535 let mut event_types = Vec::new();
536 while let Ok(event) = event_rx.try_recv() {
537 if let crate::core::events::Event::WorkflowUi { event, .. } = event
538 && let Some(kind) = event["type"].as_str()
539 {
540 event_types.push(kind.to_string());
541 }
542 }
543 assert!(event_types.iter().any(|kind| kind == "run_started"));
544 assert!(event_types.iter().any(|kind| kind == "run_completed"));
545 }
546
547 #[tokio::test]
548 async fn direct_workflow_mcp_pool_applies_network_policy_before_connect() {
549 let workspace = tempfile::tempdir().expect("workspace");
550 let mcp_path = workspace.path().join("mcp.json");
551 std::fs::write(
552 &mcp_path,
553 r#"{
554 "mcpServers": {
555 "blocked": { "url": "https://blocked.invalid/mcp" }
556 }
557 }"#,
558 )
559 .expect("write MCP config");
560 let config = Config {
561 mcp_config_path: Some(mcp_path.display().to_string()),
562 ..Default::default()
563 };
564 let policy = crate::network_policy::NetworkPolicyDecider::new(
565 crate::network_policy::NetworkPolicy {
566 default: crate::network_policy::DecisionToml::Deny,
567 allow: Vec::new(),
568 deny: Vec::new(),
569 proxy: Vec::new(),
570 proxy_fake_ip_cidrs: Vec::new(),
571 audit: false,
572 },
573 None,
574 );
575
576 let plugins = Arc::new(crate::plugins::PluginRegistry::empty(workspace.path()));
577 let (_pool, failures) =
578 initialize_direct_workflow_mcp_pool(&config, workspace.path(), Some(policy), plugins)
579 .await
580 .expect("MCP feature enabled");
581 assert_eq!(failures.len(), 1, "failures={failures:?}");
582 assert_eq!(failures[0].0, "blocked");
583 assert!(failures[0].1.contains("blocked by network policy"));
584 }
585
586 #[test]
587 fn exec_model_resolution_uses_provider_scoped_default() {
588 let _env_lock = crate::test_support::lock_test_env();
589 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
590 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
591 let config = Config {
592 provider: Some("openrouter".to_string()),
593 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
594 providers: Some(crate::config::ProvidersConfig {
595 openrouter: crate::config::ProviderConfig {
596 model: Some("arcee-ai/trinity-large-thinking".to_string()),
597 ..Default::default()
598 },
599 ..Default::default()
600 }),
601 ..Default::default()
602 };
603
604 assert_eq!(
605 resolve_exec_model(&config, None),
606 "arcee-ai/trinity-large-thinking"
607 );
608 assert_eq!(
609 resolve_exec_model(&config, Some("arcee-ai/trinity-large-thinking")),
610 "arcee-ai/trinity-large-thinking"
611 );
612 }
613
614 #[test]
615 fn exec_model_resolution_prefers_codewhale_model_env_override() {
616 let _env_lock = crate::test_support::lock_test_env();
617 let _codewhale_model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", " auto ");
618 let _deepseek_model =
619 crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", "stale-deepseek-model");
620 let config = Config {
621 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
622 ..Default::default()
623 };
624
625 assert_eq!(resolve_exec_model(&config, None), "auto");
626 }
627
628 #[test]
629 fn exec_model_resolution_uses_legacy_deepseek_model_env_override() {
630 let _env_lock = crate::test_support::lock_test_env();
631 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
632 let _deepseek_model = crate::test_support::EnvVarGuard::set("DEEPSEEK_MODEL", " auto ");
633 let config = Config {
634 default_text_model: Some("deepseek/deepseek-v4-pro".to_string()),
635 ..Default::default()
636 };
637
638 assert_eq!(resolve_exec_model(&config, None), "auto");
639 }
640
641 #[test]
642 fn exec_model_resolution_uses_provider_safe_default_for_zai() {
643 let _env_lock = crate::test_support::lock_test_env();
644 let _codewhale_model = crate::test_support::EnvVarGuard::remove("CODEWHALE_MODEL");
645 let _deepseek_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
646 let config = Config {
647 provider: Some("zai".to_string()),
648 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
649 ..Default::default()
650 };
651
652 assert_eq!(
653 resolve_exec_model(&config, None),
654 crate::config::DEFAULT_ZAI_MODEL
655 );
656 }
657
658 #[test]
659 fn fresh_launch_uses_selected_fleet_operator_unless_route_is_explicit() {
660 let workspace = tempfile::tempdir().expect("workspace");
661 let fleets = workspace.path().join(".codewhale").join("fleets");
662 std::fs::create_dir_all(&fleets).expect("fleet directory");
663 std::fs::write(fleets.join("selected"), "Launch\n").expect("selection");
664 std::fs::write(
665 fleets.join("launch.toml"),
666 r#"schema = "fleet"
667 schema_revision = 2
668 name = "Launch"
669
670 [operator]
671 provider = "deepseek"
672 model = "deepseek-v4-flash-vision-exp"
673 reasoning = "high"
674 "#,
675 )
676 .expect("fleet file");
677
678 let base = Config {
679 provider: Some("openrouter".to_string()),
680 reasoning_effort: Some("off".to_string()),
681 ..Default::default()
682 }
683 .with_legacy_root(Some("test-key".to_string()), None);
684 let mut explicit = base.clone();
685 assert!(
686 !apply_selected_fleet_operator_for_launch(&mut explicit, workspace.path(), true, false,)
687 .expect("explicit route bypasses Fleet operator")
688 );
689 assert_eq!(
690 explicit.active_provider_identity().unwrap().provider,
691 crate::config::ProviderKind::Openrouter
692 );
693
694 let mut selected = base;
695 assert!(
696 apply_selected_fleet_operator_for_launch(&mut selected, workspace.path(), false, false,)
697 .expect("selected operator applies")
698 );
699 assert_eq!(
700 selected.active_provider_identity().unwrap().provider,
701 crate::config::ProviderKind::Deepseek
702 );
703 assert_eq!(selected.default_model(), "deepseek-v4-flash-vision-exp");
704 assert_eq!(selected.reasoning_effort(), Some("high"));
705 assert!(selected.fleet_operator_route_applied);
706 assert!(selected.fleet_operator_reasoning_applied);
707
708 let mut reasoning_override = Config {
709 reasoning_effort: Some("off".to_string()),
710 ..Default::default()
711 }
712 .with_legacy_root(Some("test-key".to_string()), None);
713 apply_selected_fleet_operator_for_launch(
714 &mut reasoning_override,
715 workspace.path(),
716 false,
717 true,
718 )
719 .expect("explicit reasoning coexists with Fleet route");
720 assert_eq!(
721 reasoning_override.default_model(),
722 "deepseek-v4-flash-vision-exp"
723 );
724 assert_eq!(reasoning_override.reasoning_effort(), Some("off"));
725 assert!(reasoning_override.fleet_operator_route_applied);
726 assert!(!reasoning_override.fleet_operator_reasoning_applied);
727 }
728
729 #[test]
730 fn selected_fleet_operator_load_error_redacts_paths_excerpts_and_opaque_name() {
731 let workspace = tempfile::tempdir().expect("workspace");
732 let fleets = workspace.path().join(".codewhale").join("fleets");
733 std::fs::create_dir_all(&fleets).expect("fleet directory");
734 let secret_marker = "sk-live-abcdef0123456789abcdef";
735 std::fs::write(fleets.join("selected"), format!("{secret_marker}\n")).expect("selection");
736 std::fs::write(
737 fleets.join(format!("{secret_marker}.toml")),
738 format!("invalid TOML /Users/operator/private {secret_marker}\n"),
739 )
740 .expect("invalid Fleet");
741
742 let mut config = Config::default();
743 let message =
744 apply_selected_fleet_operator_for_launch(&mut config, workspace.path(), false, false)
745 .expect_err("invalid selected Fleet must fail")
746 .to_string();
747
748 assert!(!message.contains(&workspace.path().display().to_string()));
749 assert!(!message.contains("/Users/operator"));
750 assert!(!message.contains(secret_marker));
751 assert!(!message.contains("invalid TOML"));
752 assert!(message.chars().count() <= 700, "{message}");
753 }
754
755 #[tokio::test]
756 #[allow(clippy::await_holding_lock)]
757 async fn explicit_exec_model_routes_to_unique_authenticated_provider_candidate() {
758 let _env_lock = crate::test_support::lock_test_env();
759 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
760 let _openrouter = crate::test_support::EnvVarGuard::remove("OPENROUTER_API_KEY");
761 let config = Config {
762 provider: Some("deepseek".to_string()),
763 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
764 ..Default::default()
765 };
766
767 let route = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
768 .await
769 .expect("explicit GLM should route to the configured Z.ai provider");
770
771 assert_eq!(route.provider.provider, crate::config::ProviderKind::Zai);
772 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
773 assert!(!route.auto_model);
774 }
775
776 #[tokio::test]
777 #[allow(clippy::await_holding_lock)]
778 async fn explicit_exec_model_reports_ambiguous_authenticated_provider_candidates() {
779 let _env_lock = crate::test_support::lock_test_env();
780 let _zai = crate::test_support::EnvVarGuard::set("ZAI_API_KEY", "zai-key");
781 let _openrouter = crate::test_support::EnvVarGuard::set("OPENROUTER_API_KEY", "or-key");
782 let config = Config {
783 provider: Some("deepseek".to_string()),
784 default_text_model: Some(crate::config::DEFAULT_TEXT_MODEL.to_string()),
785 ..Default::default()
786 };
787
788 let err = resolve_cli_auto_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "pong")
789 .await
790 .expect_err("ambiguous GLM route should ask for an explicit provider");
791 let message = err.to_string();
792
793 assert!(message.contains("model `GLM-5.2` is available"));
794 assert!(message.contains("openrouter"));
795 assert!(message.contains("zai"));
796 assert!(message.contains("--provider"));
797 assert!(message.contains("/provider"));
798 assert!(message.contains("/model"));
799 assert!(message.contains("/setup"));
800 }
801
802 #[tokio::test]
803 async fn cli_auto_model_honors_a_fixed_reasoning_preference() {
804 let config = Config {
805 provider: Some("vllm".to_string()),
806 reasoning_effort: Some("low".to_string()),
807 providers: Some(crate::config::ProvidersConfig {
808 vllm: crate::config::ProviderConfig {
809 base_url: Some("http://127.0.0.1:18190/v1".to_string()),
810 model: Some("local-auto-model".to_string()),
811 ..Default::default()
812 },
813 ..Default::default()
814 }),
815 ..Default::default()
816 };
817
818 let route = resolve_cli_auto_route(&config, "auto", "debug a failing test")
819 .await
820 .expect("Auto model route");
821
822 assert!(route.auto_model);
823 assert_eq!(
824 route.reasoning_effort,
825 Some(crate::reasoning_preference::ReasoningEffort::Low)
826 );
827 assert!(
828 !route.auto_controls_reasoning,
829 "a fixed saved tier must not be replaced per prompt"
830 );
831 }
832
833 #[test]
834 fn cli_route_execution_config_stamps_routed_model_into_provider_slot() {
835 let mut providers = crate::config::ProvidersConfig::default();
836 providers.deepseek.model = Some("deepseek-v4-pro".to_string());
837 let config = Config {
838 provider: Some("deepseek".to_string()),
839 providers: Some(providers),
840 ..Default::default()
841 };
842 let route = CliAutoRoute {
843 provider: Config::default().test_identity_for_kind(crate::config::ProviderKind::Deepseek),
844 model: "deepseek-v4-flash".to_string(),
845 reasoning_effort: None,
846 auto_controls_reasoning: true,
847 auto_model: true,
848 };
849
850 let execution_config = config_for_cli_route(&config, &route).expect("admitted execution route");
851
852 assert_eq!(execution_config.default_model(), "deepseek-v4-flash");
853 assert_eq!(
854 execution_config
855 .provider_config_for(
856 &execution_config.test_identity_for_kind(crate::config::ProviderKind::Deepseek)
857 )
858 .and_then(|entry| entry.model.as_deref()),
859 Some("deepseek-v4-flash")
860 );
861 }
862
863 #[test]
864 fn cli_route_execution_config_preserves_the_legacy_literal_custom_route() {
865 let _lock = crate::test_support::lock_test_env();
866 let _source = crate::test_support::EnvVarGuard::remove("DEEPSEEK_API_KEY_SOURCE");
867 let _cli_key = crate::test_support::EnvVarGuard::remove("CODEWHALE_CLI_API_KEY");
868 let config = Config {
869 provider: Some("custom".to_string()),
870 default_text_model: Some("legacy-model".to_string()),
871 ..Default::default()
872 }
873 .with_legacy_root(
874 Some("legacy-root-key".to_string()),
875 Some("http://127.0.0.1:18183/v1".to_string()),
876 );
877 let route = CliAutoRoute {
878 provider: config.active_provider_identity().expect("captured literal root identity"),
879 model: "routed-legacy-model".to_string(),
880 reasoning_effort: None,
881 auto_controls_reasoning: false,
882 auto_model: false,
883 };
884
885 let execution = config_for_cli_route(&config, &route).expect("admitted execution route");
886
887 // The literal route's top-level fields live in `[providers.custom]`
888 // (#6394), and the routed model lands there too.
889 assert!(execution.selects_literal_custom_provider());
890 assert_eq!(execution.provider.as_deref(), Some("custom"));
891 assert_eq!(execution.default_model(), "routed-legacy-model");
892 assert_eq!(
893 execution.active_route_base_url(),
894 "http://127.0.0.1:18183/v1"
895 );
896 assert_eq!(execution.active_route_api_key().unwrap(), "legacy-root-key");
897 for _ in 0..2 {
898 let identity = execution
899 .resolve_provider_identity("custom")
900 .expect("legacy identity remains repeatedly resolvable");
901 assert_eq!(identity.key.as_str(), "custom");
902 }
903 let client = crate::client::CodewhaleClient::new(&execution).expect("legacy execution client");
904 assert_eq!(client.base_url(), "http://127.0.0.1:18183/v1");
905 }
906
907 /// #6510: only a flag that grants tool authority opens a tool surface.
908 /// Limits, prompt/hook opt-ins and the output format keep plain exec a
909 /// zero-tool one-shot; `--max-turns 1` used to make it a tool agent.
910 #[test]
911 fn exec_tool_surface_needs_an_explicit_grant() {
912 let grants = |argv: &[&str], yolo: bool, resuming: bool, env: bool| {
913 let mut full = vec!["codewhale", "exec"];
914 full.extend_from_slice(argv);
915 full.push("hi");
916 let cli = parse_cli(&full);
917 let Some(Commands::Exec(args)) = cli.command else {
918 panic!("expected exec command");
919 };
920 exec_grants_tool_surface(&args, yolo, resuming, env)
921 };
922
923 for zero_tool in [
924 &[][..],
925 &["--max-turns", "1"],
926 &["--max-tool-calls", "3"],
927 &["--disallowed-tools", "exec_shell"],
928 &["--append-system-prompt", "be brief"],
929 &["--hooks"],
930 &["--sandbox", "read-only"],
931 &["--allow-sandbox-elevation"],
932 &["--output-format", "stream-json"],
933 &["--json"],
934 ] {
935 assert!(
936 !grants(zero_tool, false, false, false),
937 "{zero_tool:?} must not grant tools"
938 );
939 }
940
941 assert!(grants(&["--auto"], false, false, false));
942 assert!(grants(
943 &["--allowed-tools", "read_file"],
944 false,
945 false,
946 false
947 ));
948 assert!(grants(
949 &["--tool-authority-json", "{}"],
950 false,
951 false,
952 false
953 ));
954 assert!(grants(&["--max-turns", "1"], true, false, false), "yolo");
955 assert!(grants(&[], false, true, false), "resumed session");
956 assert!(grants(&[], false, false, true), "launcher tool surface");
957 }
958
959 #[test]
960 fn exec_accepts_split_prompt_words_for_windows_cmd_shims() {
961 let cli = parse_cli(&["codewhale", "exec", "hello", "world"]);
962 let Some(Commands::Exec(args)) = cli.command else {
963 panic!("expected exec command");
964 };
965
966 assert_eq!(args.prompt, vec!["hello", "world"]);
967 }
968
969 #[test]
970 fn exec_keeps_model_flag_before_split_prompt_words() {
971 let cli = parse_cli(&["codewhale", "exec", "--model", "auto", "hello", "world"]);
972 let Some(Commands::Exec(args)) = cli.command else {
973 panic!("expected exec command");
974 };
975
976 assert_eq!(args.model.as_deref(), Some("auto"));
977 assert_eq!(args.prompt, vec!["hello", "world"]);
978 }
979
980 #[test]
981 fn exec_keeps_flags_before_split_prompt_words() {
982 let cli = parse_cli(&["codewhale", "exec", "--json", "hello", "world"]);
983 let Some(Commands::Exec(args)) = cli.command else {
984 panic!("expected exec command");
985 };
986
987 assert!(args.json);
988 assert_eq!(args.prompt, vec!["hello", "world"]);
989 }
990
991 #[test]
992 fn exec_prompt_file_carries_prompts_past_the_argv_ceiling() {
993 // #6688: argv caps one argument at 128 KiB; the file transport does not.
994 let dir = tempfile::tempdir().expect("tempdir");
995 let path = dir.path().join("big-prompt.txt");
996 let body = "стих ".repeat(60_000);
997 assert!(body.len() > 512 * 1024);
998 std::fs::write(&path, &body).expect("write prompt");
999 let path_arg = path.to_str().expect("utf-8 path");
1000
1001 let cli = parse_cli(&["codewhale", "exec", "--auto", "--prompt-file", path_arg]);
1002 let Some(Commands::Exec(args)) = cli.command else {
1003 panic!("expected exec command");
1004 };
1005 assert!(args.auto);
1006 assert!(args.prompt.is_empty());
1007 assert_eq!(resolve_exec_prompt(&args).expect("prompt"), body);
1008
1009 let conflict =
1010 Cli::try_parse_from(["codewhale", "exec", "--prompt-file", path_arg, "also argv"])
1011 .expect_err("positional prompt and --prompt-file conflict");
1012 assert_eq!(conflict.kind(), clap::error::ErrorKind::ArgumentConflict);
1013
1014 let missing = dir.path().join("missing.txt");
1015 let cli = parse_cli(&[
1016 "codewhale",
1017 "exec",
1018 "--prompt-file",
1019 missing.to_str().expect("utf-8 path"),
1020 ]);
1021 let Some(Commands::Exec(args)) = cli.command else {
1022 panic!("expected exec command");
1023 };
1024 let err = resolve_exec_prompt(&args).expect_err("missing file fails");
1025 assert!(err.to_string().contains("--prompt-file"), "{err:#}");
1026
1027 let cli = parse_cli(&[
1028 "codewhale",
1029 "exec",
1030 "--parent-death-watch",
1031 "--prompt-file",
1032 "-",
1033 ]);
1034 let Some(Commands::Exec(args)) = cli.command else {
1035 panic!("expected exec command");
1036 };
1037 let err = resolve_exec_prompt(&args).expect_err("stdin is owned by the watcher");
1038 assert!(err.to_string().contains("--parent-death-watch"), "{err:#}");
1039
1040 let cli = parse_cli(&["codewhale", "exec", "explain", "this"]);
1041 let Some(Commands::Exec(args)) = cli.command else {
1042 panic!("expected exec command");
1043 };
1044 assert_eq!(
1045 resolve_exec_prompt(&args).expect("argv prompt"),
1046 "explain this"
1047 );
1048
1049 // A positional `-` is prompt text, never a stdin read: cloud dispatch
1050 // passes job prompts verbatim as argv.
1051 let cli = parse_cli(&["codewhale", "exec", "--auto", "-"]);
1052 let Some(Commands::Exec(args)) = cli.command else {
1053 panic!("expected exec command");
1054 };
1055 assert_eq!(resolve_exec_prompt(&args).expect("literal dash"), "-");
1056 }
1057
1058 #[test]
1059 fn read_capped_text_names_the_limit_in_bytes() {
1060 let err =
1061 read_capped_text(&b"abcdef"[..], 4, "exec prompt on stdin").expect_err("over the cap");
1062 assert_eq!(
1063 err.to_string(),
1064 "exec prompt on stdin exceeds the 4-byte limit"
1065 );
1066 assert_eq!(
1067 read_capped_text(&b"abcd"[..], 4, "x").expect("at the cap"),
1068 "abcd"
1069 );
1070 }
1071
1072 #[test]
1073 fn exec_parses_provider_flag_alongside_model() {
1074 // #4093: Fleet threads `--provider <id>` so a worker launches on its
1075 // profile-pinned provider even when the parent session is elsewhere.
1076 let cli = parse_cli(&[
1077 "codewhale",
1078 "exec",
1079 "--provider",
1080 "openrouter",
1081 "--model",
1082 "glm-5.2",
1083 "audit",
1084 ]);
1085 let Some(Commands::Exec(args)) = cli.command else {
1086 panic!("expected exec command");
1087 };
1088
1089 assert_eq!(args.provider.as_deref(), Some("openrouter"));
1090 assert_eq!(args.model.as_deref(), Some("glm-5.2"));
1091 assert_eq!(args.prompt, vec!["audit"]);
1092 // The threaded id round-trips through the provider vocabulary the exec
1093 // handler validates against — never a model-id sniff (EPIC #2608).
1094 assert_eq!(
1095 crate::config::ProviderKind::parse(args.provider.as_deref().unwrap()),
1096 Some(crate::config::ProviderKind::Openrouter)
1097 );
1098 }
1099
1100 #[test]
1101 fn exec_provider_override_accepts_configured_custom_provider() {
1102 let mut custom = std::collections::HashMap::new();
1103 custom.insert(
1104 "lm-studio".to_string(),
1105 crate::config::ProviderConfig {
1106 kind: Some("openai-compatible".to_string()),
1107 base_url: Some("http://127.0.0.1:1234/v1".to_string()),
1108 model: Some("qwen-2.5-7b".to_string()),
1109 api_key: Some("lm-studio".to_string()),
1110 ..Default::default()
1111 },
1112 );
1113 let mut config = Config {
1114 provider: Some("deepseek".to_string()),
1115 providers: Some(crate::config::ProvidersConfig {
1116 custom,
1117 ..Default::default()
1118 }),
1119 ..Default::default()
1120 };
1121
1122 apply_exec_provider_override(&mut config, "lm-studio")
1123 .expect("configured custom provider should be accepted");
1124
1125 assert_eq!(config.provider.as_deref(), Some("lm-studio"));
1126 assert_eq!(
1127 config.active_provider_identity().unwrap().provider,
1128 crate::config::ProviderKind::Custom
1129 );
1130 }
1131
1132 #[test]
1133 fn exec_provider_override_prefers_exact_case_colliding_custom_key() {
1134 let mut config = Config {
1135 provider: Some("deepseek".to_string()),
1136 providers: Some(crate::config::ProvidersConfig {
1137 custom: std::collections::HashMap::from([(
1138 "CUSTOM".to_string(),
1139 crate::config::ProviderConfig {
1140 kind: Some("openai-compatible".to_string()),
1141 base_url: Some("http://127.0.0.1:5678/v1".to_string()),
1142 model: Some("case-model".to_string()),
1143 api_key: Some("case-key".to_string()),
1144 ..Default::default()
1145 },
1146 )]),
1147 ..Default::default()
1148 }),
1149 ..Default::default()
1150 };
1151
1152 apply_exec_provider_override(&mut config, "CUSTOM")
1153 .expect("exact case-colliding custom provider");
1154 assert_eq!(config.provider.as_deref(), Some("CUSTOM"));
1155 assert_eq!(
1156 config.active_provider_identity().unwrap().provider,
1157 crate::config::ProviderKind::Custom
1158 );
1159 assert_eq!(
1160 config.active_provider_identity().unwrap().key.as_str(),
1161 "CUSTOM"
1162 );
1163 let route = crate::route_runtime::resolve_runtime_route(
1164 &config,
1165 crate::config::ProviderKind::Custom,
1166 Some("case-model"),
1167 )
1168 .expect("resolve exact case-colliding route")
1169 .validate()
1170 .expect("preflight exact case-colliding route");
1171 assert_eq!(route.identity.key.as_str(), "CUSTOM");
1172 assert_eq!(route.client.base_url(), "http://127.0.0.1:5678/v1");
1173 }
1174
1175 #[test]
1176 fn exec_provider_override_rejects_unknown_provider() {
1177 let mut config = Config {
1178 provider: Some("deepseek".to_string()),
1179 ..Default::default()
1180 };
1181
1182 let err = apply_exec_provider_override(&mut config, "lm-studio")
1183 .expect_err("unconfigured custom provider should fail closed");
1184 let message = err.to_string();
1185
1186 assert!(message.contains("Unrecognized --provider"));
1187 assert!(message.contains("[providers.<name>] custom provider"));
1188 assert_eq!(config.provider.as_deref(), Some("deepseek"));
1189 }
1190
1191 #[test]
1192 fn exec_resume_route_matrix_preserves_or_overrides_exact_provider_deliberately() {
1193 let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
1194
1195 let mut restored = custom_exec_config("custom-b");
1196 let model = resolve_exec_resume_route(&mut restored, &saved, false, None)
1197 .expect("plain resume restores saved route");
1198 assert_eq!(restored.provider.as_deref(), Some("custom-a"));
1199 assert_eq!(model, crate::config::ZAI_GLM_5_2_MODEL);
1200
1201 let mut explicit_provider = custom_exec_config("custom-a");
1202 apply_exec_provider_override(&mut explicit_provider, "custom-b").expect("custom B");
1203 let model = resolve_exec_resume_route(&mut explicit_provider, &saved, true, None)
1204 .expect("explicit provider wins");
1205 assert_eq!(explicit_provider.provider.as_deref(), Some("custom-b"));
1206 assert_eq!(model, "model-b");
1207
1208 let mut explicit_model = custom_exec_config("custom-b");
1209 let model =
1210 resolve_exec_resume_route(&mut explicit_model, &saved, false, Some("override-model"))
1211 .expect("explicit model keeps saved provider");
1212 assert_eq!(explicit_model.provider.as_deref(), Some("custom-a"));
1213 assert_eq!(model, "override-model");
1214
1215 let mut missing = custom_exec_config("custom-b");
1216 missing
1217 .providers
1218 .as_mut()
1219 .expect("providers")
1220 .custom
1221 .remove("custom-a");
1222 let before = missing.provider.clone();
1223 let err = resolve_exec_resume_route(&mut missing, &saved, false, None)
1224 .expect_err("removed saved provider must fail closed");
1225 assert!(err.to_string().contains("will not fall back"), "{err}");
1226 assert_eq!(missing.provider, before);
1227 }
1228
1229 #[test]
1230 fn exec_resume_honours_dispatcher_forwarded_launch_overrides() {
1231 // `codewhale --provider X --model Y exec --resume ID ...` reaches this
1232 // binary with X/Y only in CODEWHALE_PROVIDER / CODEWHALE_MODEL; a
1233 // resume must treat them as explicit instead of restoring the saved
1234 // route.
1235 assert_eq!(
1236 exec_resume_route_overrides(None, None, None, None),
1237 (false, None)
1238 );
1239 assert_eq!(
1240 exec_resume_route_overrides(None, None, Some("modelstudio-token-plan"), None),
1241 (true, None)
1242 );
1243 assert_eq!(
1244 exec_resume_route_overrides(None, None, None, Some("qwen3.8-flash")),
1245 (false, Some("qwen3.8-flash".to_string()))
1246 );
1247 assert_eq!(
1248 exec_resume_route_overrides(
1249 None,
1250 None,
1251 Some("modelstudio-token-plan"),
1252 Some(" qwen3.8-flash ")
1253 ),
1254 (true, Some("qwen3.8-flash".to_string()))
1255 );
1256 // Exec-level flags still win over the forwarded launch env.
1257 assert_eq!(
1258 exec_resume_route_overrides(
1259 Some("deepseek"),
1260 Some("deepseek-v4-pro"),
1261 Some("x"),
1262 Some("y")
1263 ),
1264 (true, Some("deepseek-v4-pro".to_string()))
1265 );
1266 // Blank values are not overrides.
1267 assert_eq!(
1268 exec_resume_route_overrides(None, None, Some(" "), Some("")),
1269 (false, None)
1270 );
1271
1272 let saved = saved_exec_session("custom-a", crate::config::ZAI_GLM_5_2_MODEL);
1273 let mut launch_model_only = custom_exec_config("custom-b");
1274 let (explicit_provider, explicit_model) =
1275 exec_resume_route_overrides(None, None, None, Some("override-model"));
1276 let model = resolve_exec_resume_route(
1277 &mut launch_model_only,
1278 &saved,
1279 explicit_provider,
1280 explicit_model.as_deref(),
1281 )
1282 .expect("launch model override keeps saved provider");
1283 assert_eq!(launch_model_only.provider.as_deref(), Some("custom-a"));
1284 assert_eq!(model, "override-model");
1285
1286 let mut launch_provider = custom_exec_config("custom-b");
1287 let (explicit_provider, explicit_model) =
1288 exec_resume_route_overrides(None, None, Some("custom-b"), None);
1289 let model = resolve_exec_resume_route(
1290 &mut launch_provider,
1291 &saved,
1292 explicit_provider,
1293 explicit_model.as_deref(),
1294 )
1295 .expect("launch provider override keeps the launched route");
1296 assert_eq!(launch_provider.provider.as_deref(), Some("custom-b"));
1297 assert_eq!(model, "model-b");
1298 }
1299
1300 #[test]
1301 fn exec_resume_uses_dispatcher_env_route_loaded_by_config() {
1302 // Exercise the production sequence without starting an Engine turn:
1303 // `Config::load` applies the dispatcher-forwarded environment, then
1304 // the resume seam must treat the same launch env as explicit and skip
1305 // the unavailable persisted route rather than restoring it.
1306 let _env_lock = crate::test_support::lock_test_env();
1307 let _legacy_provider = crate::test_support::EnvVarGuard::remove("DEEPSEEK_PROVIDER");
1308 let _legacy_model = crate::test_support::EnvVarGuard::remove("DEEPSEEK_MODEL");
1309 let _provider = crate::test_support::EnvVarGuard::set("CODEWHALE_PROVIDER", "launch-route");
1310 let _model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "dispatcher-model");
1311 let tmp = tempfile::tempdir().expect("config tempdir");
1312 let codewhale_home = tmp.path().join("codewhale-home");
1313 std::fs::create_dir_all(&codewhale_home).expect("isolated Codewhale home");
1314 let _codewhale_home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
1315 let config_path = tmp.path().join("config.toml");
1316 std::fs::write(
1317 &config_path,
1318 r#"provider = "stored-route"
1319
1320 [providers.launch-route]
1321 kind = "openai-compatible"
1322 base_url = "https://launch.example.test/v1"
1323 model = "configured-launch-model"
1324 api_key = "test-only-key"
1325 "#,
1326 )
1327 .expect("write config");
1328
1329 let mut config = Config::load(Some(config_path), None).expect("load launch config");
1330 assert_eq!(config.provider.as_deref(), Some("launch-route"));
1331 assert_eq!(config.default_model(), "dispatcher-model");
1332
1333 // The saved route deliberately has no live config table. This control
1334 // proves that a non-explicit resume would fail closed instead of
1335 // silently falling back; the dispatcher overrides must prevent that
1336 // restore attempt.
1337 let saved = saved_exec_session("stored-route", "stored-model");
1338 let mut restore_attempt = config.clone();
1339 let restore_error = resolve_exec_resume_route(&mut restore_attempt, &saved, false, None)
1340 .expect_err("unavailable saved route must not be restored");
1341 assert!(
1342 restore_error.to_string().contains("stored-route"),
1343 "{restore_error}"
1344 );
1345
1346 let (explicit_provider, explicit_model) = exec_resume_route_overrides(
1347 None,
1348 None,
1349 crate::config::explicit_launch_provider_override().as_deref(),
1350 crate::config::explicit_launch_model_override().as_deref(),
1351 );
1352 assert!(explicit_provider);
1353 assert_eq!(explicit_model.as_deref(), Some("dispatcher-model"));
1354
1355 let model = resolve_exec_resume_route(
1356 &mut config,
1357 &saved,
1358 explicit_provider,
1359 explicit_model.as_deref(),
1360 )
1361 .expect("dispatcher environment must keep the launch route on resume");
1362 assert_eq!(config.provider.as_deref(), Some("launch-route"));
1363 assert_eq!(model, "dispatcher-model");
1364 }
1365
1366 #[test]
1367 fn exec_model_reads_wait_for_foreign_test_env_overrides_to_restore() {
1368 let (started_tx, started_rx) = std::sync::mpsc::channel();
1369 let (tx, rx) = std::sync::mpsc::channel();
1370
1371 let (reader, expected_after_restore) = {
1372 let lock = crate::test_support::lock_test_env();
1373 let expected_after_restore = exec_model_env_override();
1374 let temporary = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "temporary-model");
1375 let reader = std::thread::spawn(move || {
1376 started_tx.send(()).expect("signal model read start");
1377 tx.send(exec_model_env_override())
1378 .expect("send resolved model override");
1379 });
1380
1381 started_rx
1382 .recv_timeout(std::time::Duration::from_secs(2))
1383 .expect("reader reached model read");
1384 assert!(
1385 rx.recv_timeout(std::time::Duration::from_millis(50))
1386 .is_err(),
1387 "a foreign reader observed another test's temporary model override"
1388 );
1389 drop(temporary);
1390 drop(lock);
1391 (reader, expected_after_restore)
1392 };
1393
1394 let observed = rx
1395 .recv_timeout(std::time::Duration::from_secs(2))
1396 .expect("reader resumed after model override was restored");
1397 reader.join().expect("reader thread");
1398 assert_eq!(observed, expected_after_restore);
1399 }
1400
1401 #[tokio::test]
1402 async fn forced_exec_route_keeps_custom_provider_when_model_matches_builtin_catalog() {
1403 let config = custom_exec_config("custom-a");
1404
1405 let route = resolve_cli_exec_route(&config, crate::config::ZAI_GLM_5_2_MODEL, "audit", true)
1406 .await
1407 .expect("forced route");
1408 let execution = config_for_cli_route(&config, &route).expect("admitted execution route");
1409
1410 assert_eq!(route.provider.provider, crate::config::ProviderKind::Custom);
1411 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
1412 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
1413 }
1414
1415 #[tokio::test]
1416 async fn no_flag_exec_keeps_configured_named_custom_route_for_matching_builtin_model() {
1417 let mut config = custom_exec_config("custom-a");
1418 config
1419 .providers
1420 .as_mut()
1421 .expect("providers")
1422 .custom
1423 .get_mut("custom-a")
1424 .expect("custom A")
1425 .model = Some(crate::config::ZAI_GLM_5_2_MODEL.to_string());
1426 let model = resolve_exec_model(&config, None);
1427 let force = should_force_configured_exec_route(false, None, None);
1428
1429 assert!(force, "configured/default exec route must be authoritative");
1430 assert!(!should_force_configured_exec_route(
1431 false,
1432 None,
1433 Some(crate::config::ZAI_GLM_5_2_MODEL)
1434 ));
1435 assert!(should_force_configured_exec_route(
1436 false,
1437 Some("custom-a"),
1438 Some(crate::config::ZAI_GLM_5_2_MODEL)
1439 ));
1440 assert!(should_force_configured_exec_route(
1441 true,
1442 None,
1443 Some("override-model")
1444 ));
1445
1446 let route = resolve_cli_exec_route(&config, &model, "audit", force)
1447 .await
1448 .expect("no-flag configured route");
1449 let execution = config_for_cli_route(&config, &route).expect("admitted execution route");
1450 assert_eq!(route.provider.provider, crate::config::ProviderKind::Custom);
1451 assert_eq!(route.model, crate::config::ZAI_GLM_5_2_MODEL);
1452 assert_eq!(execution.provider.as_deref(), Some("custom-a"));
1453 }
1454
1455 #[tokio::test]
1456 async fn configured_review_default_keeps_named_custom_route_and_exact_receipt() {
1457 let mut config = custom_exec_config("custom-a");
1458 config
1459 .providers
1460 .as_mut()
1461 .expect("providers")
1462 .custom
1463 .get_mut("custom-a")
1464 .expect("custom A")
1465 .model = Some("model-a".to_string());
1466 config.default_text_model = Some("stale-root-deepseek-model".to_string());
1467 let model = resolve_review_model(&config, None);
1468 assert_eq!(model, "model-a");
1469 assert_eq!(
1470 resolve_review_model(&config, Some("explicit-review-model")),
1471 "explicit-review-model"
1472 );
1473
1474 let route = resolve_cli_exec_route(&config, &model, "review diff", true)
1475 .await
1476 .expect("configured review route");
1477 let execution = config_for_cli_route(&config, &route).expect("admitted execution route");
1478 let identity = execution.active_provider_identity().unwrap();
1479 let provider = identity.key.as_str();
1480
1481 assert_eq!(route.provider.provider, crate::config::ProviderKind::Custom);
1482 assert_eq!(provider, "custom-a");
1483 assert_eq!(
1484 execution.active_route_base_url(),
1485 "http://127.0.0.1:18181/v1"
1486 );
1487 let output = crate::tools::review::ReviewOutput::from_str("{}");
1488 let receipt = crate::tools::review::build_review_receipt(
1489 "working tree",
1490 "diff --git a/a b/a",
1491 provider,
1492 &route.model,
1493 &output,
1494 "{}",
1495 Vec::new(),
1496 );
1497 assert_eq!(receipt.provider, "custom-a");
1498 let serialized = serde_json::to_string(&receipt).expect("review receipt");
1499 assert!(!serialized.contains("127.0.0.1"));
1500 assert!(!serialized.contains("local-test-key"));
1501 }
1502
1503 fn review_args(argv: &[&str]) -> ReviewArgs {
1504 let cli = parse_cli(argv);
1505 let Some(Commands::Review(args)) = cli.command else {
1506 panic!("expected review command");
1507 };
1508 args
1509 }
1510
1511 #[test]
1512 fn review_parses_provider_flag_alongside_model() {
1513 let args = review_args(&[
1514 "codewhale",
1515 "review",
1516 "--pr",
1517 "5709",
1518 "--provider",
1519 "zai",
1520 "--model",
1521 "GLM-5.3",
1522 ]);
1523
1524 assert_eq!(args.provider.as_deref(), Some("zai"));
1525 assert_eq!(args.model.as_deref(), Some("GLM-5.3"));
1526 assert_eq!(args.pr, Some(5709));
1527 assert_eq!(args.max_passes, 1);
1528 // The threaded id round-trips through the provider vocabulary the
1529 // override validates against — never a model-id sniff.
1530 assert_eq!(
1531 crate::config::ProviderKind::parse(args.provider.as_deref().unwrap()),
1532 Some(crate::config::ProviderKind::Zai)
1533 );
1534 }
1535
1536 #[test]
1537 fn review_batch_passes_require_explicit_bounded_opt_in() {
1538 let args = review_args(&["codewhale", "review", "--pr", "6002", "--max-passes", "31"]);
1539 assert_eq!(args.max_passes, 31);
1540 assert!(validate_review_receipt_args(&args).is_ok());
1541 let too_many = review_args(&["codewhale", "review", "--pr", "6002", "--max-passes", "65"]);
1542 assert!(validate_review_receipt_args(&too_many).is_err());
1543 let local = review_args(&["codewhale", "review", "--max-passes", "2"]);
1544 assert!(validate_review_receipt_args(&local).is_err());
1545 }
1546
1547 #[test]
1548 fn review_failure_payload_preserves_usage_without_complete_claim() {
1549 let usage = codewhale_models::Usage {
1550 input_tokens: 24,
1551 output_tokens: 4,
1552 reasoning_tokens: Some(3),
1553 ..Default::default()
1554 };
1555 let payload = review_failure_payload(
1556 "fixture-provider",
1557 "fixture-model",
1558 &usage,
1559 1,
1560 2,
1561 ReviewPublication::NotAttempted,
1562 "second pass malformed",
1563 );
1564 assert_eq!(payload["success"], false);
1565 assert_eq!(payload["complete"], false);
1566 assert_eq!(payload["completed_review_passes"], 1);
1567 assert_eq!(payload["planned_review_passes"], 2);
1568 assert_eq!(payload["publication"], "not_attempted");
1569 assert_eq!(payload["usage"]["input_tokens"], 24);
1570 assert_eq!(payload["usage"]["reasoning_tokens"], 3);
1571 assert!(payload.get("review").is_none());
1572 assert!(payload.get("receipt").is_none());
1573 }
1574
1574 lines RUST