mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
38c442ca7f
## Summary - Limit `search_tool_bm25` indexing to `codex_apps` tools only, so non-Apps MCP servers are no longer discoverable through this search path. - Move search-tool discovery guidance into the `search_tool_bm25` tool description (via template include) instead of injecting it as a separate developer message. - Update Apps discovery guidance wording to clarify when to use `search_tool_bm25` for Apps-backed systems (for example Slack, Google Drive, Jira, Notion) and when to call tools directly. - Remove dead `core` helper code (`filter_codex_apps_mcp_tools` and `codex_apps_connector_id`) that is no longer used after the tool-selection refactor. - Update `core` search-tool tests to assert codex-apps-only behavior and to validate guidance from the tool description. ## Validation - ✅ `just fmt` - ✅ `cargo test -p codex-core search_tool` - ⚠️ `cargo test -p codex-core` was attempted, but the run repeatedly stalled on `tools::js_repl::tests::js_repl_can_attach_image_via_view_image_tool`. ## Tickets - None
503 lines
16 KiB
Rust
503 lines
16 KiB
Rust
#![cfg(not(target_os = "windows"))]
|
|
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
|
|
|
use std::time::Duration;
|
|
|
|
use anyhow::Result;
|
|
use codex_core::config::types::McpServerConfig;
|
|
use codex_core::config::types::McpServerTransportConfig;
|
|
use codex_core::features::Feature;
|
|
use codex_core::protocol::AskForApproval;
|
|
use codex_core::protocol::SandboxPolicy;
|
|
use core_test_support::responses::ResponsesRequest;
|
|
use core_test_support::responses::ev_assistant_message;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_function_call;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::mount_sse_sequence;
|
|
use core_test_support::responses::sse;
|
|
use core_test_support::responses::start_mock_server;
|
|
use core_test_support::skip_if_no_network;
|
|
use core_test_support::stdio_server_bin;
|
|
use core_test_support::test_codex::test_codex;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
|
|
const SEARCH_TOOL_INSTRUCTION_SNIPPETS: [&str; 2] = [
|
|
"apps tools (`mcp__codex_apps__...`) are hidden until you search for them.",
|
|
"Matching tools are added to available `tools` and available for the remainder of the current turn.",
|
|
];
|
|
|
|
fn tool_names(body: &Value) -> Vec<String> {
|
|
body.get("tools")
|
|
.and_then(Value::as_array)
|
|
.map(|tools| {
|
|
tools
|
|
.iter()
|
|
.filter_map(|tool| {
|
|
tool.get("name")
|
|
.or_else(|| tool.get("type"))
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
})
|
|
.collect()
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
fn search_tool_description(body: &Value) -> Option<String> {
|
|
body.get("tools")
|
|
.and_then(Value::as_array)
|
|
.and_then(|tools| {
|
|
tools.iter().find_map(|tool| {
|
|
if tool.get("name").and_then(Value::as_str) == Some("search_tool_bm25") {
|
|
tool.get("description")
|
|
.and_then(Value::as_str)
|
|
.map(str::to_string)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
fn search_tool_output_payload(request: &ResponsesRequest, call_id: &str) -> Value {
|
|
let (content, _success) = request
|
|
.function_call_output_content_and_success(call_id)
|
|
.expect("search_tool_bm25 function_call_output should be present");
|
|
let content = content.expect("search_tool_bm25 output should include content");
|
|
serde_json::from_str(&content).expect("search_tool_bm25 content should be valid JSON")
|
|
}
|
|
|
|
fn active_selected_tools(payload: &Value) -> Vec<String> {
|
|
payload
|
|
.get("active_selected_tools")
|
|
.and_then(Value::as_array)
|
|
.expect("active_selected_tools should be an array")
|
|
.iter()
|
|
.map(|value| {
|
|
value
|
|
.as_str()
|
|
.expect("active_selected_tools entries should be strings")
|
|
.to_string()
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn search_result_tools(payload: &Value) -> Vec<&Value> {
|
|
payload
|
|
.get("tools")
|
|
.and_then(Value::as_array)
|
|
.map(Vec::as_slice)
|
|
.unwrap_or_default()
|
|
.iter()
|
|
.collect()
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn search_tool_flag_adds_tool() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let mock = mount_sse_sequence(
|
|
&server,
|
|
vec![sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-1"),
|
|
])],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.features.enable(Feature::Apps);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn_with_policies(
|
|
"list tools",
|
|
AskForApproval::Never,
|
|
SandboxPolicy::DangerFullAccess,
|
|
)
|
|
.await?;
|
|
|
|
let body = mock.single_request().body_json();
|
|
let tools = tool_names(&body);
|
|
assert!(
|
|
tools.iter().any(|name| name == "search_tool_bm25"),
|
|
"tools list should include search_tool_bm25 when enabled: {tools:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn search_tool_adds_discovery_instructions_to_tool_description() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let mock = mount_sse_sequence(
|
|
&server,
|
|
vec![sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-1"),
|
|
])],
|
|
)
|
|
.await;
|
|
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.features.enable(Feature::Apps);
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn_with_policies(
|
|
"list tools",
|
|
AskForApproval::Never,
|
|
SandboxPolicy::DangerFullAccess,
|
|
)
|
|
.await?;
|
|
|
|
let body = mock.single_request().body_json();
|
|
let search_tool_description =
|
|
search_tool_description(&body).expect("search tool description should be present");
|
|
assert!(
|
|
SEARCH_TOOL_INSTRUCTION_SNIPPETS
|
|
.iter()
|
|
.all(|snippet| search_tool_description.contains(snippet)),
|
|
"search tool description should include search tool workflow: {search_tool_description:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn search_tool_hides_mcp_tools_without_search() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let mock = mount_sse_sequence(
|
|
&server,
|
|
vec![sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-1"),
|
|
])],
|
|
)
|
|
.await;
|
|
|
|
let rmcp_test_server_bin = stdio_server_bin()?;
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.features.enable(Feature::Apps);
|
|
let mut servers = config.mcp_servers.get().clone();
|
|
servers.insert(
|
|
"rmcp".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::Stdio {
|
|
command: rmcp_test_server_bin,
|
|
args: Vec::new(),
|
|
env: None,
|
|
env_vars: Vec::new(),
|
|
cwd: None,
|
|
},
|
|
enabled: true,
|
|
required: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: Some(Duration::from_secs(10)),
|
|
tool_timeout_sec: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
},
|
|
);
|
|
config
|
|
.mcp_servers
|
|
.set(servers)
|
|
.expect("test mcp servers should accept any configuration");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn_with_policies(
|
|
"hello tools",
|
|
AskForApproval::Never,
|
|
SandboxPolicy::DangerFullAccess,
|
|
)
|
|
.await?;
|
|
|
|
let body = mock.single_request().body_json();
|
|
let tools = tool_names(&body);
|
|
assert!(
|
|
tools.iter().any(|name| name == "search_tool_bm25"),
|
|
"tools list should include search_tool_bm25 when enabled: {tools:?}"
|
|
);
|
|
assert!(
|
|
!tools.iter().any(|name| name == "mcp__rmcp__echo"),
|
|
"tools list should not include MCP tools before search: {tools:?}"
|
|
);
|
|
assert!(
|
|
!tools.iter().any(|name| name == "mcp__rmcp__image"),
|
|
"tools list should not include MCP tools before search: {tools:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn search_tool_selection_persists_within_turn_and_resets_next_turn() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let call_id = "tool-search";
|
|
let args = json!({
|
|
"query": "echo",
|
|
"limit": 1,
|
|
});
|
|
let responses = vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(call_id, "search_tool_bm25", &serde_json::to_string(&args)?),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_assistant_message("msg-2", "done again"),
|
|
ev_completed("resp-3"),
|
|
]),
|
|
];
|
|
let mock = mount_sse_sequence(&server, responses).await;
|
|
|
|
let rmcp_test_server_bin = stdio_server_bin()?;
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.features.enable(Feature::Apps);
|
|
let mut servers = config.mcp_servers.get().clone();
|
|
servers.insert(
|
|
"rmcp".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::Stdio {
|
|
command: rmcp_test_server_bin,
|
|
args: Vec::new(),
|
|
env: None,
|
|
env_vars: Vec::new(),
|
|
cwd: None,
|
|
},
|
|
enabled: true,
|
|
required: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: Some(Duration::from_secs(10)),
|
|
tool_timeout_sec: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
},
|
|
);
|
|
config
|
|
.mcp_servers
|
|
.set(servers)
|
|
.expect("test mcp servers should accept any configuration");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn_with_policies(
|
|
"find the echo tool",
|
|
AskForApproval::Never,
|
|
SandboxPolicy::DangerFullAccess,
|
|
)
|
|
.await?;
|
|
test.submit_turn_with_policies(
|
|
"hello again",
|
|
AskForApproval::Never,
|
|
SandboxPolicy::DangerFullAccess,
|
|
)
|
|
.await?;
|
|
|
|
let requests = mock.requests();
|
|
assert_eq!(
|
|
requests.len(),
|
|
3,
|
|
"expected 3 requests, got {}",
|
|
requests.len()
|
|
);
|
|
|
|
let first_tools = tool_names(&requests[0].body_json());
|
|
assert!(
|
|
!first_tools.iter().any(|name| name == "mcp__rmcp__echo"),
|
|
"first request should not include MCP tools before search: {first_tools:?}"
|
|
);
|
|
|
|
let second_tools = tool_names(&requests[1].body_json());
|
|
assert!(
|
|
!second_tools.iter().any(|name| name == "mcp__rmcp__echo"),
|
|
"second request should not include rmcp MCP tools after search: {second_tools:?}"
|
|
);
|
|
assert!(
|
|
!second_tools.iter().any(|name| name == "mcp__rmcp__image"),
|
|
"second request should not include rmcp MCP tools after search: {second_tools:?}"
|
|
);
|
|
|
|
let search_output_payload = search_tool_output_payload(&requests[1], call_id);
|
|
assert!(
|
|
search_output_payload.get("selected_tools").is_none(),
|
|
"selected_tools should not be returned: {search_output_payload:?}"
|
|
);
|
|
for tool in search_result_tools(&search_output_payload) {
|
|
assert_eq!(
|
|
tool.get("server").and_then(Value::as_str),
|
|
Some("codex_apps"),
|
|
"search results should only include codex_apps tools: {search_output_payload:?}"
|
|
);
|
|
}
|
|
assert!(
|
|
!active_selected_tools(&search_output_payload)
|
|
.iter()
|
|
.any(|tool_name| tool_name.starts_with("mcp__rmcp__")),
|
|
"search should not add rmcp tools to active selection: {search_output_payload:?}"
|
|
);
|
|
|
|
let third_tools = tool_names(&requests[2].body_json());
|
|
assert!(
|
|
!third_tools.iter().any(|name| name == "mcp__rmcp__echo"),
|
|
"third request should not include MCP tools after turn reset: {third_tools:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn search_tool_selection_unions_results_within_turn() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
|
|
let server = start_mock_server().await;
|
|
let first_call_id = "tool-search-echo";
|
|
let second_call_id = "tool-search-image";
|
|
let first_args = json!({
|
|
"query": "echo",
|
|
"limit": 1,
|
|
});
|
|
let second_args = json!({
|
|
"query": "image",
|
|
"limit": 1,
|
|
});
|
|
let responses = vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(
|
|
first_call_id,
|
|
"search_tool_bm25",
|
|
&serde_json::to_string(&first_args)?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_function_call(
|
|
second_call_id,
|
|
"search_tool_bm25",
|
|
&serde_json::to_string(&second_args)?,
|
|
),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-3"),
|
|
]),
|
|
];
|
|
let mock = mount_sse_sequence(&server, responses).await;
|
|
|
|
let rmcp_test_server_bin = stdio_server_bin()?;
|
|
let mut builder = test_codex().with_config(move |config| {
|
|
config.features.enable(Feature::Apps);
|
|
let mut servers = config.mcp_servers.get().clone();
|
|
servers.insert(
|
|
"rmcp".to_string(),
|
|
McpServerConfig {
|
|
transport: McpServerTransportConfig::Stdio {
|
|
command: rmcp_test_server_bin,
|
|
args: Vec::new(),
|
|
env: None,
|
|
env_vars: Vec::new(),
|
|
cwd: None,
|
|
},
|
|
enabled: true,
|
|
required: false,
|
|
disabled_reason: None,
|
|
startup_timeout_sec: Some(Duration::from_secs(10)),
|
|
tool_timeout_sec: None,
|
|
enabled_tools: None,
|
|
disabled_tools: None,
|
|
scopes: None,
|
|
},
|
|
);
|
|
config
|
|
.mcp_servers
|
|
.set(servers)
|
|
.expect("test mcp servers should accept any configuration");
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn_with_policies(
|
|
"find echo and image tools",
|
|
AskForApproval::Never,
|
|
SandboxPolicy::DangerFullAccess,
|
|
)
|
|
.await?;
|
|
|
|
let requests = mock.requests();
|
|
assert_eq!(
|
|
requests.len(),
|
|
3,
|
|
"expected 3 requests, got {}",
|
|
requests.len()
|
|
);
|
|
|
|
let first_tools = tool_names(&requests[0].body_json());
|
|
assert!(
|
|
!first_tools.iter().any(|name| name == "mcp__rmcp__echo"),
|
|
"first request should not include MCP tools before search: {first_tools:?}"
|
|
);
|
|
|
|
let second_tools = tool_names(&requests[1].body_json());
|
|
assert!(
|
|
!second_tools.iter().any(|name| name == "mcp__rmcp__echo"),
|
|
"second request should not include rmcp tools after first search: {second_tools:?}"
|
|
);
|
|
assert!(
|
|
!second_tools.iter().any(|name| name == "mcp__rmcp__image"),
|
|
"second request should not include rmcp tools after first search: {second_tools:?}"
|
|
);
|
|
|
|
let third_tools = tool_names(&requests[2].body_json());
|
|
assert!(
|
|
!third_tools.iter().any(|name| name == "mcp__rmcp__echo"),
|
|
"third request should not include rmcp tools: {third_tools:?}"
|
|
);
|
|
assert!(
|
|
!third_tools.iter().any(|name| name == "mcp__rmcp__image"),
|
|
"third request should not include rmcp tools: {third_tools:?}"
|
|
);
|
|
|
|
let second_search_payload = search_tool_output_payload(&requests[2], second_call_id);
|
|
assert!(
|
|
second_search_payload.get("selected_tools").is_none(),
|
|
"selected_tools should not be returned: {second_search_payload:?}"
|
|
);
|
|
for tool in search_result_tools(&second_search_payload) {
|
|
assert_eq!(
|
|
tool.get("server").and_then(Value::as_str),
|
|
Some("codex_apps"),
|
|
"search results should only include codex_apps tools: {second_search_payload:?}"
|
|
);
|
|
}
|
|
assert!(
|
|
!active_selected_tools(&second_search_payload)
|
|
.iter()
|
|
.any(|tool_name| tool_name.starts_with("mcp__rmcp__")),
|
|
"search should not add rmcp tools to active selection: {second_search_payload:?}"
|
|
);
|
|
|
|
Ok(())
|
|
}
|