diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index b1dd5bd2..fd4b067d 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -1670,4 +1670,42 @@ mod tests { "task_shell_wait should be included when allow_shell is true" ); } + + /// #2683 — `exec_wait` and `exec_interact` are legacy aliases for + /// `exec_shell_wait` and `exec_shell_interact`. They must remain + /// callable (for saved transcript replay) but hidden from the + /// model-facing catalog. + #[test] + fn shell_alias_tools_hidden_from_model_catalog() { + let tmp = tempdir().expect("tempdir"); + let ctx = ToolContext::new(tmp.path().to_path_buf()); + let registry = ToolRegistryBuilder::new().with_shell_tools().build(ctx); + + // Legacy aliases stay callable. + for alias in ["exec_wait", "exec_interact"] { + assert!(registry.contains(alias), "{alias} should remain callable"); + } + + let api_names: Vec = registry + .to_api_tools() + .into_iter() + .map(|tool| tool.name) + .collect(); + + // Canonical names are model-visible. + for canonical in ["exec_shell_wait", "exec_shell_interact"] { + assert!( + api_names.iter().any(|n| n == canonical), + "{canonical} should be model-visible" + ); + } + + // Legacy aliases are hidden. + for alias in ["exec_wait", "exec_interact"] { + assert!( + api_names.iter().all(|n| n != alias), + "{alias} should be hidden from the model catalog" + ); + } + } } diff --git a/crates/tui/src/tools/shell.rs b/crates/tui/src/tools/shell.rs index d3521d19..5d90c5eb 100644 --- a/crates/tui/src/tools/shell.rs +++ b/crates/tui/src/tools/shell.rs @@ -2755,6 +2755,11 @@ impl ToolSpec for ShellWaitTool { self.name } + fn model_visible(&self) -> bool { + // `exec_wait` is a legacy alias; only `exec_shell_wait` is model-visible. + self.name == "exec_shell_wait" + } + fn description(&self) -> &'static str { "Wait for a background shell task and return incremental output. Turn cancellation stops waiting but leaves the background task running." } @@ -2836,6 +2841,11 @@ impl ToolSpec for ShellInteractTool { self.name } + fn model_visible(&self) -> bool { + // `exec_interact` is a legacy alias; only `exec_shell_interact` is model-visible. + self.name == "exec_shell_interact" + } + fn description(&self) -> &'static str { "Send input to a background shell task and return incremental output." } diff --git a/crates/tui/src/tools/subagent/tests.rs b/crates/tui/src/tools/subagent/tests.rs index 41d4bf10..097b2323 100644 --- a/crates/tui/src/tools/subagent/tests.rs +++ b/crates/tui/src/tools/subagent/tests.rs @@ -2658,3 +2658,50 @@ fn subagent_completion_payload_carries_existing_sentinel_format() { "sentinel should not duplicate the human summary line" ); } + +/// #2683 — Verify the model-facing tool catalog only advertises canonical +/// subagent tools and never exposes legacy superseded names. +#[test] +fn model_catalog_only_advertises_canonical_subagent_tools() { + use crate::tools::ToolRegistryBuilder; + + let tmp = tempfile::tempdir().expect("tempdir"); + let runtime = stub_runtime(); + let manager = runtime.manager.clone(); + let ctx = crate::tools::spec::ToolContext::new(tmp.path().to_path_buf()); + let registry = ToolRegistryBuilder::new() + .with_subagent_tools(manager, runtime) + .build(ctx); + + let api_names: Vec = registry + .to_api_tools() + .into_iter() + .map(|t| t.name) + .collect(); + + // Canonical tools must be model-visible. + for canonical in ["agent_open", "agent_eval", "agent_close", "tool_agent"] { + assert!( + api_names.iter().any(|n| n == canonical), + "{canonical} should be in the model-facing catalog" + ); + } + + // Legacy/superseded names must NOT appear in the model catalog. + for legacy in [ + "agent_spawn", + "agent_result", + "agent_cancel", + "resume_agent", + "agent_list", + "agent_send_input", + "agent_assign", + "agent_wait", + "delegate_to_agent", + ] { + assert!( + api_names.iter().all(|n| n != legacy), + "{legacy} should be hidden from the model-facing catalog" + ); + } +}