返回 CodeWhale
roster_routes.rs
根目录 / crates / tui / src / tools / subagent / tests / roster_routes.rs
1 //! Consumer regressions for operator-visible routes (#5915/#5955).
2 use super::*;
3
4 #[tokio::test]
5 async fn roster_matches_actual_start_receipts_and_refreshes_live_role_defaults() {
6 let _env = crate::test_support::lock_test_env();
7 let root = tempdir().unwrap();
8 let (client, calls, _) = delayed_chat_client(Duration::ZERO, "done").await;
9 let config = crate::config::Config {
10 api_key: Some("test-key".into()),
11 base_url: Some(client.base_url().into()),
12 subagents: Some(crate::config::SubagentsConfig {
13 worker_model: Some("deepseek-v4-flash".into()),
14 ..Default::default()
15 }),
16 ..Default::default()
17 };
18 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 8);
19 let context = ToolContext::new(root.path()).with_state_namespace("roster-route-consumer");
20 let mut runtime = SubAgentRuntime::new(
21 client,
22 "deepseek-v4-pro".into(),
23 context.clone(),
24 false,
25 None,
26 manager.clone(),
27 )
28 .with_api_config(config);
29 runtime
30 .role_models
31 .insert("general".into(), "deepseek-v4-pro".into());
32 let tool = AgentTool::new(manager.clone(), runtime);
33 let query = tool
34 .execute(json!({"action":"roster"}), &context)
35 .await
36 .unwrap();
37 let roster: Value = serde_json::from_str(&query.content).unwrap();
38 assert_eq!(
39 calls.load(Ordering::SeqCst),
40 0,
41 "discovery must not send inference"
42 );
43 let rows = roster["members"].as_array().unwrap();
44 assert_eq!(rows.len(), 8);
45 assert_eq!(
46 rows[0]["route"]["model"], "deepseek-v4-flash",
47 "live config supersedes launch default"
48 );
49 for row in rows {
50 assert!(row["route"].is_object(), "route missing: {row}");
51 assert_eq!(row["route"]["reachability"], "unverified");
52 let role = row["role"].as_str().unwrap();
53 let mut request = json!({"action":"start", "type":role, "prompt":"Say done."});
54 if role == "custom" {
55 request["allowed_tools"] = json!(["Read"]);
56 }
57 let started = tool.execute(request, &context).await.unwrap();
58 let metadata = started.metadata.as_ref().unwrap();
59 let receipt = &metadata["child_route"];
60 for (discovery, dispatch) in [
61 ("provider", "provider_id"),
62 ("model", "model_id"),
63 ("reasoning_effort", "effective_reasoning"),
64 ("source", "route_source"),
65 ] {
66 assert_eq!(
67 row["route"][discovery], receipt[dispatch],
68 "{role}: {discovery}"
69 );
70 }
71 manager
72 .write()
73 .await
74 .cancel_agent(metadata["agent_id"].as_str().unwrap())
75 .unwrap();
76 }
77 }
78
79 #[tokio::test]
80 async fn roster_preserves_unknown_and_non_metered_costs_and_invalid_role_errors() {
81 let _env = crate::test_support::lock_test_env();
82 let _live = crate::provider_lake::lock_live_snapshot();
83 crate::provider_lake::clear_live_snapshot();
84 for (provider, model, vendor, expected_cost, reason) in [
85 ("deepseek", "deepseek-v4-flash", None, "paid", None),
86 (
87 "openrouter",
88 "qwen/qwen3.7-plus",
89 Some("cerebras"),
90 "unknown",
91 Some("routing_dependent_price"),
92 ),
93 (
94 "ollama",
95 "fixture-local-model",
96 None,
97 "not_money_metered",
98 Some("not_money_metered"),
99 ),
100 ] {
101 let root = tempdir().unwrap();
102 let mut config = crate::config::Config {
103 provider: Some(provider.into()),
104 ..Default::default()
105 };
106 let selected = config.provider_config_for_mut(ApiProvider::parse(provider).unwrap());
107 selected.api_key = Some("roster-private-fixture-key".into());
108 selected.model = Some(model.into());
109 selected.vendor = vendor.map(str::to_string);
110 let client = CodewhaleClient::new(&config).unwrap();
111 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 1);
112 let runtime = SubAgentRuntime::new(
113 client,
114 model.into(),
115 ToolContext::new(root.path()),
116 false,
117 None,
118 manager,
119 )
120 .with_api_config(config);
121 let row =
122 resolved_role_roster_entry(&runtime, &spawn_roster(&runtime), &FleetRole::Worker).await;
123 assert_eq!(
124 row["route"]["cost_class"], expected_cost,
125 "{provider}: {row}"
126 );
127 assert_eq!(row["route"]["unpriced_reason"], json!(reason));
128 assert!(!row.to_string().contains("roster-private-fixture-key"));
129 }
130 let mut runtime = stub_runtime();
131 runtime
132 .role_models
133 .insert("general".into(), "invalid\nmodel".into());
134 let row =
135 resolved_role_roster_entry(&runtime, &spawn_roster(&runtime), &FleetRole::Worker).await;
136 assert!(row["route"].is_null());
137 assert!(row["route_error"].as_str().is_some());
138 let other =
139 resolved_role_roster_entry(&runtime, &spawn_roster(&runtime), &FleetRole::Reviewer).await;
140 assert!(
141 other["route"].is_object(),
142 "one bad role must not hide other routes: {other}"
143 );
144 }
145
146 #[tokio::test]
147 async fn advertised_task_route_overrides_reach_start_and_foreign_models_fail_before_admission() {
148 let _env = crate::test_support::lock_test_env();
149 let root = tempdir().unwrap();
150 let (client, _, _) = delayed_chat_client(Duration::ZERO, "done").await;
151 let config = crate::config::Config {
152 api_key: Some("test-key".into()),
153 base_url: Some(client.base_url().into()),
154 ..Default::default()
155 };
156 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 2);
157 let context = ToolContext::new(root.path()).with_state_namespace("explicit-task-route");
158 let runtime = SubAgentRuntime::new(
159 client,
160 "deepseek-v4-flash".into(),
161 context.clone(),
162 false,
163 None,
164 manager.clone(),
165 )
166 .with_api_config(config);
167 let tool = AgentTool::new(manager.clone(), runtime);
168 let schema = tool.input_schema();
169 for field in ["model", "model_strength", "thinking"] {
170 assert!(schema["properties"].get(field).is_some());
171 }
172 let started = tool.execute(json!({"action":"start", "type":"explore", "prompt":"Say done.", "model":"deepseek-v4-pro", "model_strength":"faster", "thinking":"high"}), &context).await.unwrap();
173 let metadata = started.metadata.as_ref().unwrap();
174 let receipt = &metadata["child_route"];
175 assert_eq!(receipt["model_id"], "deepseek-v4-pro");
176 assert_eq!(receipt["route_source"], "task.model");
177 assert_eq!(receipt["effective_reasoning"], "high");
178 manager
179 .write()
180 .await
181 .cancel_agent(metadata["agent_id"].as_str().unwrap())
182 .unwrap();
183 let error = tool.execute(json!({"action":"start", "type":"explore", "prompt":"Say done.", "model":"claude-fable-5"}), &context).await.unwrap_err();
184 assert!(error.to_string().contains("provider"), "{error}");
185 }
186
187 struct ProjectProfilesGuard(bool);
188 impl ProjectProfilesGuard {
189 fn enabled() -> Self {
190 let previous = crate::fleet::roster::project_agent_profiles_enabled();
191 crate::fleet::roster::set_project_agent_profiles_enabled(true);
192 Self(previous)
193 }
194 }
195 impl Drop for ProjectProfilesGuard {
196 fn drop(&mut self) {
197 crate::fleet::roster::set_project_agent_profiles_enabled(self.0);
198 }
199 }
200
201 #[tokio::test]
202 async fn saved_profile_discovery_and_actual_start_share_current_instructions_route_and_trust() {
203 let _env = crate::test_support::lock_test_env();
204 let root = tempdir().unwrap();
205 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state"));
206 let _project = ProjectProfilesGuard::enabled();
207 let profile_dir = root.path().join(".codewhale/agents");
208 std::fs::create_dir_all(&profile_dir).unwrap();
209 let profile = profile_dir.join("bug-hunter.toml");
210 let (client, calls, bodies) = delayed_chat_client(Duration::ZERO, "done").await;
211 let config = crate::config::Config {
212 api_key: Some("test-key".into()),
213 base_url: Some(client.base_url().into()),
214 subagents: Some(crate::config::SubagentsConfig {
215 explorer_model: Some("invalid\nrole-default".into()),
216 ..Default::default()
217 }),
218 ..Default::default()
219 };
220 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 2);
221 let context = ToolContext::new(root.path()).with_state_namespace("saved-profile-consumer");
222 let runtime = SubAgentRuntime::new(
223 client,
224 "deepseek-v4-flash".into(),
225 context.clone(),
226 false,
227 None,
228 manager.clone(),
229 )
230 .with_api_config(config);
231 let tool = AgentTool::new(manager.clone(), runtime);
232 for (model, instruction) in [
233 ("deepseek-v4-pro", "Inspect only changed parser branches."),
234 ("deepseek-v4-flash", "Inspect the new queue consumer."),
235 ] {
236 std::fs::write(&profile, format!("id = \"bug-hunter\"\nbase_role = \"scout\"\nmodel = \"{model}\"\nreasoning_effort = \"high\"\npersona = \"{instruction}\"\n")).unwrap();
237 let before = calls.load(Ordering::SeqCst);
238 let discovered = tool
239 .execute(json!({"action":"roster"}), &context)
240 .await
241 .unwrap();
242 assert_eq!(
243 calls.load(Ordering::SeqCst),
244 before,
245 "roster must never infer"
246 );
247 let roster: Value = serde_json::from_str(&discovered.content).unwrap();
248 let row = roster["profiles"]
249 .as_array()
250 .unwrap()
251 .iter()
252 .find(|row| row["member_id"] == "bug-hunter")
253 .unwrap();
254 assert_eq!(row["route"]["model"], model);
255 assert_eq!(row["route"]["reasoning_effort"], "high");
256 let started = tool
257 .execute(
258 json!({"profile":"bug-hunter", "prompt":"Inspect the assigned slice."}),
259 &context,
260 )
261 .await
262 .unwrap();
263 let meta = started.metadata.as_ref().unwrap();
264 let receipt = &meta["child_route"];
265 assert_eq!(receipt["resolved_profile_id"], "bug-hunter");
266 assert_eq!(receipt["profile_origin"], "project");
267 assert_eq!(receipt["model_id"], row["route"]["model"]);
268 assert_eq!(
269 receipt["effective_reasoning"],
270 row["route"]["reasoning_effort"]
271 );
272 assert_eq!(receipt["route_source"], "agent_profile.model");
273 tokio::time::timeout(Duration::from_secs(5), async {
274 while calls.load(Ordering::SeqCst) == before {
275 tokio::time::sleep(Duration::from_millis(10)).await;
276 }
277 })
278 .await
279 .expect("local provider receives the saved profile prompt");
280 let body = bodies.lock().unwrap().last().unwrap().clone();
281 assert!(
282 body.to_string().contains(instruction),
283 "saved instructions must reach the actual request"
284 );
285 let id = meta["agent_id"].as_str().unwrap();
286 let mut guard = manager.write().await;
287 let worker = guard.worker_records.get(id).unwrap();
288 assert!(
289 worker
290 .spec
291 .launch_manifest
292 .as_ref()
293 .unwrap()
294 .prompt
295 .contains(instruction)
296 );
297 assert!(!worker.spec.runtime_profile.permissions.write);
298 if guard.agents[id].status == SubAgentStatus::Running {
299 guard.cancel_agent(id).unwrap();
300 }
301 }
302 crate::fleet::roster::set_project_agent_profiles_enabled(false);
303 let error = tool
304 .execute(
305 json!({"profile":"bug-hunter", "prompt":"Inspect."}),
306 &context,
307 )
308 .await
309 .unwrap_err();
310 assert!(
311 error.to_string().contains("Unknown Fleet role/profile"),
312 "{error}"
313 );
314 }
315
316 #[tokio::test]
317 async fn saved_provider_pin_reaches_actual_request_and_conflicts_fail_before_admission() {
318 let _env = crate::test_support::lock_test_env();
319 let root = tempdir().unwrap();
320 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state"));
321 let _project = ProjectProfilesGuard::enabled();
322 let profile_dir = root.path().join(".codewhale/agents");
323 std::fs::create_dir_all(&profile_dir).unwrap();
324 std::fs::write(profile_dir.join("router-review.toml"), "id = \"router-review\"\nbase_role = \"reviewer\"\nprovider = \"openrouter\"\nmodel = \"qwen/qwen3.7-plus\"\nreasoning_effort = \"low\"\n").unwrap();
325 let (client, calls, bodies) = delayed_chat_client(Duration::ZERO, "done").await;
326 let mut config = crate::config::Config {
327 api_key: Some("test-key".into()),
328 base_url: Some(client.base_url().into()),
329 ..Default::default()
330 };
331 let router = config.provider_config_for_mut(ApiProvider::Openrouter);
332 router.api_key = Some("test-router-key".into());
333 router.base_url = Some(client.base_url().into());
334 router.vendor = Some("cerebras".into());
335 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 2);
336 let context = ToolContext::new(root.path()).with_state_namespace("saved-provider-consumer");
337 let runtime = SubAgentRuntime::new(
338 client,
339 "deepseek-v4-flash".into(),
340 context.clone(),
341 false,
342 None,
343 manager.clone(),
344 )
345 .with_api_config(config);
346 let tool = AgentTool::new(manager.clone(), runtime);
347 for extra in [
348 json!({"model":"deepseek-v4-pro"}),
349 json!({"model_strength":"faster"}),
350 json!({"type":"builder"}),
351 ] {
352 let mut input = json!({"profile":"router-review", "prompt":"Inspect."});
353 input
354 .as_object_mut()
355 .unwrap()
356 .extend(extra.as_object().unwrap().clone());
357 assert!(tool.execute(input, &context).await.is_err());
358 }
359 assert!(manager.read().await.agents.is_empty());
360 assert_eq!(calls.load(Ordering::SeqCst), 0);
361 let discovered = tool
362 .execute(json!({"action":"roster"}), &context)
363 .await
364 .unwrap();
365 let roster: Value = serde_json::from_str(&discovered.content).unwrap();
366 let row = roster["profiles"]
367 .as_array()
368 .unwrap()
369 .iter()
370 .find(|row| row["member_id"] == "router-review")
371 .unwrap();
372 assert_eq!(row["route"]["provider"], "openrouter");
373 assert_eq!(row["route"]["openrouter_vendor"], "cerebras");
374 assert_eq!(row["route"]["cost_class"], "unknown");
375 let started = tool
376 .execute(
377 json!({"profile":"router-review", "prompt":"Say done.", "thinking":"high"}),
378 &context,
379 )
380 .await
381 .unwrap();
382 let meta = started.metadata.as_ref().unwrap();
383 assert_eq!(meta["child_route"]["provider_id"], "openrouter");
384 assert_eq!(meta["child_route"]["model_id"], "qwen/qwen3.7-plus");
385 assert_eq!(meta["child_route"]["effective_reasoning"], "high");
386 tokio::time::timeout(Duration::from_secs(5), async {
387 while calls.load(Ordering::SeqCst) == 0 {
388 tokio::time::sleep(Duration::from_millis(10)).await;
389 }
390 })
391 .await
392 .expect("local provider fixture receives child request");
393 let body = bodies.lock().unwrap()[0].clone();
394 assert_eq!(body["model"], "qwen/qwen3.7-plus");
395 assert_eq!(body["provider"]["order"], json!(["cerebras"]));
396 assert_eq!(body["provider"]["allow_fallbacks"], false);
397 assert!(
398 !serde_json::to_string(meta)
399 .unwrap()
400 .contains("test-router-key")
401 );
402 let id = meta["agent_id"].as_str().unwrap();
403 if manager.read().await.agents[id].status == SubAgentStatus::Running {
404 manager.write().await.cancel_agent(id).unwrap();
405 }
406 }
407
408 #[tokio::test]
409 async fn saved_profile_cannot_widen_parent_posture_or_depth_and_missing_provider_fails_closed() {
410 let _env = crate::test_support::lock_test_env();
411 let root = tempdir().unwrap();
412 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state"));
413 let (client, calls, _) = delayed_chat_client(Duration::ZERO, "done").await;
414 let mut profile = codewhale_config::FleetProfile::default();
415 profile.role.name = "builder".into();
416 profile.model = Some("deepseek-v4-flash".into());
417 profile.delegation.max_spawn_depth = Some(0);
418 profile.permissions.allow_shell = true;
419 profile.permissions.trust = true;
420 let mut config = crate::config::Config {
421 api_key: Some("test-key".into()),
422 base_url: Some(client.base_url().into()),
423 ..Default::default()
424 };
425 let mut fleet = codewhale_config::FleetConfigToml::default();
426 fleet
427 .profiles
428 .insert("bounded-builder".into(), profile.clone());
429 profile.provider = Some("unconfigured-private-route".into());
430 fleet.profiles.insert("missing-route".into(), profile);
431 config.fleet = Some(fleet);
432 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 2);
433 let context = ToolContext::new(root.path()).with_state_namespace("saved-profile-ceiling");
434 let mut runtime = SubAgentRuntime::new(
435 client,
436 "deepseek-v4-flash".into(),
437 context.clone(),
438 false,
439 None,
440 manager.clone(),
441 )
442 .with_api_config(config);
443 runtime.worker_profile = WorkerRuntimeProfile::for_role(FleetRole::Scout);
444 runtime.worker_profile.shell = ShellPolicy::None;
445 let tool = AgentTool::new(manager.clone(), runtime);
446 assert!(
447 tool.execute(
448 json!({"profile":"missing-route", "prompt":"Inspect."}),
449 &context
450 )
451 .await
452 .is_err()
453 );
454 assert!(manager.read().await.agents.is_empty());
455 assert_eq!(calls.load(Ordering::SeqCst), 0);
456 let started = tool
457 .execute(
458 json!({"profile":"bounded-builder", "prompt":"Inspect only.", "max_depth":2}),
459 &context,
460 )
461 .await
462 .unwrap();
463 let id = started.metadata.as_ref().unwrap()["agent_id"]
464 .as_str()
465 .unwrap();
466 let mut guard = manager.write().await;
467 let worker = guard.worker_records.get(id).unwrap();
468 assert!(!worker.spec.runtime_profile.permissions.write);
469 assert_eq!(worker.spec.runtime_profile.shell, ShellPolicy::None);
470 assert_eq!(worker.spec.runtime_profile.max_spawn_depth, 1);
471 assert_eq!(worker.spec.runtime_profile.spawn_depth, 1);
472 assert!(!worker.spec.runtime_profile.can_spawn_child());
473 guard.cancel_agent(id).unwrap();
474 }
475
476 #[tokio::test]
477 async fn selected_fleet_capability_and_broken_selection_refuse_actual_start() {
478 use crate::fleet::store::{FleetFile, FleetScope, save_fleet, set_selected};
479 let _env = crate::test_support::lock_test_env();
480 let root = tempdir().unwrap();
481 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state"));
482 let (client, calls, _) = delayed_chat_client(Duration::ZERO, "done").await;
483 let config = crate::config::Config {
484 api_key: Some("test-key".into()),
485 base_url: Some(client.base_url().into()),
486 ..Default::default()
487 };
488 let mut fleet = FleetFile::new("Capability fixture".into(), None).unwrap();
489 fleet.members.push(serde_json::from_value(json!({
490 "id":"visual-review", "role":"reviewer", "provider":"deepseek", "model":"deepseek-v4-flash", "requires":["vision"]
491 })).unwrap());
492 let path = save_fleet(&fleet, FleetScope::Workspace, root.path()).unwrap();
493 set_selected(&fleet.name, FleetScope::Workspace, root.path()).unwrap();
494 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 1);
495 let context =
496 ToolContext::new(root.path()).with_state_namespace("selected-capability-consumer");
497 let runtime = SubAgentRuntime::new(
498 client,
499 "deepseek-v4-flash".into(),
500 context.clone(),
501 false,
502 None,
503 manager.clone(),
504 )
505 .with_api_config(config);
506 let tool = AgentTool::new(manager.clone(), runtime);
507 let error = tool
508 .execute(
509 json!({"profile":"visual-review", "prompt":"Inspect image."}),
510 &context,
511 )
512 .await
513 .unwrap_err();
514 assert!(error.to_string().contains("requires vision"), "{error}");
515 assert!(manager.read().await.agents.is_empty());
516 assert_eq!(calls.load(Ordering::SeqCst), 0);
517 std::fs::write(path, "this is not a Fleet document").unwrap();
518 let error = tool
519 .execute(json!({"type":"reviewer", "prompt":"Inspect."}), &context)
520 .await
521 .unwrap_err();
522 assert!(error.to_string().contains("Selected"), "{error}");
523 assert!(manager.read().await.agents.is_empty());
524 assert_eq!(calls.load(Ordering::SeqCst), 0);
525 }
526
527 #[tokio::test]
528 async fn selected_models_reach_exact_provider_and_off_list_refuses_before_admission() {
529 use crate::fleet::store::{FleetFile, FleetScope, save_fleet, set_selected};
530 let _env = crate::test_support::lock_test_env();
531 let root = tempdir().unwrap();
532 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state"));
533 let (client, calls, bodies) = delayed_chat_client(Duration::ZERO, "done").await;
534 let mut config = crate::config::Config {
535 api_key: Some("test-key".into()),
536 base_url: Some(client.base_url().into()),
537 ..Default::default()
538 };
539 let router = config.provider_config_for_mut(ApiProvider::Openrouter);
540 router.api_key = Some("test-router-key".into());
541 router.base_url = Some(client.base_url().into());
542 router.vendor = Some("cerebras".into());
543 let mut fleet = FleetFile::new("Selected routes".into(), None).unwrap();
544 fleet.members.push(serde_json::from_value(json!({
545 "id":"review-choice", "role":"reviewer", "provider":"openrouter", "model":"qwen/qwen3.7-plus"
546 })).unwrap());
547 save_fleet(&fleet, FleetScope::Workspace, root.path()).unwrap();
548 set_selected(&fleet.name, FleetScope::Workspace, root.path()).unwrap();
549 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 2);
550 let context = ToolContext::new(root.path()).with_state_namespace("shortlist-consumer");
551 let runtime = SubAgentRuntime::new(
552 client,
553 "deepseek-v4-flash".into(),
554 context.clone(),
555 false,
556 None,
557 manager.clone(),
558 )
559 .with_api_config(config);
560 let tool = AgentTool::new(manager.clone(), runtime);
561 let roster = tool
562 .execute(json!({"action":"roster"}), &context)
563 .await
564 .unwrap();
565 let rows: Value = serde_json::from_str(&roster.content).unwrap();
566 assert_eq!(rows["model_total_count"], 1);
567 assert_eq!(
568 rows["models"][0]["selector"]["model"],
569 "openrouter/qwen/qwen3.7-plus"
570 );
571 assert_eq!(rows["models"][0]["route"]["provider"], "openrouter");
572 assert_eq!(rows["models"][0]["route"]["openrouter_vendor"], "cerebras");
573 assert_eq!(rows["models"][0]["route"]["reachability"], "unverified");
574 assert_eq!(calls.load(Ordering::SeqCst), 0, "discovery must not infer");
575 let error = tool
576 .execute(
577 json!({"type":"explore", "model":"deepseek-v4-pro", "prompt":"Inspect."}),
578 &context,
579 )
580 .await
581 .unwrap_err();
582 assert!(
583 error.to_string().contains("outside the selected Fleet"),
584 "{error}"
585 );
586 assert!(
587 error.to_string().contains("openrouter/qwen/qwen3.7-plus"),
588 "{error}"
589 );
590 assert!(
591 error.to_string().contains("deepseek/deepseek-v4-flash"),
592 "{error}"
593 );
594 assert!(manager.read().await.agents.is_empty());
595 assert_eq!(calls.load(Ordering::SeqCst), 0);
596 let started = tool
597 .execute(
598 json!({"type":"explore", "model":"openrouter/qwen/qwen3.7-plus", "prompt":"Say done."}),
599 &context,
600 )
601 .await
602 .unwrap();
603 let meta = started.metadata.as_ref().unwrap();
604 assert_eq!(meta["child_route"]["provider_id"], "openrouter");
605 assert_eq!(meta["child_route"]["model_id"], "qwen/qwen3.7-plus");
606 assert!(
607 meta["child_route"]["resolved_profile_id"].is_null(),
608 "model choice does not invent a saved profile"
609 );
610 tokio::time::timeout(Duration::from_secs(5), async {
611 while calls.load(Ordering::SeqCst) == 0 {
612 tokio::time::sleep(Duration::from_millis(10)).await;
613 }
614 })
615 .await
616 .expect("local fixture receives selected model request");
617 let body = bodies.lock().unwrap()[0].clone();
618 assert_eq!(body["model"], "qwen/qwen3.7-plus");
619 assert_eq!(body["provider"]["order"], json!(["cerebras"]));
620 assert_eq!(body["provider"]["allow_fallbacks"], false);
621 let id = meta["agent_id"].as_str().unwrap();
622 if manager.read().await.agents[id].status == SubAgentStatus::Running {
623 manager.write().await.cancel_agent(id).unwrap();
624 }
625 let session = tool
626 .execute(
627 json!({"type":"explore", "model":"deepseek/deepseek-v4-flash", "prompt":"Say done."}),
628 &context,
629 )
630 .await
631 .unwrap();
632 assert_eq!(
633 session.metadata.as_ref().unwrap()["child_route"]["provider_id"],
634 "deepseek"
635 );
636 let id = session.metadata.as_ref().unwrap()["agent_id"]
637 .as_str()
638 .unwrap();
639 if manager.read().await.agents[id].status == SubAgentStatus::Running {
640 manager.write().await.cancel_agent(id).unwrap();
641 }
642 fleet.members.clear();
643 save_fleet(&fleet, FleetScope::Workspace, root.path()).unwrap();
644 let empty = tool
645 .execute(json!({"action":"roster"}), &context)
646 .await
647 .unwrap();
648 let rows: Value = serde_json::from_str(&empty.content).unwrap();
649 assert_eq!(
650 rows["model_total_count"], 0,
651 "live removal must not preserve stale choices"
652 );
653 let error = tool
654 .execute(
655 json!({"type":"explore", "model":"openrouter/qwen/qwen3.7-plus", "prompt":"Inspect."}),
656 &context,
657 )
658 .await
659 .unwrap_err();
660 assert!(error.to_string().contains("provider DeepSeek"), "{error}");
661 }
662
663 #[tokio::test]
664 async fn shortlisted_model_on_multiple_providers_requires_exact_selector() {
665 use crate::fleet::store::{FleetFile, FleetScope, save_fleet, set_selected};
666 let _env = crate::test_support::lock_test_env();
667 let root = tempdir().unwrap();
668 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state"));
669 let (client, calls, _) = delayed_chat_client(Duration::ZERO, "done").await;
670 let mut fleet = FleetFile::new("Ambiguous routes".into(), None).unwrap();
671 for (id, provider) in [("review-a", "openrouter"), ("review-b", "openai")] {
672 fleet.members.push(
673 serde_json::from_value(json!({
674 "id":id, "role":"reviewer", "provider":provider, "model":"shared-wire-model"
675 }))
676 .unwrap(),
677 );
678 }
679 save_fleet(&fleet, FleetScope::Workspace, root.path()).unwrap();
680 set_selected(&fleet.name, FleetScope::Workspace, root.path()).unwrap();
681 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 1);
682 let context = ToolContext::new(root.path()).with_state_namespace("ambiguous-model-consumer");
683 let runtime = SubAgentRuntime::new(
684 client,
685 "deepseek-v4-flash".into(),
686 context.clone(),
687 false,
688 None,
689 manager.clone(),
690 );
691 let tool = AgentTool::new(manager.clone(), runtime);
692 let error = tool
693 .execute(
694 json!({"type":"explore", "model":"shared-wire-model", "prompt":"Inspect."}),
695 &context,
696 )
697 .await
698 .unwrap_err();
699 assert!(error.to_string().contains("multiple providers"), "{error}");
700 assert!(manager.read().await.agents.is_empty());
701 assert_eq!(calls.load(Ordering::SeqCst), 0);
702 }
703
704 fn write_restart_route_config(path: &std::path::Path, base_url: &str, role_pins: &str) {
705 std::fs::write(
706 path,
707 format!(
708 r#"
709 provider = "deepseek"
710 model = "deepseek-v4-flash"
711 api_key = "fixture-key"
712 base_url = "{base_url}"
713
714 [providers.ReviewerRoute]
715 kind = "openai-compatible"
716 api_key = "fixture-review-key"
717 base_url = "{base_url}"
718 model = "fixture-review-model"
719
720 [providers.OtherRoute]
721 kind = "openai-compatible"
722 api_key = "fixture-other-key"
723 base_url = "{base_url}"
724 model = "fixture-review-model"
725
726 {role_pins}
727 "#
728 ),
729 )
730 .unwrap();
731 }
732
733 async fn assert_admitted_route(
734 manager: &SharedSubAgentManager,
735 started: &crate::tools::spec::ToolResult,
736 expected: Value,
737 ) -> String {
738 let content: Value = serde_json::from_str(&started.content).unwrap();
739 let metadata = started.metadata.as_ref().unwrap();
740 let receipt = &metadata["child_route"];
741 assert_eq!(&content["child_route"], receipt);
742 for (field, value) in expected.as_object().unwrap() {
743 assert_eq!(&receipt[field], value, "admitted {field}: {receipt}");
744 }
745 let id = metadata["agent_id"].as_str().unwrap().to_string();
746 let manager = manager.read().await;
747 let spec = &manager.worker_records[&id].spec;
748 assert_eq!(serde_json::to_value(&spec.child_route).unwrap(), *receipt);
749 assert_eq!(spec.model, receipt["model_id"].as_str().unwrap());
750 assert_eq!(spec.agent_type.as_str(), receipt["canonical_role"]);
751 assert_eq!(manager.agents[&id].model, spec.model);
752 let manifest = spec
753 .launch_manifest
754 .as_ref()
755 .expect("persisted launch authority");
756 assert_eq!(manifest.child_id, id);
757 assert_eq!(manifest.profile, spec.runtime_profile);
758 assert_eq!(manifest.profile.role, spec.agent_type);
759 assert_eq!(
760 manifest.profile.model,
761 crate::worker_profile::ModelRoute::Fixed(spec.model.clone())
762 );
763 assert_eq!(
764 manifest.profile.provider.as_deref(),
765 receipt["provider_id"].as_str()
766 );
767 assert_eq!(
768 manifest.profile.reasoning_effort.as_deref(),
769 receipt["effective_reasoning"].as_str()
770 );
771 id
772 }
773
774 async fn wait_for_queued_child(mailbox: &mut MailboxReceiver, id: &str) {
775 tokio::time::timeout(Duration::from_secs(2), async {
776 loop {
777 let envelope = mailbox.recv().await.expect("child progress channel");
778 if matches!(envelope.message, MailboxMessage::Progress { agent_id, status }
779 if agent_id == id && status.contains("queued"))
780 {
781 break;
782 }
783 }
784 })
785 .await
786 .expect("actual child reaches the held launch gate");
787 }
788
789 #[tokio::test]
790 async fn fleet_editor_save_reload_reaches_type_only_admission_without_a_model_request() {
791 use crate::fleet::store::{
792 FleetFile, FleetScope, load_fleet_in_scope, save_fleet, set_selected,
793 };
794 use crate::tui::views::fleet_detail::FleetDetailView;
795 use crate::tui::views::{ModalView, ViewAction, ViewEvent};
796 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
797
798 let _env = crate::test_support::lock_test_env();
799 let root = tempdir().unwrap();
800 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state"));
801 let _project = ProjectProfilesGuard::enabled();
802 let (fixture_client, calls, bodies) = delayed_chat_client(Duration::ZERO, "done").await;
803 let _provider = crate::test_support::EnvVarGuard::set("CODEWHALE_PROVIDER", "deepseek");
804 let _endpoint =
805 crate::test_support::EnvVarGuard::set("CODEWHALE_BASE_URL", fixture_client.base_url());
806 let _model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "deepseek-v4-flash");
807 let config_path = root.path().join("config.toml");
808 write_restart_route_config(&config_path, fixture_client.base_url(), "");
809 let config = crate::config::Config::load(Some(config_path.clone()), None).unwrap();
810 let mut fleet = FleetFile::new("Editor restart acceptance".into(), None).unwrap();
811 fleet.members.push(
812 serde_json::from_value(json!({
813 "id":"review-pin", "role":"reviewer", "instructions":"SAVED_REVIEW_INSTRUCTION"
814 }))
815 .unwrap(),
816 );
817 save_fleet(&fleet, FleetScope::Workspace, root.path()).unwrap();
818 set_selected(&fleet.name, FleetScope::Workspace, root.path()).unwrap();
819
820 let mut app =
821 crate::tui::app::App::new(crate::test_support::test_tui_options(root.path()), &config);
822 app.workspace = root.path().to_path_buf();
823 let mut view = FleetDetailView::open_for_member(
824 &app,
825 &config,
826 &fleet.name,
827 FleetScope::Workspace,
828 Some("review-pin"),
829 )
830 .unwrap();
831 let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
832 view.handle_key(key(KeyCode::Char('e')));
833 for ch in "ReviewerRoute".chars() {
834 view.handle_key(key(KeyCode::Char(ch)));
835 }
836 view.handle_key(key(KeyCode::Enter));
837 // Off survives generic route normalization and distinguishes the saved
838 // choice from default reasoning without inventing fixture capabilities.
839 view.handle_key(key(KeyCode::Char('t')));
840 assert!(matches!(
841 view.handle_key(key(KeyCode::Char('s'))),
842 ViewAction::EmitAndClose(ViewEvent::FleetStoreChanged { .. })
843 ));
844 let (saved, _) = load_fleet_in_scope(&fleet.name, FleetScope::Workspace, root.path()).unwrap();
845 let pin = &saved.members[0];
846 assert!(!pin.shortlist);
847 assert_eq!(pin.role, "reviewer");
848 assert_eq!(pin.provider.as_deref(), Some("ReviewerRoute"));
849 assert_eq!(pin.model.as_deref(), Some("fixture-review-model"));
850 assert_eq!(pin.reasoning.as_deref(), Some("off"));
851 drop((view, app, config, fixture_client));
852
853 // Restart from the ordinary file loader; no old UI state or runtime roster survives.
854 let reloaded = crate::config::Config::load(Some(config_path), None).unwrap();
855 let client = CodewhaleClient::new(&reloaded).unwrap();
856 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 1);
857 let gate = manager.read().await.launch_gate.clone();
858 let held_permit = gate.acquire().await;
859 let (mailbox, mut mailbox_rx) = Mailbox::new(CancellationToken::new());
860 let context = ToolContext::new(root.path()).with_state_namespace("fleet-editor-restarted");
861 let mut runtime = SubAgentRuntime::new(
862 client,
863 "deepseek-v4-flash".into(),
864 context.clone(),
865 false,
866 None,
867 manager.clone(),
868 )
869 .with_api_config(reloaded);
870 runtime.mailbox = Some(mailbox);
871 let loaded_roster = spawn_roster(&runtime);
872 let loaded_pin = loaded_roster
873 .members()
874 .iter()
875 .find(|member| member.id == "review-pin")
876 .unwrap();
877 assert_eq!(loaded_pin.profile.reasoning_effort.as_deref(), Some("off"));
878 let tool = AgentTool::new(manager.clone(), runtime);
879 for extra in [
880 json!({"model":"OtherRoute/fixture-review-model"}),
881 json!({"model_strength":"faster"}),
882 ] {
883 let mut request = json!({"type":"reviewer", "prompt":"Review without execution."});
884 request
885 .as_object_mut()
886 .unwrap()
887 .extend(extra.as_object().unwrap().clone());
888 assert!(tool.execute(request, &context).await.is_err());
889 assert!(manager.read().await.agents.is_empty());
890 assert!(manager.read().await.worker_records.is_empty());
891 assert_eq!(calls.load(Ordering::SeqCst), 0);
892 }
893 let started = tool
894 .execute(
895 json!({"type":"reviewer", "prompt":"Review without execution."}),
896 &context,
897 )
898 .await
899 .unwrap();
900 let id = assert_admitted_route(
901 &manager,
902 &started,
903 json!({
904 "requested_type":"reviewer", "requested_profile":null,
905 "resolved_profile_id":"review-pin", "profile_origin":"project",
906 "canonical_role":"reviewer", "provider_id":"ReviewerRoute",
907 "model_id":"fixture-review-model", "route_source":"agent_profile.model",
908 "requested_reasoning":"inherit", "effective_reasoning":"off"
909 }),
910 )
911 .await;
912 assert!(
913 manager.read().await.worker_records[&id]
914 .spec
915 .launch_manifest
916 .as_ref()
917 .unwrap()
918 .prompt
919 .contains("SAVED_REVIEW_INSTRUCTION")
920 );
921 wait_for_queued_child(&mut mailbox_rx, &id).await;
922 assert_eq!(calls.load(Ordering::SeqCst), 0);
923 assert!(bodies.lock().unwrap().is_empty());
924 manager.write().await.cancel_agent(&id).unwrap();
925 assert_eq!(
926 manager.read().await.agents[&id].status,
927 SubAgentStatus::Cancelled
928 );
929 drop(held_permit);
930 }
931
932 #[tokio::test]
933 async fn reloaded_manual_role_pin_and_explicit_profile_keep_distinct_shortlist_receipts() {
934 use crate::fleet::store::{FleetFile, FleetScope, save_fleet, set_selected};
935 let _env = crate::test_support::lock_test_env();
936 let root = tempdir().unwrap();
937 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state"));
938 let _project = ProjectProfilesGuard::enabled();
939 let (fixture_client, calls, bodies) = delayed_chat_client(Duration::ZERO, "done").await;
940 let _provider = crate::test_support::EnvVarGuard::set("CODEWHALE_PROVIDER", "deepseek");
941 let _endpoint =
942 crate::test_support::EnvVarGuard::set("CODEWHALE_BASE_URL", fixture_client.base_url());
943 let _model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", "deepseek-v4-flash");
944 let config_path = root.path().join("config.toml");
945 write_restart_route_config(
946 &config_path,
947 fixture_client.base_url(),
948 r#"
949 [subagents.models]
950 reviewer = "deepseek-v4-flash"
951 default = "deepseek-v4-pro"
952 [subagents.roles.reviewer]
953 model = "deepseek-v4-pro"
954 "#,
955 );
956 let mut fleet = FleetFile::new("Manual pin precedence".into(), None).unwrap();
957 for member in [
958 json!({"id":"review-choice", "shortlist":true, "provider":"ReviewerRoute", "model":"fixture-review-model"}),
959 // Off stays distinct from the default on this generic custom route.
960 json!({"id":"review-pin", "role":"reviewer", "provider":"ReviewerRoute", "model":"fixture-review-model", "reasoning":"off"}),
961 ] {
962 fleet.members.push(serde_json::from_value(member).unwrap());
963 }
964 save_fleet(&fleet, FleetScope::Workspace, root.path()).unwrap();
965 set_selected(&fleet.name, FleetScope::Workspace, root.path()).unwrap();
966 let config = crate::config::Config::load(Some(config_path), None).unwrap();
967 let overrides = config.subagent_model_overrides();
968 assert_eq!(overrides["reviewer"].model, "deepseek-v4-pro");
969 assert_eq!(overrides["reviewer"].provider, None);
970 let client = CodewhaleClient::new(&config).unwrap();
971 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 1);
972 let gate = manager.read().await.launch_gate.clone();
973 let held_permit = gate.acquire().await;
974 let (mailbox, mut mailbox_rx) = Mailbox::new(CancellationToken::new());
975 let context = ToolContext::new(root.path()).with_state_namespace("manual-role-restarted");
976 let mut runtime = SubAgentRuntime::new(
977 client,
978 "deepseek-v4-flash".into(),
979 context.clone(),
980 false,
981 None,
982 manager.clone(),
983 )
984 .with_api_config(config);
985 runtime.mailbox = Some(mailbox);
986 let loaded_roster = spawn_roster(&runtime);
987 let loaded_pin = loaded_roster
988 .members()
989 .iter()
990 .find(|member| member.id == "review-pin")
991 .unwrap();
992 assert_eq!(loaded_pin.profile.reasoning_effort.as_deref(), Some("off"));
993 let tool = AgentTool::new(manager.clone(), runtime);
994 let result = tool
995 .execute(json!({"action":"roster"}), &context)
996 .await
997 .unwrap();
998 let roster: Value = serde_json::from_str(&result.content).unwrap();
999 let model = roster["models"]
1000 .as_array()
1001 .unwrap()
1002 .iter()
1003 .find(|row| row["selector"]["model"] == "ReviewerRoute/fixture-review-model")
1004 .expect("shortlisted model row");
1005 assert_eq!(model["route"]["provider"], "ReviewerRoute");
1006 assert_eq!(model["route"]["model"], "fixture-review-model");
1007 assert_ne!(model["route"]["source"], "role.pin");
1008 for role in ["general", "reviewer"] {
1009 let row = roster["members"]
1010 .as_array()
1011 .unwrap()
1012 .iter()
1013 .find(|row| row["role"] == role)
1014 .unwrap();
1015 assert_eq!(row["route"]["model"], "deepseek-v4-pro");
1016 assert_eq!(row["route"]["source"], "role.pin");
1017 }
1018 for extra in [
1019 json!({"model":"ReviewerRoute/fixture-review-model"}),
1020 json!({"model_strength":"faster"}),
1021 ] {
1022 let mut request = json!({"type":"reviewer", "prompt":"Review."});
1023 request
1024 .as_object_mut()
1025 .unwrap()
1026 .extend(extra.as_object().unwrap().clone());
1027 assert!(tool.execute(request, &context).await.is_err());
1028 assert!(manager.read().await.agents.is_empty());
1029 assert!(manager.read().await.worker_records.is_empty());
1030 }
1031 for (request, expected) in [
1032 (
1033 json!({"type":"reviewer", "prompt":"Review."}),
1034 json!({
1035 "requested_profile":null, "resolved_profile_id":null,
1036 "provider_id":"deepseek", "model_id":"deepseek-v4-pro", "route_source":"role.pin"
1037 }),
1038 ),
1039 (
1040 json!({"type":"reviewer", "model":"deepseek/deepseek-v4-pro", "prompt":"Review."}),
1041 json!({
1042 "requested_profile":null, "resolved_profile_id":null,
1043 "provider_id":"deepseek", "model_id":"deepseek-v4-pro", "route_source":"role.pin"
1044 }),
1045 ),
1046 (
1047 json!({"profile":"member:review-pin", "prompt":"Review."}),
1048 json!({
1049 "requested_profile":"member:review-pin", "resolved_profile_id":"review-pin",
1050 "provider_id":"ReviewerRoute", "model_id":"fixture-review-model",
1051 "canonical_role":"reviewer", "requested_reasoning":"inherit",
1052 "effective_reasoning":"off", "route_source":"agent_profile.model"
1053 }),
1054 ),
1055 (
1056 json!({"profile":"member:review-pin", "thinking":"low", "prompt":"Review."}),
1057 json!({
1058 "requested_profile":"member:review-pin", "resolved_profile_id":"review-pin",
1059 "provider_id":"ReviewerRoute", "model_id":"fixture-review-model",
1060 "canonical_role":"reviewer", "requested_reasoning":"low",
1061 "effective_reasoning":"high", "route_source":"agent_profile.model"
1062 }),
1063 ),
1064 ] {
1065 let started = tool.execute(request, &context).await.unwrap();
1066 let id = assert_admitted_route(&manager, &started, expected).await;
1067 wait_for_queued_child(&mut mailbox_rx, &id).await;
1068 assert_eq!(calls.load(Ordering::SeqCst), 0);
1069 assert!(bodies.lock().unwrap().is_empty());
1070 manager.write().await.cancel_agent(&id).unwrap();
1071 }
1072 drop(held_permit);
1073 }
1074
1075 #[tokio::test]
1076 async fn loaded_structured_role_routes_bind_exact_providers_and_legacy_namespaces_stay_opaque() {
1077 let _env = crate::test_support::lock_test_env();
1078 for (
1079 name,
1080 parent_provider,
1081 parent_model,
1082 declaration,
1083 pin_provider,
1084 pin_model,
1085 admitted_provider,
1086 ) in [
1087 (
1088 "structured-route",
1089 "deepseek",
1090 "deepseek-v4-flash",
1091 "[subagents.roles.reviewer]\nmodel = \"ReviewerRoute/fixture-review-model\"",
1092 Some("ReviewerRoute"),
1093 "fixture-review-model",
1094 Some("ReviewerRoute"),
1095 ),
1096 (
1097 "unknown-route",
1098 "deepseek",
1099 "deepseek-v4-flash",
1100 "[subagents.roles.reviewer]\nmodel = \"MissingRoute/fixture-review-model\"",
1101 Some("MissingRoute"),
1102 "fixture-review-model",
1103 None,
1104 ),
1105 (
1106 "legacy-namespace",
1107 "openrouter",
1108 "deepseek/deepseek-v4-flash",
1109 "[subagents.models]\nreviewer = \"deepseek/deepseek-v4-pro\"",
1110 None,
1111 "deepseek/deepseek-v4-pro",
1112 Some("openrouter"),
1113 ),
1114 ] {
1115 let root = tempdir().unwrap();
1116 let _home =
1117 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("state"));
1118 let (fixture_client, calls, bodies) = delayed_chat_client(Duration::ZERO, "done").await;
1119 let _provider =
1120 crate::test_support::EnvVarGuard::set("CODEWHALE_PROVIDER", parent_provider);
1121 let _endpoint =
1122 crate::test_support::EnvVarGuard::set("CODEWHALE_BASE_URL", fixture_client.base_url());
1123 let _model = crate::test_support::EnvVarGuard::set("CODEWHALE_MODEL", parent_model);
1124 let config_path = root.path().join("config.toml");
1125 // `deepseek` is a real configured provider as well as the legacy wire
1126 // namespace. The old map must not reinterpret that namespace as a pin.
1127 let declarations = format!(
1128 r#"
1129 [providers.deepseek]
1130 api_key = "fixture-deepseek-key"
1131 base_url = "{base_url}"
1132 model = "deepseek-v4-flash"
1133 [providers.openrouter]
1134 api_key = "fixture-router-key"
1135 base_url = "{base_url}"
1136 model = "deepseek/deepseek-v4-flash"
1137 {declaration}
1138 "#,
1139 base_url = fixture_client.base_url()
1140 );
1141 write_restart_route_config(&config_path, fixture_client.base_url(), &declarations);
1142 let config = crate::config::Config::load(Some(config_path), None).unwrap();
1143 let overrides = config.subagent_model_overrides();
1144 assert_eq!(
1145 overrides["reviewer"].provider.as_deref(),
1146 pin_provider,
1147 "{name}"
1148 );
1149 assert_eq!(overrides["reviewer"].model, pin_model, "{name}");
1150 assert_eq!(
1151 config.provider_identity_for(config.api_provider()),
1152 parent_provider
1153 );
1154 let client = CodewhaleClient::new(&config).unwrap();
1155 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 1);
1156 let gate = manager.read().await.launch_gate.clone();
1157 let held_permit = gate.acquire().await;
1158 let (mailbox, mut mailbox_rx) = Mailbox::new(CancellationToken::new());
1159 let context = ToolContext::new(root.path()).with_state_namespace(name);
1160 let mut runtime = SubAgentRuntime::new(
1161 client,
1162 parent_model.into(),
1163 context.clone(),
1164 false,
1165 None,
1166 manager.clone(),
1167 )
1168 .with_api_config(config);
1169 runtime.mailbox = Some(mailbox);
1170 let tool = AgentTool::new(manager.clone(), runtime);
1171 let roster_result = tool
1172 .execute(json!({"action":"roster"}), &context)
1173 .await
1174 .unwrap();
1175 let roster: Value = serde_json::from_str(&roster_result.content).unwrap();
1176 let row = roster["members"]
1177 .as_array()
1178 .unwrap()
1179 .iter()
1180 .find(|row| row["role"] == "reviewer")
1181 .unwrap();
1182 let request = json!({"type":"reviewer", "prompt":"Review before model execution."});
1183 let Some(expected_provider) = admitted_provider else {
1184 assert!(row["route"].is_null());
1185 assert!(
1186 row["route_error"]
1187 .as_str()
1188 .unwrap()
1189 .contains("MissingRoute")
1190 );
1191 let error = tool.execute(request, &context).await.unwrap_err();
1192 assert!(error.to_string().contains("MissingRoute"), "{error}");
1193 assert!(manager.read().await.agents.is_empty());
1194 assert!(manager.read().await.worker_records.is_empty());
1195 assert_eq!(calls.load(Ordering::SeqCst), 0);
1196 assert!(bodies.lock().unwrap().is_empty());
1197 drop(held_permit);
1198 continue;
1199 };
1200 assert_eq!(row["route"]["provider"], expected_provider, "{name}");
1201 assert_eq!(row["route"]["model"], pin_model, "{name}");
1202 assert_eq!(row["route"]["source"], "role.pin", "{name}");
1203 if pin_provider.is_some() {
1204 for extra in [
1205 json!({"model":"OtherRoute/fixture-review-model"}),
1206 json!({"model_strength":"faster"}),
1207 ] {
1208 let mut conflicting = request.clone();
1209 conflicting
1210 .as_object_mut()
1211 .unwrap()
1212 .extend(extra.as_object().unwrap().clone());
1213 let error = tool.execute(conflicting, &context).await.unwrap_err();
1214 assert!(error.to_string().contains("pins"), "{error}");
1215 assert!(manager.read().await.agents.is_empty());
1216 assert!(manager.read().await.worker_records.is_empty());
1217 assert_eq!(calls.load(Ordering::SeqCst), 0);
1218 }
1219 }
1220 let mut matching = request.clone();
1221 matching["model"] = json!(format!("{expected_provider}/{pin_model}"));
1222 for request in [request, matching] {
1223 let started = tool.execute(request, &context).await.unwrap();
1224 let id = assert_admitted_route(
1225 &manager,
1226 &started,
1227 json!({
1228 "requested_type":"reviewer", "canonical_role":"reviewer",
1229 "requested_profile":null, "resolved_profile_id":null, "profile_origin":null,
1230 "provider_id":expected_provider, "model_id":pin_model, "route_source":"role.pin"
1231 }),
1232 )
1233 .await;
1234 wait_for_queued_child(&mut mailbox_rx, &id).await;
1235 assert_eq!(calls.load(Ordering::SeqCst), 0);
1236 assert!(bodies.lock().unwrap().is_empty());
1237 manager.write().await.cancel_agent(&id).unwrap();
1238 assert_eq!(
1239 manager.read().await.agents[&id].status,
1240 SubAgentStatus::Cancelled
1241 );
1242 }
1243 drop(held_permit);
1244 }
1245 }
1246
1247 #[tokio::test]
1248 async fn issue_6117_invalid_personal_profile_is_visible_and_never_admitted_as_builtin() {
1249 let _env = crate::test_support::lock_test_env();
1250 let root = tempdir().unwrap();
1251 let home = root.path().join("state");
1252 let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &home);
1253 std::fs::create_dir_all(home.join("agents")).unwrap();
1254 let profile = home.join("agents/scout.toml");
1255 std::fs::write(&profile, "provider = \"openrouter\"\nmodel = \"qwen/qwen3.7-plus\"\nallow_shell = false\ntrust = false\n").unwrap();
1256 let (client, calls, _) = delayed_chat_client(Duration::ZERO, "done").await;
1257 let config = crate::config::Config {
1258 api_key: Some("test-key".into()),
1259 base_url: Some(client.base_url().into()),
1260 ..Default::default()
1261 };
1262 let manager = new_shared_subagent_manager(root.path().to_path_buf(), 2);
1263 let context = ToolContext::new(root.path()).with_state_namespace("issue-6117");
1264 let runtime = SubAgentRuntime::new(
1265 client,
1266 "deepseek-v4-flash".into(),
1267 context.clone(),
1268 false,
1269 None,
1270 manager.clone(),
1271 )
1272 .with_api_config(config);
1273 let tool = AgentTool::new(manager.clone(), runtime);
1274 let discovered = tool
1275 .execute(json!({"action":"roster"}), &context)
1276 .await
1277 .unwrap();
1278 let roster: Value = serde_json::from_str(&discovered.content).unwrap();
1279 assert_eq!(roster["profile_load_issue_count"], 1);
1280 assert_eq!(roster["profile_load_issues"][0]["id"], "scout");
1281 for selector in ["scout", "explore", "member:SCOUT"] {
1282 let error = tool
1283 .execute(
1284 json!({"action":"start", "profile":selector, "prompt":"Inspect."}),
1285 &context,
1286 )
1287 .await
1288 .unwrap_err();
1289 assert!(error.to_string().contains("scout.toml"), "{error}");
1290 }
1291 assert_eq!(calls.load(Ordering::SeqCst), 0);
1292 assert!(manager.read().await.agents.is_empty());
1293 std::fs::write(&profile, "base_role = \"explore\"\nprovider = \"deepseek\"\nmodel = \"deepseek-v4-pro\"\nreasoning_effort = \"low\"\n[permissions]\nallow_shell = false\ntrust = false\n").unwrap();
1294 let started = tool
1295 .execute(
1296 json!({"action":"start", "profile":"scout", "prompt":"Say done."}),
1297 &context,
1298 )
1299 .await
1300 .unwrap();
1301 let meta = started.metadata.as_ref().unwrap();
1302 let receipt = &meta["child_route"];
1303 assert_eq!(receipt["resolved_profile_id"], "scout");
1304 assert_eq!(receipt["profile_origin"], "personal");
1305 assert_eq!(receipt["provider_id"], "deepseek");
1306 assert_eq!(receipt["model_id"], "deepseek-v4-pro");
1307 assert_eq!(receipt["effective_reasoning"], "low");
1308 assert_eq!(receipt["route_source"], "agent_profile.model");
1309 let id = meta["agent_id"].as_str().unwrap();
1310 let mut guard = manager.write().await;
1311 assert!(
1312 !guard.worker_records[id]
1313 .spec
1314 .runtime_profile
1315 .permissions
1316 .write
1317 );
1318 if guard.agents[id].status == SubAgentStatus::Running {
1319 guard.cancel_agent(id).unwrap();
1320 }
1321 }
1322
1322 lines RUST