| 1 | //! Local narrowing for the existing Engine. It grants no authority. |
| 2 | use super::*; |
| 3 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] |
| 4 | pub(crate) enum EngineHostProfile { |
| 5 | #[default] |
| 6 | Normal, |
| 7 | Acp, |
| 8 | Rlm, |
| 9 | Child, |
| 10 | } |
| 11 | impl EngineHostProfile { |
| 12 | pub(crate) fn is_acp(self) -> bool { |
| 13 | self == Self::Acp |
| 14 | } |
| 15 | } |
| 16 | /// Additional local narrowing on one exact admitted control. Neither request |
| 17 | /// JSON nor persistent configuration can choose it. Inherit preserves the base |
| 18 | /// host's ceiling (including Child); Acp never replaces that base authority. |
| 19 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] |
| 20 | pub(crate) enum TurnNarrowing { |
| 21 | #[default] |
| 22 | Inherit, |
| 23 | Acp, |
| 24 | } |
| 25 | impl TurnNarrowing { |
| 26 | pub(crate) fn is_acp(self) -> bool { |
| 27 | self == Self::Acp |
| 28 | } |
| 29 | pub(crate) fn request_fingerprint(self, ordinary: String) -> String { |
| 30 | match self { |
| 31 | Self::Inherit => ordinary, |
| 32 | Self::Acp => crate::hashing::sha256_hex(format!( |
| 33 | "runtime-turn-narrowing\u{1f}acp\u{1f}{ordinary}" |
| 34 | )), |
| 35 | } |
| 36 | } |
| 37 | } |
| 38 | impl Engine { |
| 39 | pub(super) fn is_acp_turn(&self) -> bool { |
| 40 | self.host_profile.is_acp() || self.turn_narrowing.is_acp() |
| 41 | } |
| 42 | |
| 43 | pub(super) fn acp_tool_build( |
| 44 | &self, |
| 45 | authority: &TurnAuthority, |
| 46 | route: &TurnRouteContext, |
| 47 | allowed: Option<Vec<String>>, |
| 48 | ) -> TurnToolBuild { |
| 49 | let mut context = self.build_tool_context_for_turn(authority, route); |
| 50 | let requested = self |
| 51 | .api_config |
| 52 | .sandbox_backend |
| 53 | .as_deref() |
| 54 | .is_some_and(|kind| !kind.trim().is_empty() && !kind.eq_ignore_ascii_case("none")); |
| 55 | let backend = crate::sandbox::backend::create_backend(&self.api_config) |
| 56 | .ok() |
| 57 | .flatten() |
| 58 | .filter(|backend| backend.kind() != crate::sandbox::backend::SandboxKind::Unsupported) |
| 59 | .map(Arc::from); |
| 60 | let shell = authority.allow_shell |
| 61 | && self.turn_acp_shell_ceiling.unwrap_or(authority.allow_shell) |
| 62 | && authority.mode != AppMode::Plan |
| 63 | && self.api_config.allow_shell() |
| 64 | && self.config.features.enabled(Feature::ShellTool) |
| 65 | && (!requested || backend.is_some()); |
| 66 | context = context.with_shell_policy(if shell { |
| 67 | crate::worker_profile::ShellPolicy::Full |
| 68 | } else { |
| 69 | crate::worker_profile::ShellPolicy::None |
| 70 | }); |
| 71 | if let Some(backend) = backend { |
| 72 | context = context.with_sandbox_backend(backend); |
| 73 | } |
| 74 | let mut builder = ToolRegistryBuilder::new() |
| 75 | .with_file_tools() |
| 76 | .with_search_tools() |
| 77 | .with_git_tools(); |
| 78 | if self.config.features.enabled(Feature::ApplyPatch) { |
| 79 | builder = builder.with_patch_tools(); |
| 80 | } |
| 81 | if shell { |
| 82 | builder = builder.with_foreground_shell_tools(); |
| 83 | } |
| 84 | let mut registry = builder.build(context); |
| 85 | if let Some(overrides) = self |
| 86 | .api_config |
| 87 | .tools |
| 88 | .as_ref() |
| 89 | .and_then(|tools| tools.overrides.as_ref()) |
| 90 | { |
| 91 | for name in overrides.keys() { |
| 92 | remove_acp_overridden_builtin(&mut registry, name); |
| 93 | } |
| 94 | } |
| 95 | let allowed = Some( |
| 96 | registry |
| 97 | .names() |
| 98 | .into_iter() |
| 99 | .filter(|name| tool_catalog::tool_allowed(allowed.as_deref(), name)) |
| 100 | .map(str::to_string) |
| 101 | .collect(), |
| 102 | ); |
| 103 | let catalog = self.child_host.as_ref().map_or_else( |
| 104 | || registry.to_api_tools_with_cache(true), |
| 105 | |child| { |
| 106 | child |
| 107 | .authority |
| 108 | .tools_for_model(®istry, &child.authority.agent_type) |
| 109 | }, |
| 110 | ); |
| 111 | let always_load = catalog.iter().map(|tool| tool.name.clone()).collect(); |
| 112 | TurnToolBuild { |
| 113 | surface: ToolSurfacePolicy::new( |
| 114 | registry, |
| 115 | Some(catalog), |
| 116 | authority.mode, |
| 117 | &always_load, |
| 118 | &[], |
| 119 | false, |
| 120 | allowed, |
| 121 | self.config.disallowed_tools.clone(), |
| 122 | self.config.max_tool_calls, |
| 123 | tool_catalog::ToolMode::Direct, |
| 124 | ), |
| 125 | mcp_tool_names: Vec::new(), |
| 126 | mcp: McpToolState::Disabled, |
| 127 | subagent_runtime_model: None, |
| 128 | mailbox: None, |
| 129 | plugin_tool_names: HashSet::new(), |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | fn remove_acp_overridden_builtin(registry: &mut crate::tools::ToolRegistry, tool_name: &str) { |
| 134 | let aliases: &[&str] = match tool_name { |
| 135 | "bash" | "Bash" | "exec_shell" => &["bash", "Bash", "exec_shell"], |
| 136 | "read" | "write" | "edit" | "File" | "read_file" | "write_file" | "edit_file" => &[ |
| 137 | "read", |
| 138 | "write", |
| 139 | "edit", |
| 140 | "File", |
| 141 | "read_file", |
| 142 | "write_file", |
| 143 | "edit_file", |
| 144 | ], |
| 145 | "apply_patch" => &["apply_patch"], |
| 146 | _ => std::slice::from_ref(&tool_name), |
| 147 | }; |
| 148 | for alias in aliases { |
| 149 | registry.remove_tool(alias); |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | #[cfg(test)] |
| 154 | mod tests { |
| 155 | use super::*; |
| 156 | fn fixture(workspace: &std::path::Path) -> (Engine, EngineHandle, TurnRouteContext) { |
| 157 | let api = Config { |
| 158 | allow_shell: Some(true), |
| 159 | ..Config::default() |
| 160 | } |
| 161 | .with_legacy_root(Some("local-acp-profile-fixture".into()), None); |
| 162 | let (mut engine, handle) = Engine::new( |
| 163 | EngineConfig { |
| 164 | workspace: workspace.to_path_buf(), |
| 165 | snapshots_enabled: false, |
| 166 | memory_enabled: false, |
| 167 | subagents_enabled: false, |
| 168 | ..Default::default() |
| 169 | }, |
| 170 | &api, |
| 171 | ); |
| 172 | engine.config.features.disable(Feature::Mcp); |
| 173 | engine.host_profile = EngineHostProfile::Acp; |
| 174 | let route = TurnRouteContext { |
| 175 | provider: crate::config::ProviderKind::Deepseek, |
| 176 | model: DEFAULT_TEXT_MODEL.into(), |
| 177 | capabilities: Default::default(), |
| 178 | limits: None, |
| 179 | client: engine.codewhale_client.clone(), |
| 180 | api_config: Box::new(api), |
| 181 | locale_tag: engine.config.locale_tag.clone(), |
| 182 | role_models: HashMap::new(), |
| 183 | auto_model: false, |
| 184 | reasoning_effort: None, |
| 185 | reasoning_effort_auto: false, |
| 186 | }; |
| 187 | (engine, handle, route) |
| 188 | } |
| 189 | fn authority(mode: AppMode, shell: bool) -> TurnAuthority { |
| 190 | TurnAuthority::from_effective_fields(mode, shell, false, false, ApprovalMode::Suggest) |
| 191 | } |
| 192 | #[tokio::test(flavor = "current_thread")] |
| 193 | async fn acp_catalog_and_final_authority_exclude_hidden_runtime_lifecycles() { |
| 194 | let dir = tempfile::tempdir().unwrap(); |
| 195 | let _home = crate::test_support::SealedHome::at(dir.path()); |
| 196 | let (engine, _handle, route) = fixture(dir.path()); |
| 197 | let build = engine.acp_tool_build(&authority(AppMode::Agent, false), &route, None); |
| 198 | assert!(build.surface.registry.get("read").is_some()); |
| 199 | for name in [ |
| 200 | "bash", |
| 201 | "Bash", |
| 202 | "exec_shell", |
| 203 | "exec_shell_interact", |
| 204 | "code_execution", |
| 205 | "js_execution", |
| 206 | "execute_tools", |
| 207 | "tool_search", |
| 208 | "Task", |
| 209 | "request_user_input", |
| 210 | "rlm", |
| 211 | ] { |
| 212 | assert!(build.surface.registry.get(name).is_none(), "{name}"); |
| 213 | assert!( |
| 214 | !build.surface.catalog.iter().any(|tool| tool.name == name), |
| 215 | "{name}" |
| 216 | ); |
| 217 | } |
| 218 | let unrelated = crate::tools::ToolRegistryBuilder::new() |
| 219 | .with_foreground_shell_tools() |
| 220 | .build(build.surface.registry.context().clone()); |
| 221 | let err = unrelated |
| 222 | .execute_full("bash", serde_json::json!({"command":"printf forbidden"})) |
| 223 | .await |
| 224 | .unwrap_err(); |
| 225 | assert!(matches!(err, ToolError::PermissionDenied { .. })); |
| 226 | } |
| 227 | #[tokio::test(flavor = "current_thread")] |
| 228 | async fn acp_final_cap_rejects_stateful_aliases_and_plan_writes() { |
| 229 | let dir = tempfile::tempdir().unwrap(); |
| 230 | let _home = crate::test_support::SealedHome::at(dir.path()); |
| 231 | let (engine, _handle, route) = fixture(dir.path()); |
| 232 | let build = engine.acp_tool_build(&authority(AppMode::Agent, true), &route, None); |
| 233 | for input in [ |
| 234 | serde_json::json!({"command":"sleep 5 &"}), |
| 235 | serde_json::json!({"command":"nohup true"}), |
| 236 | serde_json::json!({"command":"printf x","persist":true}), |
| 237 | serde_json::json!({"command":"printf x","interactive":true}), |
| 238 | serde_json::json!({"action":"start","command":"printf x"}), |
| 239 | ] { |
| 240 | assert!( |
| 241 | build |
| 242 | .surface |
| 243 | .registry |
| 244 | .execute_full("Bash", input) |
| 245 | .await |
| 246 | .is_err() |
| 247 | ); |
| 248 | } |
| 249 | let plan = engine.acp_tool_build(&authority(AppMode::Plan, true), &route, None); |
| 250 | assert!( |
| 251 | plan.surface |
| 252 | .registry |
| 253 | .execute_full("write", serde_json::json!({"path":"no.txt","content":"x"})) |
| 254 | .await |
| 255 | .is_err() |
| 256 | ); |
| 257 | assert!(!dir.path().join("no.txt").exists()); |
| 258 | for command in [ |
| 259 | "printf 'a&b'", |
| 260 | "printf a\\&b", |
| 261 | "true && true", |
| 262 | "printf x >&2", |
| 263 | ] { |
| 264 | assert!( |
| 265 | !crate::tools::shell::foreground_command_requests_detach(command), |
| 266 | "{command}" |
| 267 | ); |
| 268 | } |
| 269 | } |
| 270 | #[tokio::test(flavor = "current_thread")] |
| 271 | async fn acp_override_removes_all_file_and_shell_compatibility_aliases() { |
| 272 | let dir = tempfile::tempdir().unwrap(); |
| 273 | let _home = crate::test_support::SealedHome::at(dir.path()); |
| 274 | let (mut engine, _handle, route) = fixture(dir.path()); |
| 275 | engine.api_config.tools = Some(crate::config::ToolsConfig { |
| 276 | overrides: Some(HashMap::from([ |
| 277 | ("write".into(), crate::config::ToolOverride::Disabled), |
| 278 | ( |
| 279 | "exec_shell".into(), |
| 280 | crate::config::ToolOverride::Command { |
| 281 | command: "must-never-launch".into(), |
| 282 | args: None, |
| 283 | }, |
| 284 | ), |
| 285 | ])), |
| 286 | ..Default::default() |
| 287 | }); |
| 288 | let build = engine.acp_tool_build(&authority(AppMode::Agent, true), &route, None); |
| 289 | for name in [ |
| 290 | "read", |
| 291 | "write", |
| 292 | "edit", |
| 293 | "File", |
| 294 | "read_file", |
| 295 | "write_file", |
| 296 | "edit_file", |
| 297 | "bash", |
| 298 | "Bash", |
| 299 | "exec_shell", |
| 300 | ] { |
| 301 | assert!(build.surface.registry.get(name).is_none(), "{name}"); |
| 302 | } |
| 303 | } |
| 304 | #[tokio::test(flavor = "current_thread")] |
| 305 | async fn ordinary_engine_context_does_not_inherit_acp_narrowing_or_observations() { |
| 306 | let dir = tempfile::tempdir().unwrap(); |
| 307 | let _home = crate::test_support::SealedHome::at(dir.path()); |
| 308 | let (mut engine, _handle, route) = fixture(dir.path()); |
| 309 | engine.host_profile = EngineHostProfile::Normal; |
| 310 | assert_eq!( |
| 311 | engine |
| 312 | .build_tool_context_for_turn(&authority(AppMode::Agent, true), &route) |
| 313 | .acp_host, |
| 314 | None |
| 315 | ); |
| 316 | engine.host_profile = EngineHostProfile::Acp; |
| 317 | assert_eq!( |
| 318 | engine |
| 319 | .build_tool_context_for_turn(&authority(AppMode::Plan, true), &route) |
| 320 | .acp_host, |
| 321 | Some(AppMode::Plan) |
| 322 | ); |
| 323 | assert!(matches!( |
| 324 | engine.goal_continuation_if_active(), |
| 325 | GoalContinuationAction::Inactive |
| 326 | )); |
| 327 | } |
| 328 | #[tokio::test(flavor = "current_thread")] |
| 329 | async fn acp_catalogs_keep_concurrent_workspaces_and_foreground_execution_independent() { |
| 330 | let dir = tempfile::tempdir().unwrap(); |
| 331 | let _home = crate::test_support::SealedHome::at(dir.path()); |
| 332 | let a = dir.path().join("a"); |
| 333 | let b = dir.path().join("b"); |
| 334 | std::fs::create_dir(&a).unwrap(); |
| 335 | std::fs::create_dir(&b).unwrap(); |
| 336 | std::fs::write(a.join("f.txt"), "workspace-a").unwrap(); |
| 337 | std::fs::write(b.join("f.txt"), "workspace-b").unwrap(); |
| 338 | let (ea, _ha, ra) = fixture(&a); |
| 339 | let (eb, _hb, rb) = fixture(&b); |
| 340 | let ba = ea.acp_tool_build(&authority(AppMode::Agent, true), &ra, None); |
| 341 | let bb = eb.acp_tool_build(&authority(AppMode::Agent, false), &rb, None); |
| 342 | let (oa, ob) = tokio::join!( |
| 343 | ba.surface |
| 344 | .registry |
| 345 | .execute_full("read", serde_json::json!({"path":"f.txt"})), |
| 346 | bb.surface |
| 347 | .registry |
| 348 | .execute_full("read", serde_json::json!({"path":"f.txt"})) |
| 349 | ); |
| 350 | assert!(oa.unwrap().content.contains("workspace-a")); |
| 351 | assert!(ob.unwrap().content.contains("workspace-b")); |
| 352 | assert!(ba.surface.registry.get("bash").is_some()); |
| 353 | assert!(bb.surface.registry.get("bash").is_none()); |
| 354 | let output = ba |
| 355 | .surface |
| 356 | .registry |
| 357 | .execute_full( |
| 358 | "bash", |
| 359 | serde_json::json!({"command":"echo acp-foreground-marker"}), |
| 360 | ) |
| 361 | .await |
| 362 | .unwrap(); |
| 363 | assert!(output.content.contains("acp-foreground-marker")); |
| 364 | } |
| 365 | #[tokio::test(flavor = "current_thread")] |
| 366 | async fn requested_unavailable_external_sandbox_removes_acp_shell_without_fallback() { |
| 367 | let dir = tempfile::tempdir().unwrap(); |
| 368 | let _home = crate::test_support::SealedHome::at(dir.path()); |
| 369 | let (mut engine, _handle, route) = fixture(dir.path()); |
| 370 | engine.api_config.sandbox_backend = Some("unsupported-fixture-backend".into()); |
| 371 | let build = engine.acp_tool_build(&authority(AppMode::Agent, true), &route, None); |
| 372 | assert!(build.surface.registry.get("bash").is_none()); |
| 373 | assert!(build.surface.registry.get("Bash").is_none()); |
| 374 | assert_eq!( |
| 375 | build.surface.registry.context().shell_policy, |
| 376 | crate::worker_profile::ShellPolicy::None |
| 377 | ); |
| 378 | } |
| 379 | #[test] |
| 380 | fn narrowed_request_fingerprint_keeps_ordinary_historical_bytes_and_separates_acp() { |
| 381 | let historical = crate::hashing::sha256_hex("ordinary canonical payload"); |
| 382 | assert_eq!( |
| 383 | TurnNarrowing::Inherit.request_fingerprint(historical.clone()), |
| 384 | historical |
| 385 | ); |
| 386 | let narrowed = TurnNarrowing::Acp.request_fingerprint(historical.clone()); |
| 387 | assert_ne!(narrowed, historical); |
| 388 | assert_eq!(narrowed, TurnNarrowing::Acp.request_fingerprint(historical)); |
| 389 | } |
| 390 | #[tokio::test(flavor = "current_thread")] |
| 391 | async fn effective_acp_catalog_keeps_normal_base_and_initial_shell_ceiling() { |
| 392 | let dir = tempfile::tempdir().unwrap(); |
| 393 | let _home = crate::test_support::SealedHome::at(dir.path()); |
| 394 | let (mut engine, _handle, route) = fixture(dir.path()); |
| 395 | engine.host_profile = EngineHostProfile::Normal; |
| 396 | engine.config.max_steps = 97; |
| 397 | engine.config.goal_max_steps = Some(211); |
| 398 | engine.config.subagents_enabled = true; |
| 399 | engine.turn_narrowing = TurnNarrowing::Acp; |
| 400 | engine.turn_acp_shell_ceiling = Some(false); |
| 401 | let build = engine.acp_tool_build(&authority(AppMode::Agent, true), &route, None); |
| 402 | assert!( |
| 403 | build.surface.registry.get("bash").is_none(), |
| 404 | "later ordinary authority cannot raise the ACP client's initial terminal ceiling" |
| 405 | ); |
| 406 | assert_eq!(engine.host_profile, EngineHostProfile::Normal); |
| 407 | assert_eq!(engine.config.max_steps, 97); |
| 408 | assert_eq!(engine.config.goal_max_steps, Some(211)); |
| 409 | assert!(engine.config.subagents_enabled); |
| 410 | assert_eq!( |
| 411 | engine |
| 412 | .build_tool_context_for_turn(&authority(AppMode::Agent, true), &route) |
| 413 | .acp_host, |
| 414 | Some(AppMode::Agent) |
| 415 | ); |
| 416 | engine.turn_narrowing = TurnNarrowing::Inherit; |
| 417 | engine.turn_acp_shell_ceiling = None; |
| 418 | assert_eq!( |
| 419 | engine |
| 420 | .build_tool_context_for_turn(&authority(AppMode::Agent, true), &route) |
| 421 | .acp_host, |
| 422 | None |
| 423 | ); |
| 424 | } |
| 425 | #[tokio::test(flavor = "current_thread")] |
| 426 | async fn acp_defers_normal_boot_queue_without_losing_its_generation() { |
| 427 | let dir = tempfile::tempdir().unwrap(); |
| 428 | let _home = crate::test_support::SealedHome::at(dir.path()); |
| 429 | let (mut engine, _handle, route) = fixture(dir.path()); |
| 430 | engine.host_profile = EngineHostProfile::Normal; |
| 431 | engine.turn_narrowing = TurnNarrowing::Acp; |
| 432 | engine.mcp_boot_generation = Some(1); |
| 433 | engine.mcp_boot_in_flight = true; |
| 434 | engine.session.pending_prefix_change_reason = None; |
| 435 | let (tx, rx) = tokio::sync::mpsc::channel(1); |
| 436 | engine.mcp_boot_rx = Some(rx); |
| 437 | tx.try_send(McpBootUpdate::Progress { |
| 438 | generation: 1, |
| 439 | authority_errors: Arc::new(HashMap::new()), |
| 440 | connection_errors: HashMap::new(), |
| 441 | connecting: vec!["ordinary-pending".into()], |
| 442 | }) |
| 443 | .unwrap(); |
| 444 | let build = engine.acp_tool_build(&authority(AppMode::Agent, false), &route, None); |
| 445 | let mut catalog = build.surface.catalog.clone(); |
| 446 | let mut names = catalog.iter().map(|tool| tool.name.clone()).collect(); |
| 447 | engine |
| 448 | .refresh_boot_mcp_catalog(&build.surface, &mut catalog, &mut names) |
| 449 | .await; |
| 450 | assert_eq!(engine.mcp_boot_rx.as_ref().unwrap().len(), 1); |
| 451 | assert!(engine.session.pending_prefix_change_reason.is_none()); |
| 452 | engine.turn_narrowing = TurnNarrowing::Inherit; |
| 453 | engine |
| 454 | .refresh_boot_mcp_catalog(&build.surface, &mut catalog, &mut names) |
| 455 | .await; |
| 456 | assert_eq!(engine.mcp_boot_rx.as_ref().unwrap().len(), 0); |
| 457 | assert_eq!( |
| 458 | engine.session.pending_prefix_change_reason.as_deref(), |
| 459 | Some("mcp-session-boot") |
| 460 | ); |
| 461 | } |
| 462 | #[tokio::test(flavor = "current_thread")] |
| 463 | async fn real_engine_queued_normal_acp_normal_controls_keep_exact_fifo_profile() { |
| 464 | use crate::llm_client::mock::{MockLlmClient, canned}; |
| 465 | let dir = tempfile::tempdir().unwrap(); |
| 466 | let _home = crate::test_support::SealedHome::at(dir.path()); |
| 467 | let api = Config::default().with_legacy_root(Some("local-acp-queue-fixture".into()), None); |
| 468 | let model = Arc::new(MockLlmClient::new(vec![ |
| 469 | canned::simple_text_turn("ordinary first"), |
| 470 | canned::simple_text_turn("ACP middle"), |
| 471 | canned::simple_text_turn("ordinary successor"), |
| 472 | ])); |
| 473 | let (mut engine, handle) = Engine::new_with_model_client( |
| 474 | EngineConfig { |
| 475 | workspace: dir.path().to_path_buf(), |
| 476 | snapshots_enabled: false, |
| 477 | memory_enabled: false, |
| 478 | max_steps: 97, |
| 479 | goal_max_steps: Some(211), |
| 480 | subagents_enabled: true, |
| 481 | ..Default::default() |
| 482 | }, |
| 483 | &api, |
| 484 | model.clone(), |
| 485 | ); |
| 486 | engine.config.features.disable(Feature::Mcp); |
| 487 | let message = |content: &str| { |
| 488 | Op::SendMessage(TurnSpec { |
| 489 | max_output_tokens: None, |
| 490 | content: content.into(), |
| 491 | images: Vec::new(), |
| 492 | mode: AppMode::Agent, |
| 493 | route: Box::new( |
| 494 | resolve_runtime_route_for_identity( |
| 495 | &api, |
| 496 | &api.active_provider_identity().unwrap(), |
| 497 | Some(DEFAULT_TEXT_MODEL), |
| 498 | ) |
| 499 | .unwrap(), |
| 500 | ), |
| 501 | compaction: Box::new(CompactionConfig::default()), |
| 502 | initial_routed_usage: Box::default(), |
| 503 | goal_objective: None, |
| 504 | goal_token_budget: None, |
| 505 | goal_status: GoalStatus::Paused, |
| 506 | reasoning_effort: None, |
| 507 | reasoning_effort_auto: false, |
| 508 | auto_model: false, |
| 509 | allow_shell: false, |
| 510 | trust_mode: false, |
| 511 | auto_approve: false, |
| 512 | approval_mode: ApprovalMode::Suggest, |
| 513 | translation_enabled: false, |
| 514 | allowed_tools: None, |
| 515 | dynamic_tools: Vec::new(), |
| 516 | hook_executor: None, |
| 517 | verbosity: None, |
| 518 | provenance: UserInputProvenance::ExternalUser, |
| 519 | submission_id: None, |
| 520 | }) |
| 521 | }; |
| 522 | handle.send(message("ordinary first")).await.unwrap(); |
| 523 | handle.send_reserved_acp_op( |
| 524 | handle.tx_op.clone().try_reserve_owned().unwrap(), |
| 525 | message("ACP middle"), |
| 526 | ); |
| 527 | handle.send(message("ordinary successor")).await.unwrap(); |
| 528 | assert_eq!(engine.host_profile, EngineHostProfile::Normal); |
| 529 | assert!( |
| 530 | !engine.is_acp_turn(), |
| 531 | "a queued ACP operation must not narrow the current owner" |
| 532 | ); |
| 533 | let worker = tokio::spawn(Box::pin(engine.run())); |
| 534 | let mut declared_tool_changes = Vec::new(); |
| 535 | tokio::time::timeout(Duration::from_secs(30), async { |
| 536 | let mut completed = 0; |
| 537 | while completed != 3 { |
| 538 | let event = handle |
| 539 | .rx_event |
| 540 | .write() |
| 541 | .await |
| 542 | .recv() |
| 543 | .await |
| 544 | .expect("Engine event"); |
| 545 | match event { |
| 546 | Event::PrefixCacheChange { |
| 547 | changed: true, |
| 548 | tools_changed: true, |
| 549 | pin_reason, |
| 550 | .. |
| 551 | } => declared_tool_changes.push(pin_reason), |
| 552 | Event::TurnComplete { .. } => completed += 1, |
| 553 | _ => {} |
| 554 | } |
| 555 | } |
| 556 | }) |
| 557 | .await |
| 558 | .expect("three actual queued turns"); |
| 559 | assert_eq!( |
| 560 | declared_tool_changes, |
| 561 | ["change:tool_surface", "change:tool_surface"], |
| 562 | "both admitted profile transitions must have attributed prefix changes" |
| 563 | ); |
| 564 | let requests = model.captured_requests(); |
| 565 | assert_eq!(requests.len(), 3); |
| 566 | assert_ne!(requests[0].tools, requests[1].tools); |
| 567 | assert_eq!( |
| 568 | requests[0].tools, requests[2].tools, |
| 569 | "the exact queued ACP control must not narrow either ordinary neighbour" |
| 570 | ); |
| 571 | handle.send(Op::Shutdown).await.unwrap(); |
| 572 | tokio::time::timeout(Duration::from_secs(5), worker) |
| 573 | .await |
| 574 | .unwrap() |
| 575 | .unwrap(); |
| 576 | } |
| 577 | } |
| 578 |