mirror of
https://github.com/openai/codex.git
synced 2026-10-31 19:28:49 +00:00
## Why Guardian review needs the original function-call metadata even when the call is no longer in live conversation history. Looking it up in history can lose the evidence needed to review encrypted multi-agent messages. ## What changed Capture native-review policy in the tool registry and retain the original call through dispatch for multi-agent tools that require review. Use that retained call for Guardian review, applying hook-rewritten arguments while preserving encrypted-argument metadata and clearing stale replay content. Limit `ToolCall` debug output to the tool name and call ID. ## Testing Add coverage for selective call retention across review settings and namespaces. Extend Guardian tests to cover calls absent from live history and hook rewrites that preserve call identity and encrypted-argument metadata across incremental reviews. GitOrigin-RevId: 0e49833741c09c29228440bddd2e12695ae99583
743 lines
26 KiB
Rust
743 lines
26 KiB
Rust
use std::collections::BTreeMap;
|
|
use std::sync::Arc;
|
|
|
|
use crate::config::Config;
|
|
use crate::session::step_context::StepContext;
|
|
use crate::session::tests::make_session_and_context;
|
|
use crate::tools::context::ToolPayload;
|
|
use crate::tools::handlers::McpHandler;
|
|
use crate::tools::registry::CoreToolRuntime;
|
|
use crate::tools::registry::RegisteredTool;
|
|
use crate::tools::registry::ToolExposure;
|
|
use crate::tools::spec_plan::append_source_tools;
|
|
use crate::tools::spec_plan::build_core_tool_registry;
|
|
use crate::tools::spec_plan::extension_tool_executors;
|
|
use crate::turn_diff_tracker::TurnDiffTracker;
|
|
use codex_extension_api::ExtensionData;
|
|
use codex_extension_api::ExtensionRegistry;
|
|
use codex_extension_api::ExtensionRegistryBuilder;
|
|
use codex_extension_api::ResponsesApiTool;
|
|
use codex_extension_api::ToolCall as ExtensionToolCall;
|
|
use codex_extension_api::ToolExecutor;
|
|
use codex_protocol::DEFAULT_FUNCTION_NAMESPACE;
|
|
use codex_protocol::dynamic_tools::DynamicToolFunctionSpec;
|
|
use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec;
|
|
use codex_protocol::dynamic_tools::DynamicToolNamespaceTool;
|
|
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
|
use codex_protocol::models::ContentItem;
|
|
use codex_protocol::models::FunctionCallOutputBody;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_tools::ResponsesApiNamespace;
|
|
use codex_tools::ResponsesApiNamespaceTool;
|
|
use codex_tools::ToolName;
|
|
use codex_tools::ToolSpec;
|
|
use codex_tools::default_namespace_description;
|
|
use core_test_support::responses::strip_response_item_ids_from_json;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
use super::ToolCall;
|
|
use super::ToolCallSource;
|
|
use super::ToolRouter;
|
|
use super::tool_log_payload;
|
|
|
|
struct ExtensionEchoContributor;
|
|
|
|
#[tokio::test]
|
|
async fn build_tool_call_retains_native_evidence_only_for_reviewed_mav2_tools() {
|
|
for review_enabled in [false, true] {
|
|
for namespace in [None, Some("team")] {
|
|
let (_, mut turn) = make_session_and_context().await;
|
|
turn.multi_agent_version = codex_protocol::protocol::MultiAgentVersion::V2;
|
|
let config = Arc::make_mut(&mut turn.config);
|
|
config.multi_agent_v2.tool_namespace = namespace.map(str::to_owned);
|
|
if review_enabled {
|
|
config
|
|
.features
|
|
.enable(codex_features::Feature::GuardianNativeMultiAgentHistory)
|
|
.unwrap();
|
|
} else {
|
|
config
|
|
.features
|
|
.disable(codex_features::Feature::GuardianNativeMultiAgentHistory)
|
|
.unwrap();
|
|
}
|
|
let step = StepContext::for_test(Arc::new(turn));
|
|
let router = test_tool_router(&step, Vec::new(), Vec::new(), &[]);
|
|
for (name, mutating) in [
|
|
("spawn_agent", true),
|
|
("send_message", true),
|
|
("followup_task", true),
|
|
("interrupt_agent", true),
|
|
("list_agents", false),
|
|
("wait_agent", false),
|
|
] {
|
|
let item: ResponseItem = serde_json::from_value(json!({
|
|
"type": "function_call",
|
|
"id": "fc_review",
|
|
"call_id": "call-review",
|
|
"namespace": namespace,
|
|
"name": name,
|
|
"arguments": r#"{"message":"opaque-message"}"#,
|
|
"encrypted_content": "opaque-replay",
|
|
"encrypted_function_args": ["message"],
|
|
}))
|
|
.unwrap();
|
|
let call = router.build_tool_call(item.clone()).unwrap().unwrap();
|
|
assert_eq!(
|
|
call.native_call.as_deref(),
|
|
(review_enabled && mutating).then_some(&item),
|
|
"{name}, namespace={namespace:?}, review_enabled={review_enabled}",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tool_log_payload_redacts_plaintext_multi_agent_messages() {
|
|
let payload = ToolPayload::Function {
|
|
arguments: json!({"target": "/root/worker", "message": "secret message"}).to_string(),
|
|
};
|
|
assert_eq!(
|
|
tool_log_payload(&payload, &ToolCallSource::DirectPlaintextMessage),
|
|
"[plaintext arguments]"
|
|
);
|
|
assert_eq!(
|
|
tool_log_payload(&payload, &ToolCallSource::Direct),
|
|
payload.log_payload()
|
|
);
|
|
}
|
|
|
|
impl codex_extension_api::ToolContributor for ExtensionEchoContributor {
|
|
fn tools(
|
|
&self,
|
|
_session_store: &ExtensionData,
|
|
_thread_store: &ExtensionData,
|
|
) -> Vec<Arc<dyn for<'call> ToolExecutor<ExtensionToolCall<'call>>>> {
|
|
vec![Arc::new(ExtensionEchoExecutor)]
|
|
}
|
|
}
|
|
|
|
struct ExtensionEchoExecutor;
|
|
|
|
impl<'call> ToolExecutor<ExtensionToolCall<'call>> for ExtensionEchoExecutor {
|
|
fn tool_name(&self) -> ToolName {
|
|
ToolName::namespaced("extension/", "echo")
|
|
}
|
|
|
|
fn spec(&self) -> ToolSpec {
|
|
ToolSpec::Namespace(ResponsesApiNamespace {
|
|
name: "extension/".to_string(),
|
|
description: default_namespace_description("extension/"),
|
|
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
|
|
name: "echo".to_string(),
|
|
description: "Echoes arguments through an extension tool.".to_string(),
|
|
strict: true,
|
|
parameters: codex_extension_api::parse_tool_input_schema(&json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"message": { "type": "string" },
|
|
},
|
|
"required": ["message"],
|
|
"additionalProperties": false,
|
|
}))
|
|
.expect("extension schema should parse"),
|
|
output_schema: None,
|
|
defer_loading: None,
|
|
})],
|
|
})
|
|
}
|
|
|
|
fn handle<'a>(&'a self, call: ExtensionToolCall<'call>) -> codex_tools::ToolExecutorFuture<'a>
|
|
where
|
|
'call: 'a,
|
|
{
|
|
Box::pin(self.handle_call(call))
|
|
}
|
|
}
|
|
|
|
impl ExtensionEchoExecutor {
|
|
async fn handle_call(
|
|
&self,
|
|
call: ExtensionToolCall<'_>,
|
|
) -> Result<Box<dyn codex_tools::ToolOutput>, codex_tools::FunctionCallError> {
|
|
let arguments: serde_json::Value =
|
|
serde_json::from_str(call.function_arguments()?).expect("test arguments should parse");
|
|
Ok(Box::new(codex_tools::JsonToolOutput::new(json!({
|
|
"arguments": arguments,
|
|
"callId": call.call_id,
|
|
"conversationHistory": call.conversation_history.items(),
|
|
"ok": true,
|
|
}))) as Box<dyn codex_tools::ToolOutput>)
|
|
}
|
|
}
|
|
|
|
fn extension_tool_test_registry() -> Arc<ExtensionRegistry<Config>> {
|
|
let mut builder = ExtensionRegistryBuilder::new();
|
|
builder.tool_contributor(Arc::new(ExtensionEchoContributor));
|
|
Arc::new(builder.build())
|
|
}
|
|
|
|
fn test_tool_router(
|
|
step_context: &StepContext,
|
|
mcp_tools: Vec<RegisteredTool>,
|
|
extension_tool_executors: impl IntoIterator<
|
|
Item = Arc<dyn for<'call> ToolExecutor<ExtensionToolCall<'call>>>,
|
|
>,
|
|
dynamic_tools: &[DynamicToolSpec],
|
|
) -> ToolRouter {
|
|
let mut registry = build_core_tool_registry(
|
|
step_context.turn.as_ref(),
|
|
step_context.turn.model_info(),
|
|
&step_context.environments,
|
|
step_context.mcp.as_ref(),
|
|
/*tool_suggest_candidates*/ None,
|
|
/*wait_for_environment_tool_config*/ None,
|
|
);
|
|
let hosted_specs = append_source_tools(
|
|
step_context.turn.as_ref(),
|
|
step_context.turn.model_info(),
|
|
&mut registry,
|
|
mcp_tools,
|
|
extension_tool_executors,
|
|
dynamic_tools,
|
|
);
|
|
ToolRouter::from_registry(
|
|
step_context.turn.as_ref(),
|
|
step_context.turn.model_info(),
|
|
registry,
|
|
hosted_specs,
|
|
&Default::default(),
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow::Result<()> {
|
|
let (session, turn) = make_session_and_context().await;
|
|
let turn = Arc::new(turn);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn));
|
|
let router = test_tool_router(
|
|
step_context.as_ref(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
session.dynamic_tools().await.as_ref(),
|
|
);
|
|
|
|
let parallel_tool_name = ["exec_command"]
|
|
.into_iter()
|
|
.find(|name| {
|
|
router.tool_supports_parallel(&ToolCall {
|
|
tool_name: ToolName::plain(*name),
|
|
call_id: "call-parallel-tool".to_string(),
|
|
payload: ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
},
|
|
encrypted_function_args: None,
|
|
native_call: None,
|
|
})
|
|
})
|
|
.expect("test session should expose a parallel shell-like tool");
|
|
|
|
assert_eq!(
|
|
router
|
|
.registered_tool(&ToolName::plain(parallel_tool_name))
|
|
.map(|tool| tool.runtime.tool_name()),
|
|
Some(ToolName::plain(parallel_tool_name))
|
|
);
|
|
|
|
assert!(!router.tool_supports_parallel(&ToolCall {
|
|
tool_name: ToolName::namespaced("mcp__server__", parallel_tool_name),
|
|
call_id: "call-namespaced-tool".to_string(),
|
|
payload: ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
},
|
|
encrypted_function_args: None,
|
|
native_call: None,
|
|
}));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()> {
|
|
let (_, turn) = make_session_and_context().await;
|
|
let step = StepContext::for_test(Arc::new(turn));
|
|
let tool_name = "create_event".to_string();
|
|
|
|
let call_item = ResponseItem::FunctionCall {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
name: tool_name.clone(),
|
|
namespace: Some("mcp__codex_apps__calendar".to_string()),
|
|
arguments: "{}".to_string(),
|
|
encrypted_function_args: Some(Vec::new()),
|
|
call_id: "call-namespace".to_string(),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
};
|
|
let call = step
|
|
.tool_router
|
|
.build_tool_call(call_item)?
|
|
.expect("function_call should produce a tool call");
|
|
|
|
assert_eq!(
|
|
call.tool_name,
|
|
ToolName::namespaced("mcp__codex_apps__calendar", tool_name)
|
|
);
|
|
assert_eq!(call.call_id, "call-namespace");
|
|
assert_eq!(call.encrypted_function_args, Some(Vec::new()));
|
|
assert_eq!(call.direct_source(), ToolCallSource::Direct);
|
|
match call.payload {
|
|
ToolPayload::Function { arguments } => {
|
|
assert_eq!(arguments, "{}");
|
|
}
|
|
other => panic!("expected function payload, got {other:?}"),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_custom_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()> {
|
|
let (_, turn) = make_session_and_context().await;
|
|
let step = StepContext::for_test(Arc::new(turn));
|
|
let tool_name = "exec".to_string();
|
|
|
|
let call_item = ResponseItem::CustomToolCall {
|
|
encrypted_content: None,
|
|
id: None,
|
|
status: None,
|
|
call_id: "call-namespace".to_string(),
|
|
name: tool_name.clone(),
|
|
namespace: Some("mcp__python".to_string()),
|
|
input: "print('hello')".to_string(),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
};
|
|
let call = step
|
|
.tool_router
|
|
.build_tool_call(call_item)?
|
|
.expect("custom_tool_call should produce a tool call");
|
|
|
|
assert_eq!(
|
|
call,
|
|
ToolCall {
|
|
tool_name: ToolName::namespaced("mcp__python", tool_name),
|
|
call_id: "call-namespace".to_string(),
|
|
payload: ToolPayload::Custom {
|
|
input: "print('hello')".to_string(),
|
|
},
|
|
encrypted_function_args: None,
|
|
native_call: None,
|
|
}
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn build_tool_call_normalizes_default_function_and_custom_namespaces() -> anyhow::Result<()> {
|
|
let (_, turn) = make_session_and_context().await;
|
|
let step = StepContext::for_test(Arc::new(turn));
|
|
for namespace in [None, Some(""), Some(DEFAULT_FUNCTION_NAMESPACE)] {
|
|
let function_call_item = ResponseItem::FunctionCall {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
name: "lookup".to_string(),
|
|
namespace: namespace.map(str::to_string),
|
|
arguments: "{}".to_string(),
|
|
encrypted_function_args: None,
|
|
call_id: "call-function".to_string(),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
};
|
|
let function_call = step
|
|
.tool_router
|
|
.build_tool_call(function_call_item)?
|
|
.expect("function_call should produce a tool call");
|
|
let custom_call_item = ResponseItem::CustomToolCall {
|
|
encrypted_content: None,
|
|
id: None,
|
|
status: None,
|
|
call_id: "call-custom".to_string(),
|
|
name: "apply_patch".to_string(),
|
|
namespace: namespace.map(str::to_string),
|
|
input: "patch".to_string(),
|
|
internal_chat_message_metadata_passthrough: None,
|
|
};
|
|
let custom_call = step
|
|
.tool_router
|
|
.build_tool_call(custom_call_item)?
|
|
.expect("custom_tool_call should produce a tool call");
|
|
|
|
assert_eq!(
|
|
[function_call.tool_name, custom_call.tool_name],
|
|
[
|
|
ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "lookup"),
|
|
ToolName::namespaced(DEFAULT_FUNCTION_NAMESPACE, "apply_patch"),
|
|
]
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn mcp_parallel_support_uses_handler_data() -> anyhow::Result<()> {
|
|
let (session, turn) = make_session_and_context().await;
|
|
let turn = Arc::new(turn);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn));
|
|
let router = test_tool_router(
|
|
step_context.as_ref(),
|
|
vec![
|
|
mcp_runtime(mcp_tool_info(
|
|
"echo",
|
|
/*supports_parallel_tool_calls*/ true,
|
|
"mcp__echo__",
|
|
"query_with_delay",
|
|
)),
|
|
RegisteredTool {
|
|
exposure: ToolExposure::DirectModelOnly,
|
|
..mcp_runtime(mcp_tool_info(
|
|
"hello_echo",
|
|
/*supports_parallel_tool_calls*/ false,
|
|
"mcp__hello_echo__",
|
|
"query_with_delay",
|
|
))
|
|
},
|
|
RegisteredTool {
|
|
exposure: ToolExposure::Hidden,
|
|
..mcp_runtime(mcp_tool_info(
|
|
"hidden_echo",
|
|
/*supports_parallel_tool_calls*/ true,
|
|
"mcp__hidden_echo__",
|
|
"query_with_delay",
|
|
))
|
|
},
|
|
RegisteredTool {
|
|
exposure: ToolExposure::CodeModeOnly,
|
|
..mcp_runtime(mcp_tool_info(
|
|
"nested_echo",
|
|
/*supports_parallel_tool_calls*/ true,
|
|
"mcp__nested_echo__",
|
|
"query_with_delay",
|
|
))
|
|
},
|
|
],
|
|
Vec::new(),
|
|
session.dynamic_tools().await.as_ref(),
|
|
);
|
|
|
|
let call = ToolCall {
|
|
tool_name: ToolName::namespaced("mcp__echo__", "query_with_delay"),
|
|
call_id: "call-handler".to_string(),
|
|
payload: ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
},
|
|
encrypted_function_args: None,
|
|
native_call: None,
|
|
};
|
|
assert!(router.tool_supports_parallel(&call));
|
|
assert_eq!(
|
|
router
|
|
.registered_tool(&call.tool_name)
|
|
.map(|tool| tool.runtime.tool_name()),
|
|
Some(call.tool_name.clone())
|
|
);
|
|
|
|
let different_server_call = ToolCall {
|
|
tool_name: ToolName::namespaced("mcp__hello_echo__", "query_with_delay"),
|
|
call_id: "call-other-server".to_string(),
|
|
payload: ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
},
|
|
encrypted_function_args: None,
|
|
native_call: None,
|
|
};
|
|
assert!(!router.tool_supports_parallel(&different_server_call));
|
|
assert_eq!(
|
|
router
|
|
.registered_tool(&different_server_call.tool_name)
|
|
.map(|tool| tool.runtime.tool_name()),
|
|
Some(different_server_call.tool_name.clone())
|
|
);
|
|
|
|
let hidden_call = ToolCall {
|
|
tool_name: ToolName::namespaced("mcp__hidden_echo__", "query_with_delay"),
|
|
call_id: "call-hidden".to_string(),
|
|
payload: ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
},
|
|
encrypted_function_args: None,
|
|
native_call: None,
|
|
};
|
|
assert!(!router.tool_supports_parallel(&hidden_call));
|
|
assert!(router.registered_tool(&hidden_call.tool_name).is_some());
|
|
|
|
let nested_only_call = ToolCall {
|
|
tool_name: ToolName::namespaced("mcp__nested_echo__", "query_with_delay"),
|
|
call_id: "call-nested-only-server".to_string(),
|
|
payload: ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
},
|
|
encrypted_function_args: None,
|
|
native_call: None,
|
|
};
|
|
assert!(router.tool_supports_parallel(&nested_only_call));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn tools_without_handlers_do_not_support_parallel() -> anyhow::Result<()> {
|
|
let (session, turn) = make_session_and_context().await;
|
|
let turn = Arc::new(turn);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn));
|
|
let router = test_tool_router(
|
|
step_context.as_ref(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
session.dynamic_tools().await.as_ref(),
|
|
);
|
|
|
|
assert!(!router.tool_supports_parallel(&ToolCall {
|
|
tool_name: ToolName::plain("web_search"),
|
|
call_id: "call-web-search".to_string(),
|
|
payload: ToolPayload::Function {
|
|
arguments: "{}".to_string(),
|
|
},
|
|
encrypted_function_args: None,
|
|
native_call: None,
|
|
}));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn specs_filter_deferred_dynamic_tools() -> anyhow::Result<()> {
|
|
let (_, turn) = make_session_and_context().await;
|
|
let turn = Arc::new(turn);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn));
|
|
let hidden_tool = "hidden_dynamic_tool";
|
|
let visible_tool = "visible_dynamic_tool";
|
|
let dynamic_tools = vec![DynamicToolSpec::Namespace(DynamicToolNamespaceSpec {
|
|
name: "codex_app".to_string(),
|
|
description: "Codex app tools.".to_string(),
|
|
tools: vec![
|
|
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
|
|
name: hidden_tool.to_string(),
|
|
description: "Hidden until discovered.".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {},
|
|
"additionalProperties": false,
|
|
}),
|
|
defer_loading: true,
|
|
}),
|
|
DynamicToolNamespaceTool::Function(DynamicToolFunctionSpec {
|
|
name: visible_tool.to_string(),
|
|
description: "Visible immediately.".to_string(),
|
|
input_schema: json!({
|
|
"type": "object",
|
|
"properties": {},
|
|
"additionalProperties": false,
|
|
}),
|
|
defer_loading: false,
|
|
}),
|
|
],
|
|
})];
|
|
|
|
let router = test_tool_router(
|
|
step_context.as_ref(),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
&dynamic_tools,
|
|
);
|
|
let visible_specs = router.model_visible_specs();
|
|
|
|
assert!(Arc::ptr_eq(&visible_specs, &router.model_visible_specs()));
|
|
assert_eq!(
|
|
namespace_function_names(&visible_specs, "codex_app"),
|
|
vec![visible_tool.to_string()]
|
|
);
|
|
assert_eq!(
|
|
router.deferred_tool_namespaces(),
|
|
BTreeMap::from([("codex_app".to_string(), "Codex app tools.".to_string())])
|
|
);
|
|
|
|
let updated_router = test_tool_router(step_context.as_ref(), Vec::new(), Vec::new(), &[]);
|
|
let updated_specs = updated_router.model_visible_specs();
|
|
assert!(!Arc::ptr_eq(&visible_specs, &updated_specs));
|
|
assert!(namespace_function_names(&updated_specs, "codex_app").is_empty());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn mcp_tool_info(
|
|
server_name: &str,
|
|
supports_parallel_tool_calls: bool,
|
|
callable_namespace: &str,
|
|
tool_name: &str,
|
|
) -> codex_mcp::ToolInfo {
|
|
codex_mcp::ToolInfo {
|
|
server_name: server_name.to_string(),
|
|
supports_parallel_tool_calls,
|
|
server_origin: None,
|
|
callable_name: tool_name.to_string(),
|
|
callable_namespace: callable_namespace.to_string(),
|
|
namespace_description: None,
|
|
tool: rmcp::model::Tool::new(
|
|
tool_name.to_string(),
|
|
"Test MCP tool",
|
|
Arc::new(rmcp::model::object(json!({
|
|
"type": "object",
|
|
}))),
|
|
),
|
|
openai_file_input_optional_fields: Default::default(),
|
|
connector_id: None,
|
|
connector_name: None,
|
|
plugin_display_names: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn mcp_runtime(tool_info: codex_mcp::ToolInfo) -> RegisteredTool {
|
|
let runtime = Arc::new(McpHandler::new(tool_info).expect("MCP tool spec should build"))
|
|
as Arc<dyn CoreToolRuntime>;
|
|
RegisteredTool {
|
|
exposure: runtime.exposure(),
|
|
native_review: Default::default(),
|
|
runtime,
|
|
model_spec: None,
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow::Result<()> {
|
|
let (mut session, turn) = make_session_and_context().await;
|
|
session.services.extensions = extension_tool_test_registry();
|
|
let turn = Arc::new(turn);
|
|
let step_context = StepContext::for_test(Arc::clone(&turn));
|
|
let history_item = ResponseItem::Message {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
role: "user".to_string(),
|
|
content: vec![ContentItem::InputText {
|
|
text: "extension history".to_string(),
|
|
}],
|
|
phase: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
};
|
|
session
|
|
.record_conversation_items(
|
|
&turn,
|
|
turn.model_info(),
|
|
std::slice::from_ref(&history_item),
|
|
)
|
|
.await;
|
|
let expected_history_item = session
|
|
.clone_history()
|
|
.await
|
|
.raw_items()
|
|
.next()
|
|
.expect("history item")
|
|
.clone();
|
|
|
|
let router = test_tool_router(
|
|
step_context.as_ref(),
|
|
Vec::new(),
|
|
extension_tool_executors(
|
|
&session,
|
|
&codex_extension_api::ExtensionData::new(turn.sub_id.clone()),
|
|
),
|
|
session.dynamic_tools().await.as_ref(),
|
|
);
|
|
|
|
assert!(
|
|
router.model_visible_specs().iter().any(
|
|
|spec| matches!(spec, ToolSpec::Namespace(namespace)
|
|
if namespace.name == "extension/"
|
|
&& namespace.tools.iter().any(|tool| matches!(
|
|
tool,
|
|
ResponsesApiNamespaceTool::Function(tool) if tool.name == "echo"
|
|
)))
|
|
),
|
|
"expected extension-provided tool to be visible to the model"
|
|
);
|
|
|
|
let call_item = ResponseItem::FunctionCall {
|
|
status: None,
|
|
encrypted_content: None,
|
|
id: None,
|
|
name: "echo".to_string(),
|
|
namespace: Some("extension/".to_string()),
|
|
arguments: json!({ "message": "hello" }).to_string(),
|
|
call_id: "call-extension".to_string(),
|
|
encrypted_function_args: None,
|
|
internal_chat_message_metadata_passthrough: None,
|
|
};
|
|
let call = router
|
|
.build_tool_call(call_item)?
|
|
.expect("function_call should produce a tool call");
|
|
let result = router
|
|
.dispatch_tool_call_with_code_mode_result(
|
|
Arc::new(session),
|
|
step_context,
|
|
CancellationToken::new(),
|
|
Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
|
|
call,
|
|
ToolCallSource::Direct,
|
|
)
|
|
.await?;
|
|
|
|
let response = result.into_response().item;
|
|
match response {
|
|
ResponseItem::FunctionCallOutput {
|
|
call_id, output, ..
|
|
} => {
|
|
assert_eq!(call_id.as_deref(), Some("call-extension"));
|
|
let FunctionCallOutputBody::Text(text) = output.body else {
|
|
panic!("expected text function call output")
|
|
};
|
|
let value: serde_json::Value =
|
|
serde_json::from_str(&text).expect("extension tool output should be json");
|
|
assert_eq!(
|
|
strip_response_item_ids_from_json(value),
|
|
strip_response_item_ids_from_json(json!({
|
|
"arguments": { "message": "hello" },
|
|
"callId": "call-extension",
|
|
"conversationHistory": [expected_history_item],
|
|
"ok": true,
|
|
}))
|
|
);
|
|
}
|
|
other => panic!("expected function call output, got {other:?}"),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn namespace_function_names(specs: &[ToolSpec], namespace_name: &str) -> Vec<String> {
|
|
specs
|
|
.iter()
|
|
.find_map(|spec| match spec {
|
|
ToolSpec::Namespace(namespace) if namespace.name == namespace_name => Some(
|
|
namespace
|
|
.tools
|
|
.iter()
|
|
.map(|tool| match tool {
|
|
ResponsesApiNamespaceTool::Function(tool) => tool.name.clone(),
|
|
ResponsesApiNamespaceTool::Custom(tool) => tool.name.clone(),
|
|
})
|
|
.collect(),
|
|
),
|
|
ToolSpec::Function(_)
|
|
| ToolSpec::Freeform(_)
|
|
| ToolSpec::ToolSearch { .. }
|
|
| ToolSpec::WebSearch { .. }
|
|
| ToolSpec::Namespace(_) => None,
|
|
})
|
|
.unwrap_or_default()
|
|
}
|