| 1 | //! Actual child loop, selected Native templates and an approved local route replacement. |
| 2 | use super::*; |
| 3 | use crate::extension_host::TestManagerGuard; |
| 4 | use crate::extension_host::composition_scope::NativePresetRef; |
| 5 | use crate::extension_host::protocol::EntryRef; |
| 6 | use crate::extension_host::tests::{FixturePlugins, node_for_tests}; |
| 7 | use crate::plugins::activation::{PluginActivationCapability, TestPolicyGuard}; |
| 8 | |
| 9 | #[cfg(test)] |
| 10 | fn preset(fixture: &FixturePlugins, tag: &str) -> NativePresetRef { |
| 11 | let plugins = fixture.registry(); |
| 12 | let (sources, problems) = crate::plugins::runtime::active_component_sources( |
| 13 | &plugins, |
| 14 | PluginActivationCapability::Native, |
| 15 | ); |
| 16 | assert!(problems.is_empty(), "{problems:?}"); |
| 17 | let source = sources |
| 18 | .into_iter() |
| 19 | .find(|source| source.path.ends_with(format!("{tag}.mjs"))) |
| 20 | .unwrap(); |
| 21 | NativePresetRef { |
| 22 | plugin_id: source.authority.plugin_id.to_string(), |
| 23 | content_hash: source.authority.content_hash, |
| 24 | entry: EntryRef { |
| 25 | path: source.path.to_string_lossy().into(), |
| 26 | sha256: crate::hashing::sha256_hex(std::fs::read(source.path).unwrap()), |
| 27 | }, |
| 28 | } |
| 29 | } |
| 30 | |
| 31 | #[tokio::test(flavor = "current_thread")] |
| 32 | async fn actual_child_persona_selection_and_approved_route_replace_use_current_model_and_cwd() { |
| 33 | let _home = crate::test_support::SealedHome::new(); |
| 34 | let _policy = TestPolicyGuard::extension_host(true); |
| 35 | let Some(node) = node_for_tests("actual_child_persona_selection_and_route_replace") else { |
| 36 | return; |
| 37 | }; |
| 38 | let fixture = FixturePlugins::new(&["child-persona"]).await; |
| 39 | let manager = fixture.manager(node); |
| 40 | let _manager = TestManagerGuard::install(Arc::clone(&manager)); |
| 41 | let original = fixture.registry(); |
| 42 | let a = preset(&fixture, "a"); |
| 43 | let b = preset(&fixture, "b"); |
| 44 | let parent = manager.attach(Arc::new(original.with_native_preset(a.clone()).unwrap())); |
| 45 | parent.reconcile().await.unwrap(); |
| 46 | assert_eq!( |
| 47 | parent.prompt_sections().await.unwrap()[0].text, |
| 48 | "Persona A model={{model}} cwd={{cwd}}" |
| 49 | ); |
| 50 | |
| 51 | // Reuse the existing actual HTTP chat recorder and approved-replacement |
| 52 | // config authority. No prompt store, fake Engine or alternate renderer. |
| 53 | let (backup, backup_calls, bodies, _fixture_config) = |
| 54 | delayed_chat_client(Duration::ZERO, "persona done").await; |
| 55 | let (pin_url, pin_calls) = refusing_chat_server().await; |
| 56 | let config_path = fixture.workspace().join("persona-route.toml"); |
| 57 | write_replacement_config( |
| 58 | &config_path, |
| 59 | &pin_url, |
| 60 | backup.base_url(), |
| 61 | r#" |
| 62 | [subagents.roles.reviewer] |
| 63 | model = "PinRoute/fixture-pin-model" |
| 64 | replacements = ["BackupRoute/fixture-backup-model"] |
| 65 | "#, |
| 66 | ) |
| 67 | .await; |
| 68 | let mut config = crate::config::Config::load(Some(config_path), None).unwrap(); |
| 69 | config.set_feature("extension_host", true).unwrap(); |
| 70 | let mut runtime = stub_runtime().with_api_config(config.clone()); |
| 71 | runtime.client = CodewhaleClient::new(&config).unwrap(); |
| 72 | runtime.context = ToolContext::new(fixture.workspace()) |
| 73 | .with_features(config.features()) |
| 74 | .with_plugin_registry(parent.plugin_view()); |
| 75 | runtime.manager = new_shared_subagent_manager(fixture.workspace().to_path_buf(), 2); |
| 76 | let request = |
| 77 | parse_spawn_request(&json!({"type":"reviewer","prompt":"Report your selected persona."})) |
| 78 | .unwrap(); |
| 79 | let (_, source, _) = bind_spawn_model_route(&mut runtime, &request, None, true, true) |
| 80 | .await |
| 81 | .unwrap(); |
| 82 | assert_eq!(source, SpawnRouteSource::RolePin); |
| 83 | assert_eq!(runtime.model, "fixture-pin-model"); |
| 84 | assert_eq!(runtime.route_replacements.len(), 1); |
| 85 | |
| 86 | // The parent keeps A alive while B executes under the same admitted owner. |
| 87 | // Then a second child selects A; no sibling or previous-child template leaks. |
| 88 | for (tag, selected) in [("B", b), ("A", a)] { |
| 89 | let mut assignment = request.assignment.clone(); |
| 90 | assignment.native_preset = Some(selected); |
| 91 | let result = tokio::time::timeout(Duration::from_secs(15), async { |
| 92 | let route = mint_child_route_receipt( |
| 93 | &RequestedChildRoute { |
| 94 | requested_type: request.agent_type.as_str().to_owned(), |
| 95 | requested_profile: request.profile.clone(), |
| 96 | requested_reasoning: subagent_thinking_label(request.thinking).to_owned(), |
| 97 | }, |
| 98 | &request, |
| 99 | None, |
| 100 | &runtime, |
| 101 | runtime.model.clone(), |
| 102 | source.as_str(), |
| 103 | None, |
| 104 | ) |
| 105 | .unwrap(); |
| 106 | let started = runtime |
| 107 | .manager |
| 108 | .write() |
| 109 | .await |
| 110 | .spawn_background_with_assignment_options( |
| 111 | Arc::clone(&runtime.manager), |
| 112 | runtime.clone(), |
| 113 | request.agent_type.clone(), |
| 114 | "Report your selected persona.".into(), |
| 115 | assignment, |
| 116 | Some(Vec::new()), |
| 117 | SubAgentSpawnOptions { |
| 118 | name: Some(format!("persona_child_{tag}")), |
| 119 | model: Some(runtime.model.clone()), |
| 120 | model_route: Some(ModelRoute::Fixed(runtime.model.clone())), |
| 121 | child_route: Some(route), |
| 122 | max_steps: Some(3), |
| 123 | ..Default::default() |
| 124 | }, |
| 125 | None, |
| 126 | ) |
| 127 | .expect("actual persona child admission"); |
| 128 | assert_eq!(started.workspace.as_deref(), Some(fixture.workspace())); |
| 129 | assert_eq!( |
| 130 | started.child_route.as_ref().unwrap().provider_id, |
| 131 | "PinRoute" |
| 132 | ); |
| 133 | loop { |
| 134 | let result = runtime |
| 135 | .manager |
| 136 | .read() |
| 137 | .await |
| 138 | .get_result(&started.agent_id) |
| 139 | .expect("admitted persona child remains registered"); |
| 140 | if result.status != SubAgentStatus::Running { |
| 141 | break result; |
| 142 | } |
| 143 | tokio::time::sleep(Duration::from_millis(10)).await; |
| 144 | } |
| 145 | }) |
| 146 | .await |
| 147 | .expect("local child settles"); |
| 148 | assert_eq!( |
| 149 | result.status, |
| 150 | SubAgentStatus::Completed, |
| 151 | "{:?}", |
| 152 | result.status |
| 153 | ); |
| 154 | assert_eq!(result.result.as_deref(), Some("persona done")); |
| 155 | let expected = format!( |
| 156 | "Persona {tag} model=fixture-backup-model cwd={}", |
| 157 | fixture.workspace().display() |
| 158 | ); |
| 159 | let body = bodies.lock().unwrap().last().unwrap().clone(); |
| 160 | assert_eq!(body["model"], "fixture-backup-model"); |
| 161 | assert!( |
| 162 | body.to_string().contains(&expected), |
| 163 | "the actual replacement request must include the new captured model: {body}" |
| 164 | ); |
| 165 | let other = if tag == "B" { "A" } else { "B" }; |
| 166 | assert!( |
| 167 | !body |
| 168 | .to_string() |
| 169 | .contains(&format!("Persona {other} model=")), |
| 170 | "an owner union must not cross selected entries" |
| 171 | ); |
| 172 | let checkpoint = result |
| 173 | .checkpoint |
| 174 | .as_ref() |
| 175 | .expect("actual child transcript checkpoint"); |
| 176 | assert!( |
| 177 | !checkpoint.messages.iter().any(|message| { |
| 178 | crate::runtime_handoff::is_internal_runtime_handoff(message) |
| 179 | && message.content.iter().any(|block| { |
| 180 | matches!(block, ContentBlock::Text { text, .. } |
| 181 | if text.contains("<codewhale:subagent.done>")) |
| 182 | }) |
| 183 | }), |
| 184 | "a persona child's checkpoint must not consume its root parent's sibling completions" |
| 185 | ); |
| 186 | let latest = checkpoint |
| 187 | .messages |
| 188 | .iter() |
| 189 | .rev() |
| 190 | .find_map(crate::runtime_handoff::extension_prompt_contributions_display) |
| 191 | .unwrap(); |
| 192 | assert!( |
| 193 | latest.contains(&expected), |
| 194 | "latest full snapshot follows the installed replacement: {latest}" |
| 195 | ); |
| 196 | assert!(!latest.contains("model=fixture-pin-model")); |
| 197 | } |
| 198 | assert_eq!( |
| 199 | pin_calls.load(Ordering::SeqCst), |
| 200 | 2, |
| 201 | "each explicit child tries the exact pin once" |
| 202 | ); |
| 203 | assert_eq!( |
| 204 | backup_calls.load(Ordering::SeqCst), |
| 205 | 2, |
| 206 | "each approved replacement receives one request" |
| 207 | ); |
| 208 | assert_eq!( |
| 209 | parent.prompt_sections().await.unwrap()[0].text, |
| 210 | "Persona A model={{model}} cwd={{cwd}}", |
| 211 | "Core-captured turn facts never mutate the registered template" |
| 212 | ); |
| 213 | manager.shutdown().await; |
| 214 | } |
| 215 |