mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
3360f128f4
- [x] Polish tool suggest prompts to distinguish between missing connectors and discoverable plugins, and be very precise about the triggering conditions.
3097 lines
110 KiB
Rust
3097 lines
110 KiB
Rust
use crate::client_common::tools::FreeformTool;
|
|
use crate::config::test_config;
|
|
use crate::models_manager::manager::ModelsManager;
|
|
use crate::models_manager::model_info::with_config_overrides;
|
|
use crate::shell::Shell;
|
|
use crate::shell::ShellType;
|
|
use crate::tools::ToolRouter;
|
|
use crate::tools::registry::ConfiguredToolSpec;
|
|
use crate::tools::router::ToolRouterParams;
|
|
use codex_app_server_protocol::AppInfo;
|
|
use codex_protocol::openai_models::InputModality;
|
|
use codex_protocol::openai_models::ModelInfo;
|
|
use codex_protocol::openai_models::ModelsResponse;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use pretty_assertions::assert_eq;
|
|
use std::path::PathBuf;
|
|
|
|
use super::*;
|
|
|
|
fn mcp_tool(name: &str, description: &str, input_schema: serde_json::Value) -> rmcp::model::Tool {
|
|
rmcp::model::Tool {
|
|
name: name.to_string().into(),
|
|
title: None,
|
|
description: Some(description.to_string().into()),
|
|
input_schema: std::sync::Arc::new(rmcp::model::object(input_schema)),
|
|
output_schema: None,
|
|
annotations: None,
|
|
execution: None,
|
|
icons: None,
|
|
meta: None,
|
|
}
|
|
}
|
|
|
|
fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool {
|
|
let slug = name.replace(' ', "-").to_lowercase();
|
|
DiscoverableTool::Connector(Box::new(AppInfo {
|
|
id: id.to_string(),
|
|
name: name.to_string(),
|
|
description: Some(description.to_string()),
|
|
logo_url: None,
|
|
logo_url_dark: None,
|
|
distribution_channel: None,
|
|
branding: None,
|
|
app_metadata: None,
|
|
labels: None,
|
|
install_url: Some(format!("https://chatgpt.com/apps/{slug}/{id}")),
|
|
is_accessible: false,
|
|
is_enabled: true,
|
|
plugin_display_names: Vec::new(),
|
|
}))
|
|
}
|
|
|
|
fn windows_shell_safety_description() -> String {
|
|
format!("\n\n{}", super::windows_destructive_filesystem_guidance())
|
|
}
|
|
|
|
fn search_capable_model_info() -> ModelInfo {
|
|
let config = test_config();
|
|
let mut model_info =
|
|
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
model_info.supports_search_tool = true;
|
|
model_info
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_to_openai_tool_inserts_empty_properties() {
|
|
let mut schema = rmcp::model::JsonObject::new();
|
|
schema.insert("type".to_string(), serde_json::json!("object"));
|
|
|
|
let tool = rmcp::model::Tool {
|
|
name: "no_props".to_string().into(),
|
|
title: None,
|
|
description: Some("No properties".to_string().into()),
|
|
input_schema: std::sync::Arc::new(schema),
|
|
output_schema: None,
|
|
annotations: None,
|
|
execution: None,
|
|
icons: None,
|
|
meta: None,
|
|
};
|
|
|
|
let openai_tool =
|
|
mcp_tool_to_openai_tool("server/no_props".to_string(), tool).expect("convert tool");
|
|
let parameters = serde_json::to_value(openai_tool.parameters).expect("serialize schema");
|
|
|
|
assert_eq!(parameters.get("properties"), Some(&serde_json::json!({})));
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_to_openai_tool_preserves_top_level_output_schema() {
|
|
let mut input_schema = rmcp::model::JsonObject::new();
|
|
input_schema.insert("type".to_string(), serde_json::json!("object"));
|
|
|
|
let mut output_schema = rmcp::model::JsonObject::new();
|
|
output_schema.insert(
|
|
"properties".to_string(),
|
|
serde_json::json!({
|
|
"result": {
|
|
"properties": {
|
|
"nested": {}
|
|
}
|
|
}
|
|
}),
|
|
);
|
|
output_schema.insert("required".to_string(), serde_json::json!(["result"]));
|
|
|
|
let tool = rmcp::model::Tool {
|
|
name: "with_output".to_string().into(),
|
|
title: None,
|
|
description: Some("Has output schema".to_string().into()),
|
|
input_schema: std::sync::Arc::new(input_schema),
|
|
output_schema: Some(std::sync::Arc::new(output_schema)),
|
|
annotations: None,
|
|
execution: None,
|
|
icons: None,
|
|
meta: None,
|
|
};
|
|
|
|
let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_output".to_string(), tool)
|
|
.expect("convert tool");
|
|
|
|
assert_eq!(
|
|
openai_tool.output_schema,
|
|
Some(serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"content": {
|
|
"type": "array",
|
|
"items": {}
|
|
},
|
|
"structuredContent": {
|
|
"properties": {
|
|
"result": {
|
|
"properties": {
|
|
"nested": {}
|
|
}
|
|
}
|
|
},
|
|
"required": ["result"]
|
|
},
|
|
"isError": {
|
|
"type": "boolean"
|
|
},
|
|
"_meta": {}
|
|
},
|
|
"required": ["content"],
|
|
"additionalProperties": false
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_tool_to_openai_tool_preserves_output_schema_without_inferred_type() {
|
|
let mut input_schema = rmcp::model::JsonObject::new();
|
|
input_schema.insert("type".to_string(), serde_json::json!("object"));
|
|
|
|
let mut output_schema = rmcp::model::JsonObject::new();
|
|
output_schema.insert("enum".to_string(), serde_json::json!(["ok", "error"]));
|
|
|
|
let tool = rmcp::model::Tool {
|
|
name: "with_enum_output".to_string().into(),
|
|
title: None,
|
|
description: Some("Has enum output schema".to_string().into()),
|
|
input_schema: std::sync::Arc::new(input_schema),
|
|
output_schema: Some(std::sync::Arc::new(output_schema)),
|
|
annotations: None,
|
|
execution: None,
|
|
icons: None,
|
|
meta: None,
|
|
};
|
|
|
|
let openai_tool = mcp_tool_to_openai_tool("mcp__server__with_enum_output".to_string(), tool)
|
|
.expect("convert tool");
|
|
|
|
assert_eq!(
|
|
openai_tool.output_schema,
|
|
Some(serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"content": {
|
|
"type": "array",
|
|
"items": {}
|
|
},
|
|
"structuredContent": {
|
|
"enum": ["ok", "error"]
|
|
},
|
|
"isError": {
|
|
"type": "boolean"
|
|
},
|
|
"_meta": {}
|
|
},
|
|
"required": ["content"],
|
|
"additionalProperties": false
|
|
}))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn search_tool_deferred_tools_always_set_defer_loading_true() {
|
|
let tool = mcp_tool(
|
|
"lookup_order",
|
|
"Look up an order",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"order_id": {"type": "string"}
|
|
},
|
|
"required": ["order_id"],
|
|
"additionalProperties": false,
|
|
}),
|
|
);
|
|
|
|
let openai_tool =
|
|
mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool)
|
|
.expect("convert deferred tool");
|
|
|
|
assert_eq!(openai_tool.defer_loading, Some(true));
|
|
}
|
|
|
|
#[test]
|
|
fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
|
let tool = mcp_tool(
|
|
"lookup_order",
|
|
"Look up an order",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"order_id": {"type": "string"}
|
|
},
|
|
"required": ["order_id"],
|
|
"additionalProperties": false,
|
|
}),
|
|
);
|
|
|
|
let serialized = serde_json::to_value(ToolSpec::Function(
|
|
mcp_tool_to_deferred_openai_tool("mcp__codex_apps__lookup_order".to_string(), tool)
|
|
.expect("convert deferred tool"),
|
|
))
|
|
.expect("serialize deferred tool");
|
|
|
|
assert_eq!(
|
|
serialized,
|
|
serde_json::json!({
|
|
"type": "function",
|
|
"name": "mcp__codex_apps__lookup_order",
|
|
"description": "Look up an order",
|
|
"strict": false,
|
|
"defer_loading": true,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"order_id": {"type": "string"}
|
|
},
|
|
"required": ["order_id"],
|
|
"additionalProperties": false,
|
|
}
|
|
})
|
|
);
|
|
}
|
|
|
|
fn tool_name(tool: &ToolSpec) -> &str {
|
|
match tool {
|
|
ToolSpec::Function(ResponsesApiTool { name, .. }) => name,
|
|
ToolSpec::ToolSearch { .. } => "tool_search",
|
|
ToolSpec::LocalShell {} => "local_shell",
|
|
ToolSpec::ImageGeneration { .. } => "image_generation",
|
|
ToolSpec::WebSearch { .. } => "web_search",
|
|
ToolSpec::Freeform(FreeformTool { name, .. }) => name,
|
|
}
|
|
}
|
|
|
|
// Avoid order-based assertions; compare via set containment instead.
|
|
fn assert_contains_tool_names(tools: &[ConfiguredToolSpec], expected_subset: &[&str]) {
|
|
use std::collections::HashSet;
|
|
let mut names = HashSet::new();
|
|
let mut duplicates = Vec::new();
|
|
for name in tools.iter().map(|t| tool_name(&t.spec)) {
|
|
if !names.insert(name) {
|
|
duplicates.push(name);
|
|
}
|
|
}
|
|
assert!(
|
|
duplicates.is_empty(),
|
|
"duplicate tool entries detected: {duplicates:?}"
|
|
);
|
|
for expected in expected_subset {
|
|
assert!(
|
|
names.contains(expected),
|
|
"expected tool {expected} to be present; had: {names:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
fn assert_lacks_tool_name(tools: &[ConfiguredToolSpec], expected_absent: &str) {
|
|
let names = tools
|
|
.iter()
|
|
.map(|tool| tool_name(&tool.spec))
|
|
.collect::<Vec<_>>();
|
|
assert!(
|
|
!names.contains(&expected_absent),
|
|
"expected tool {expected_absent} to be absent; had: {names:?}"
|
|
);
|
|
}
|
|
|
|
fn shell_tool_name(config: &ToolsConfig) -> Option<&'static str> {
|
|
match config.shell_type {
|
|
ConfigShellToolType::Default => Some("shell"),
|
|
ConfigShellToolType::Local => Some("local_shell"),
|
|
ConfigShellToolType::UnifiedExec => None,
|
|
ConfigShellToolType::Disabled => None,
|
|
ConfigShellToolType::ShellCommand => Some("shell_command"),
|
|
}
|
|
}
|
|
|
|
fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a ConfiguredToolSpec {
|
|
tools
|
|
.iter()
|
|
.find(|tool| tool_name(&tool.spec) == expected_name)
|
|
.unwrap_or_else(|| panic!("expected tool {expected_name}"))
|
|
}
|
|
|
|
fn strip_descriptions_schema(schema: &mut JsonSchema) {
|
|
match schema {
|
|
JsonSchema::Boolean { description }
|
|
| JsonSchema::String { description }
|
|
| JsonSchema::Number { description } => {
|
|
*description = None;
|
|
}
|
|
JsonSchema::Array { items, description } => {
|
|
strip_descriptions_schema(items);
|
|
*description = None;
|
|
}
|
|
JsonSchema::Object {
|
|
properties,
|
|
required: _,
|
|
additional_properties,
|
|
} => {
|
|
for v in properties.values_mut() {
|
|
strip_descriptions_schema(v);
|
|
}
|
|
if let Some(AdditionalProperties::Schema(s)) = additional_properties {
|
|
strip_descriptions_schema(s);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn strip_descriptions_tool(spec: &mut ToolSpec) {
|
|
match spec {
|
|
ToolSpec::ToolSearch { parameters, .. } => strip_descriptions_schema(parameters),
|
|
ToolSpec::Function(ResponsesApiTool { parameters, .. }) => {
|
|
strip_descriptions_schema(parameters);
|
|
}
|
|
ToolSpec::Freeform(_)
|
|
| ToolSpec::LocalShell {}
|
|
| ToolSpec::ImageGeneration { .. }
|
|
| ToolSpec::WebSearch { .. } => {}
|
|
}
|
|
}
|
|
|
|
fn model_info_from_models_json(slug: &str) -> ModelInfo {
|
|
let config = test_config();
|
|
let response: ModelsResponse =
|
|
serde_json::from_str(include_str!("../../models.json")).expect("valid models.json");
|
|
let model = response
|
|
.models
|
|
.into_iter()
|
|
.find(|candidate| candidate.slug == slug)
|
|
.unwrap_or_else(|| panic!("model slug {slug} is missing from models.json"));
|
|
with_config_overrides(model, &config)
|
|
}
|
|
|
|
#[test]
|
|
fn unified_exec_is_blocked_for_windows_sandboxed_policies_only() {
|
|
assert!(!unified_exec_allowed_in_environment(
|
|
true,
|
|
&SandboxPolicy::new_read_only_policy(),
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
));
|
|
assert!(!unified_exec_allowed_in_environment(
|
|
true,
|
|
&SandboxPolicy::new_workspace_write_policy(),
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
));
|
|
assert!(unified_exec_allowed_in_environment(
|
|
true,
|
|
&SandboxPolicy::DangerFullAccess,
|
|
WindowsSandboxLevel::RestrictedToken,
|
|
));
|
|
assert!(unified_exec_allowed_in_environment(
|
|
true,
|
|
&SandboxPolicy::DangerFullAccess,
|
|
WindowsSandboxLevel::Disabled,
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() {
|
|
let mut model_info = model_info_from_models_json("gpt-5-codex");
|
|
model_info.shell_type = ConfigShellToolType::UnifiedExec;
|
|
let features = Features::with_defaults();
|
|
let available_models = Vec::new();
|
|
let config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::new_workspace_write_policy(),
|
|
windows_sandbox_level: WindowsSandboxLevel::RestrictedToken,
|
|
});
|
|
|
|
let expected_shell_type = if cfg!(target_os = "windows") {
|
|
ConfigShellToolType::ShellCommand
|
|
} else {
|
|
ConfigShellToolType::UnifiedExec
|
|
};
|
|
assert_eq!(config.shell_type, expected_shell_type);
|
|
}
|
|
|
|
#[test]
|
|
fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() {
|
|
let model_info = model_info_from_models_json("gpt-5-codex");
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Live),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&config, None, None, &[]).build();
|
|
|
|
// Build actual map name -> spec
|
|
use std::collections::BTreeMap;
|
|
use std::collections::HashSet;
|
|
let mut actual: BTreeMap<String, ToolSpec> = BTreeMap::from([]);
|
|
let mut duplicate_names = Vec::new();
|
|
for t in &tools {
|
|
let name = tool_name(&t.spec).to_string();
|
|
if actual.insert(name.clone(), t.spec.clone()).is_some() {
|
|
duplicate_names.push(name);
|
|
}
|
|
}
|
|
assert!(
|
|
duplicate_names.is_empty(),
|
|
"duplicate tool entries detected: {duplicate_names:?}"
|
|
);
|
|
|
|
// Build expected from the same helpers used by the builder.
|
|
let mut expected: BTreeMap<String, ToolSpec> = BTreeMap::from([]);
|
|
for spec in [
|
|
create_exec_command_tool(true, false),
|
|
create_write_stdin_tool(),
|
|
PLAN_TOOL.clone(),
|
|
create_request_user_input_tool(CollaborationModesConfig::default()),
|
|
create_apply_patch_freeform_tool(),
|
|
ToolSpec::WebSearch {
|
|
external_web_access: Some(true),
|
|
filters: None,
|
|
user_location: None,
|
|
search_context_size: None,
|
|
search_content_types: None,
|
|
},
|
|
create_view_image_tool(config.can_request_original_image_detail),
|
|
] {
|
|
expected.insert(tool_name(&spec).to_string(), spec);
|
|
}
|
|
let collab_specs = if config.multi_agent_v2 {
|
|
vec![
|
|
create_spawn_agent_tool_v2(&config),
|
|
create_send_message_tool(),
|
|
create_wait_agent_tool_v2(),
|
|
create_close_agent_tool_v2(),
|
|
]
|
|
} else {
|
|
vec![
|
|
create_spawn_agent_tool_v1(&config),
|
|
create_send_input_tool_v1(),
|
|
create_wait_agent_tool_v1(),
|
|
create_close_agent_tool_v1(),
|
|
]
|
|
};
|
|
for spec in collab_specs {
|
|
expected.insert(tool_name(&spec).to_string(), spec);
|
|
}
|
|
if !config.multi_agent_v2 {
|
|
let spec = create_resume_agent_tool();
|
|
expected.insert(tool_name(&spec).to_string(), spec);
|
|
}
|
|
|
|
if config.exec_permission_approvals_enabled {
|
|
let spec = create_request_permissions_tool();
|
|
expected.insert(tool_name(&spec).to_string(), spec);
|
|
}
|
|
|
|
// Exact name set match — this is the only test allowed to fail when tools change.
|
|
let actual_names: HashSet<_> = actual.keys().cloned().collect();
|
|
let expected_names: HashSet<_> = expected.keys().cloned().collect();
|
|
assert_eq!(actual_names, expected_names, "tool name set mismatch");
|
|
|
|
// Compare specs ignoring human-readable descriptions.
|
|
for name in expected.keys() {
|
|
let mut a = actual.get(name).expect("present").clone();
|
|
let mut e = expected.get(name).expect("present").clone();
|
|
strip_descriptions_tool(&mut a);
|
|
strip_descriptions_tool(&mut e);
|
|
assert_eq!(a, e, "spec mismatch for {name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_collab_tools_enabled() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::Collab);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
assert_contains_tool_names(
|
|
&tools,
|
|
&["spawn_agent", "send_input", "wait_agent", "close_agent"],
|
|
);
|
|
assert_lacks_tool_name(&tools, "spawn_agents_on_csv");
|
|
assert_lacks_tool_name(&tools, "list_agents");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::Collab);
|
|
features.enable(Feature::MultiAgentV2);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
assert_contains_tool_names(
|
|
&tools,
|
|
&[
|
|
"spawn_agent",
|
|
"send_message",
|
|
"assign_task",
|
|
"wait_agent",
|
|
"close_agent",
|
|
"list_agents",
|
|
],
|
|
);
|
|
|
|
let spawn_agent = find_tool(&tools, "spawn_agent");
|
|
let ToolSpec::Function(ResponsesApiTool {
|
|
parameters,
|
|
output_schema,
|
|
..
|
|
}) = &spawn_agent.spec
|
|
else {
|
|
panic!("spawn_agent should be a function tool");
|
|
};
|
|
let JsonSchema::Object {
|
|
properties,
|
|
required,
|
|
..
|
|
} = parameters
|
|
else {
|
|
panic!("spawn_agent should use object params");
|
|
};
|
|
assert!(properties.contains_key("task_name"));
|
|
assert_eq!(required.as_ref(), None);
|
|
let output_schema = output_schema
|
|
.as_ref()
|
|
.expect("spawn_agent should define output schema");
|
|
assert_eq!(
|
|
output_schema["required"],
|
|
json!(["agent_id", "task_name", "nickname"])
|
|
);
|
|
|
|
let send_message = find_tool(&tools, "send_message");
|
|
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &send_message.spec else {
|
|
panic!("send_message should be a function tool");
|
|
};
|
|
let JsonSchema::Object {
|
|
properties,
|
|
required,
|
|
..
|
|
} = parameters
|
|
else {
|
|
panic!("send_message should use object params");
|
|
};
|
|
assert!(properties.contains_key("target"));
|
|
assert!(!properties.contains_key("message"));
|
|
assert_eq!(
|
|
required.as_ref(),
|
|
Some(&vec!["target".to_string(), "items".to_string()])
|
|
);
|
|
|
|
let assign_task = find_tool(&tools, "assign_task");
|
|
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &assign_task.spec else {
|
|
panic!("assign_task should be a function tool");
|
|
};
|
|
let JsonSchema::Object {
|
|
properties,
|
|
required,
|
|
..
|
|
} = parameters
|
|
else {
|
|
panic!("assign_task should use object params");
|
|
};
|
|
assert!(properties.contains_key("target"));
|
|
assert!(!properties.contains_key("message"));
|
|
assert_eq!(
|
|
required.as_ref(),
|
|
Some(&vec!["target".to_string(), "items".to_string()])
|
|
);
|
|
|
|
let wait_agent = find_tool(&tools, "wait_agent");
|
|
let ToolSpec::Function(ResponsesApiTool {
|
|
parameters,
|
|
output_schema,
|
|
..
|
|
}) = &wait_agent.spec
|
|
else {
|
|
panic!("wait_agent should be a function tool");
|
|
};
|
|
let JsonSchema::Object {
|
|
properties,
|
|
required,
|
|
..
|
|
} = parameters
|
|
else {
|
|
panic!("wait_agent should use object params");
|
|
};
|
|
assert!(properties.contains_key("targets"));
|
|
assert_eq!(required.as_ref(), Some(&vec!["targets".to_string()]));
|
|
let output_schema = output_schema
|
|
.as_ref()
|
|
.expect("wait_agent should define output schema");
|
|
assert_eq!(
|
|
output_schema["properties"]["message"]["description"],
|
|
json!("Brief wait summary without the agent's final content.")
|
|
);
|
|
|
|
let list_agents = find_tool(&tools, "list_agents");
|
|
let ToolSpec::Function(ResponsesApiTool {
|
|
parameters,
|
|
output_schema,
|
|
..
|
|
}) = &list_agents.spec
|
|
else {
|
|
panic!("list_agents should be a function tool");
|
|
};
|
|
let JsonSchema::Object {
|
|
properties,
|
|
required,
|
|
..
|
|
} = parameters
|
|
else {
|
|
panic!("list_agents should use object params");
|
|
};
|
|
assert!(properties.contains_key("path_prefix"));
|
|
assert_eq!(required.as_ref(), None);
|
|
let output_schema = output_schema
|
|
.as_ref()
|
|
.expect("list_agents should define output schema");
|
|
assert_eq!(
|
|
output_schema["properties"]["agents"]["items"]["required"],
|
|
json!(["agent_name", "agent_status", "last_task_message"])
|
|
);
|
|
assert_lacks_tool_name(&tools, "send_input");
|
|
assert_lacks_tool_name(&tools, "resume_agent");
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_enable_fanout_enables_agent_jobs_and_collab_tools() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::SpawnCsv);
|
|
features.normalize_dependencies();
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
assert_contains_tool_names(
|
|
&tools,
|
|
&[
|
|
"spawn_agent",
|
|
"send_input",
|
|
"wait_agent",
|
|
"close_agent",
|
|
"spawn_agents_on_csv",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn view_image_tool_omits_detail_without_original_detail_feature() {
|
|
let config = test_config();
|
|
let mut model_info =
|
|
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
model_info.supports_image_detail_original = true;
|
|
let features = Features::with_defaults();
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME);
|
|
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &view_image.spec else {
|
|
panic!("view_image should be a function tool");
|
|
};
|
|
let JsonSchema::Object { properties, .. } = parameters else {
|
|
panic!("view_image should use an object schema");
|
|
};
|
|
assert!(!properties.contains_key("detail"));
|
|
}
|
|
|
|
#[test]
|
|
fn view_image_tool_includes_detail_with_original_detail_feature() {
|
|
let config = test_config();
|
|
let mut model_info =
|
|
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
model_info.supports_image_detail_original = true;
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::ImageDetailOriginal);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
let view_image = find_tool(&tools, VIEW_IMAGE_TOOL_NAME);
|
|
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = &view_image.spec else {
|
|
panic!("view_image should be a function tool");
|
|
};
|
|
let JsonSchema::Object { properties, .. } = parameters else {
|
|
panic!("view_image should use an object schema");
|
|
};
|
|
assert!(properties.contains_key("detail"));
|
|
let Some(JsonSchema::String {
|
|
description: Some(description),
|
|
}) = properties.get("detail")
|
|
else {
|
|
panic!("view_image detail should include a description");
|
|
};
|
|
assert!(description.contains("only supported value is `original`"));
|
|
assert!(description.contains("omit this field for default resized behavior"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_agent_job_worker_tools_enabled() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::SpawnCsv);
|
|
features.normalize_dependencies();
|
|
features.enable(Feature::Sqlite);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::SubAgent(SubAgentSource::Other(
|
|
"agent_job:test".to_string(),
|
|
)),
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
assert_contains_tool_names(
|
|
&tools,
|
|
&[
|
|
"spawn_agent",
|
|
"send_input",
|
|
"resume_agent",
|
|
"wait_agent",
|
|
"close_agent",
|
|
"spawn_agents_on_csv",
|
|
"report_agent_job_result",
|
|
],
|
|
);
|
|
assert_lacks_tool_name(&tools, "request_user_input");
|
|
}
|
|
|
|
#[test]
|
|
fn request_user_input_description_reflects_default_mode_feature_flag() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
let request_user_input_tool = find_tool(&tools, "request_user_input");
|
|
assert_eq!(
|
|
request_user_input_tool.spec,
|
|
create_request_user_input_tool(CollaborationModesConfig::default())
|
|
);
|
|
|
|
features.enable(Feature::DefaultModeRequestUserInput);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
let request_user_input_tool = find_tool(&tools, "request_user_input");
|
|
assert_eq!(
|
|
request_user_input_tool.spec,
|
|
create_request_user_input_tool(CollaborationModesConfig {
|
|
default_mode_request_user_input: true,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn request_permissions_requires_feature_flag() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let features = Features::with_defaults();
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
assert_lacks_tool_name(&tools, "request_permissions");
|
|
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::RequestPermissionsTool);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
let request_permissions_tool = find_tool(&tools, "request_permissions");
|
|
assert_eq!(
|
|
request_permissions_tool.spec,
|
|
create_request_permissions_tool()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn request_permissions_tool_is_independent_from_additional_permissions() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::ExecPermissionApprovals);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
|
|
assert_lacks_tool_name(&tools, "request_permissions");
|
|
}
|
|
|
|
#[test]
|
|
fn get_memory_requires_feature_flag() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.disable(Feature::MemoryTool);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
assert!(
|
|
!tools.iter().any(|t| t.spec.name() == "get_memory"),
|
|
"get_memory should be disabled when memory_tool feature is off"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn js_repl_requires_feature_flag() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let features = Features::with_defaults();
|
|
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
|
|
assert!(
|
|
!tools.iter().any(|tool| tool.spec.name() == "js_repl"),
|
|
"js_repl should be disabled when the feature is off"
|
|
);
|
|
assert!(
|
|
!tools.iter().any(|tool| tool.spec.name() == "js_repl_reset"),
|
|
"js_repl_reset should be disabled when the feature is off"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn js_repl_enabled_adds_tools() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::JsRepl);
|
|
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]);
|
|
}
|
|
|
|
#[test]
|
|
fn image_generation_tools_require_feature_and_supported_model() {
|
|
let config = test_config();
|
|
let mut supported_model_info =
|
|
ModelsManager::construct_model_info_offline_for_tests("gpt-5.2", &config);
|
|
supported_model_info.slug = "custom/gpt-5.2-variant".to_string();
|
|
let mut unsupported_model_info = supported_model_info.clone();
|
|
unsupported_model_info.input_modalities = vec![InputModality::Text];
|
|
let default_features = Features::with_defaults();
|
|
let mut image_generation_features = default_features.clone();
|
|
image_generation_features.enable(Feature::ImageGeneration);
|
|
|
|
let available_models = Vec::new();
|
|
let default_tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &supported_model_info,
|
|
available_models: &available_models,
|
|
features: &default_features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (default_tools, _) = build_specs(&default_tools_config, None, None, &[]).build();
|
|
assert!(
|
|
!default_tools
|
|
.iter()
|
|
.any(|tool| tool.spec.name() == "image_generation"),
|
|
"image_generation should be disabled by default"
|
|
);
|
|
|
|
let supported_tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &supported_model_info,
|
|
available_models: &available_models,
|
|
features: &image_generation_features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (supported_tools, _) = build_specs(&supported_tools_config, None, None, &[]).build();
|
|
assert_contains_tool_names(&supported_tools, &["image_generation"]);
|
|
let image_generation_tool = find_tool(&supported_tools, "image_generation");
|
|
assert_eq!(
|
|
serde_json::to_value(&image_generation_tool.spec).expect("serialize image tool"),
|
|
serde_json::json!({
|
|
"type": "image_generation",
|
|
"output_format": "png"
|
|
})
|
|
);
|
|
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &unsupported_model_info,
|
|
available_models: &available_models,
|
|
features: &image_generation_features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
assert!(
|
|
!tools
|
|
.iter()
|
|
.any(|tool| tool.spec.name() == "image_generation"),
|
|
"image_generation should be disabled for unsupported models"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn js_repl_freeform_grammar_blocks_common_non_js_prefixes() {
|
|
let ToolSpec::Freeform(FreeformTool { format, .. }) = create_js_repl_tool() else {
|
|
panic!("js_repl should use a freeform tool spec");
|
|
};
|
|
|
|
assert_eq!(format.syntax, "lark");
|
|
assert!(format.definition.contains("PRAGMA_LINE"));
|
|
assert!(format.definition.contains("`[^`]"));
|
|
assert!(format.definition.contains("``[^`]"));
|
|
assert!(format.definition.contains("PLAIN_JS_SOURCE"));
|
|
assert!(format.definition.contains("codex-js-repl:"));
|
|
assert!(!format.definition.contains("(?!"));
|
|
}
|
|
|
|
fn assert_model_tools(
|
|
model_slug: &str,
|
|
features: &Features,
|
|
web_search_mode: Option<WebSearchMode>,
|
|
expected_tools: &[&str],
|
|
) {
|
|
let _config = test_config();
|
|
let model_info = model_info_from_models_json(model_slug);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features,
|
|
web_search_mode,
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let router = ToolRouter::from_config(
|
|
&tools_config,
|
|
ToolRouterParams {
|
|
mcp_tools: None,
|
|
app_tools: None,
|
|
discoverable_tools: None,
|
|
dynamic_tools: &[],
|
|
},
|
|
);
|
|
let model_visible_specs = router.model_visible_specs();
|
|
let tool_names = model_visible_specs
|
|
.iter()
|
|
.map(ToolSpec::name)
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(&tool_names, &expected_tools,);
|
|
}
|
|
|
|
fn assert_default_model_tools(
|
|
model_slug: &str,
|
|
features: &Features,
|
|
web_search_mode: Option<WebSearchMode>,
|
|
shell_tool: &'static str,
|
|
expected_tail: &[&str],
|
|
) {
|
|
let mut expected = if features.enabled(Feature::UnifiedExec) {
|
|
vec!["exec_command", "write_stdin"]
|
|
} else {
|
|
vec![shell_tool]
|
|
};
|
|
expected.extend(expected_tail);
|
|
assert_model_tools(model_slug, features, web_search_mode, &expected);
|
|
}
|
|
|
|
#[test]
|
|
fn web_search_mode_cached_sets_external_web_access_false() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let features = Features::with_defaults();
|
|
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
|
|
let tool = find_tool(&tools, "web_search");
|
|
assert_eq!(
|
|
tool.spec,
|
|
ToolSpec::WebSearch {
|
|
external_web_access: Some(false),
|
|
filters: None,
|
|
user_location: None,
|
|
search_context_size: None,
|
|
search_content_types: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn web_search_mode_live_sets_external_web_access_true() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let features = Features::with_defaults();
|
|
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Live),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
|
|
let tool = find_tool(&tools, "web_search");
|
|
assert_eq!(
|
|
tool.spec,
|
|
ToolSpec::WebSearch {
|
|
external_web_access: Some(true),
|
|
filters: None,
|
|
user_location: None,
|
|
search_context_size: None,
|
|
search_content_types: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn web_search_config_is_forwarded_to_tool_spec() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let features = Features::with_defaults();
|
|
let web_search_config = WebSearchConfig {
|
|
filters: Some(codex_protocol::config_types::WebSearchFilters {
|
|
allowed_domains: Some(vec!["example.com".to_string()]),
|
|
}),
|
|
user_location: Some(codex_protocol::config_types::WebSearchUserLocation {
|
|
r#type: codex_protocol::config_types::WebSearchUserLocationType::Approximate,
|
|
country: Some("US".to_string()),
|
|
region: Some("California".to_string()),
|
|
city: Some("San Francisco".to_string()),
|
|
timezone: Some("America/Los_Angeles".to_string()),
|
|
}),
|
|
search_context_size: Some(codex_protocol::config_types::WebSearchContextSize::High),
|
|
};
|
|
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Live),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
})
|
|
.with_web_search_config(Some(web_search_config.clone()));
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
|
|
let tool = find_tool(&tools, "web_search");
|
|
assert_eq!(
|
|
tool.spec,
|
|
ToolSpec::WebSearch {
|
|
external_web_access: Some(true),
|
|
filters: web_search_config
|
|
.filters
|
|
.map(crate::client_common::tools::ResponsesApiWebSearchFilters::from),
|
|
user_location: web_search_config
|
|
.user_location
|
|
.map(crate::client_common::tools::ResponsesApiWebSearchUserLocation::from),
|
|
search_context_size: web_search_config.search_context_size,
|
|
search_content_types: None,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn web_search_tool_type_text_and_image_sets_search_content_types() {
|
|
let config = test_config();
|
|
let mut model_info =
|
|
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
model_info.web_search_tool_type = WebSearchToolType::TextAndImage;
|
|
let features = Features::with_defaults();
|
|
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Live),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
|
|
let tool = find_tool(&tools, "web_search");
|
|
assert_eq!(
|
|
tool.spec,
|
|
ToolSpec::WebSearch {
|
|
external_web_access: Some(true),
|
|
filters: None,
|
|
user_location: None,
|
|
search_context_size: None,
|
|
search_content_types: Some(
|
|
WEB_SEARCH_CONTENT_TYPES
|
|
.into_iter()
|
|
.map(str::to_string)
|
|
.collect()
|
|
),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_resource_tools_are_hidden_without_mcp_servers() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let features = Features::with_defaults();
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
|
|
assert!(
|
|
!tools.iter().any(|tool| matches!(
|
|
tool.spec.name(),
|
|
"list_mcp_resources" | "list_mcp_resource_templates" | "read_mcp_resource"
|
|
)),
|
|
"MCP resource tools should be omitted when no MCP servers are configured"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn mcp_resource_tools_are_included_when_mcp_servers_are_present() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let features = Features::with_defaults();
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build();
|
|
|
|
assert_contains_tool_names(
|
|
&tools,
|
|
&[
|
|
"list_mcp_resources",
|
|
"list_mcp_resource_templates",
|
|
"read_mcp_resource",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_gpt5_codex_default() {
|
|
let features = Features::with_defaults();
|
|
assert_default_model_tools(
|
|
"gpt-5-codex",
|
|
&features,
|
|
Some(WebSearchMode::Cached),
|
|
"shell_command",
|
|
&[
|
|
"update_plan",
|
|
"request_user_input",
|
|
"apply_patch",
|
|
"web_search",
|
|
"view_image",
|
|
"spawn_agent",
|
|
"send_input",
|
|
"resume_agent",
|
|
"wait_agent",
|
|
"close_agent",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_gpt51_codex_default() {
|
|
let features = Features::with_defaults();
|
|
assert_default_model_tools(
|
|
"gpt-5.1-codex",
|
|
&features,
|
|
Some(WebSearchMode::Cached),
|
|
"shell_command",
|
|
&[
|
|
"update_plan",
|
|
"request_user_input",
|
|
"apply_patch",
|
|
"web_search",
|
|
"view_image",
|
|
"spawn_agent",
|
|
"send_input",
|
|
"resume_agent",
|
|
"wait_agent",
|
|
"close_agent",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_gpt5_codex_unified_exec_web_search() {
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
assert_model_tools(
|
|
"gpt-5-codex",
|
|
&features,
|
|
Some(WebSearchMode::Live),
|
|
&[
|
|
"exec_command",
|
|
"write_stdin",
|
|
"update_plan",
|
|
"request_user_input",
|
|
"apply_patch",
|
|
"web_search",
|
|
"view_image",
|
|
"spawn_agent",
|
|
"send_input",
|
|
"resume_agent",
|
|
"wait_agent",
|
|
"close_agent",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_gpt51_codex_unified_exec_web_search() {
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
assert_model_tools(
|
|
"gpt-5.1-codex",
|
|
&features,
|
|
Some(WebSearchMode::Live),
|
|
&[
|
|
"exec_command",
|
|
"write_stdin",
|
|
"update_plan",
|
|
"request_user_input",
|
|
"apply_patch",
|
|
"web_search",
|
|
"view_image",
|
|
"spawn_agent",
|
|
"send_input",
|
|
"resume_agent",
|
|
"wait_agent",
|
|
"close_agent",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gpt_5_1_codex_max_defaults() {
|
|
let features = Features::with_defaults();
|
|
assert_default_model_tools(
|
|
"gpt-5.1-codex-max",
|
|
&features,
|
|
Some(WebSearchMode::Cached),
|
|
"shell_command",
|
|
&[
|
|
"update_plan",
|
|
"request_user_input",
|
|
"apply_patch",
|
|
"web_search",
|
|
"view_image",
|
|
"spawn_agent",
|
|
"send_input",
|
|
"resume_agent",
|
|
"wait_agent",
|
|
"close_agent",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_codex_5_1_mini_defaults() {
|
|
let features = Features::with_defaults();
|
|
assert_default_model_tools(
|
|
"gpt-5.1-codex-mini",
|
|
&features,
|
|
Some(WebSearchMode::Cached),
|
|
"shell_command",
|
|
&[
|
|
"update_plan",
|
|
"request_user_input",
|
|
"apply_patch",
|
|
"web_search",
|
|
"view_image",
|
|
"spawn_agent",
|
|
"send_input",
|
|
"resume_agent",
|
|
"wait_agent",
|
|
"close_agent",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gpt_5_defaults() {
|
|
let features = Features::with_defaults();
|
|
assert_default_model_tools(
|
|
"gpt-5",
|
|
&features,
|
|
Some(WebSearchMode::Cached),
|
|
"shell",
|
|
&[
|
|
"update_plan",
|
|
"request_user_input",
|
|
"web_search",
|
|
"view_image",
|
|
"spawn_agent",
|
|
"send_input",
|
|
"resume_agent",
|
|
"wait_agent",
|
|
"close_agent",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gpt_5_1_defaults() {
|
|
let features = Features::with_defaults();
|
|
assert_default_model_tools(
|
|
"gpt-5.1",
|
|
&features,
|
|
Some(WebSearchMode::Cached),
|
|
"shell_command",
|
|
&[
|
|
"update_plan",
|
|
"request_user_input",
|
|
"apply_patch",
|
|
"web_search",
|
|
"view_image",
|
|
"spawn_agent",
|
|
"send_input",
|
|
"resume_agent",
|
|
"wait_agent",
|
|
"close_agent",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_gpt_5_1_codex_max_unified_exec_web_search() {
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
assert_model_tools(
|
|
"gpt-5.1-codex-max",
|
|
&features,
|
|
Some(WebSearchMode::Live),
|
|
&[
|
|
"exec_command",
|
|
"write_stdin",
|
|
"update_plan",
|
|
"request_user_input",
|
|
"apply_patch",
|
|
"web_search",
|
|
"view_image",
|
|
"spawn_agent",
|
|
"send_input",
|
|
"resume_agent",
|
|
"wait_agent",
|
|
"close_agent",
|
|
],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_default_shell_present() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Live),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), None, &[]).build();
|
|
|
|
// Only check the shell variant and a couple of core tools.
|
|
let mut subset = vec!["exec_command", "write_stdin", "update_plan"];
|
|
if let Some(shell_tool) = shell_tool_name(&tools_config) {
|
|
subset.push(shell_tool);
|
|
}
|
|
assert_contains_tool_names(&tools, &subset);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
features.enable(Feature::ShellZshFork);
|
|
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Live),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let user_shell = Shell {
|
|
shell_type: ShellType::Zsh,
|
|
shell_path: PathBuf::from("/bin/zsh"),
|
|
shell_snapshot: crate::shell::empty_shell_snapshot_receiver(),
|
|
};
|
|
|
|
assert_eq!(tools_config.shell_type, ConfigShellToolType::ShellCommand);
|
|
assert_eq!(
|
|
tools_config.shell_command_backend,
|
|
ShellCommandBackendConfig::ZshFork
|
|
);
|
|
assert_eq!(
|
|
tools_config.unified_exec_shell_mode,
|
|
UnifiedExecShellMode::Direct
|
|
);
|
|
assert_eq!(
|
|
tools_config
|
|
.with_unified_exec_shell_mode_for_session(
|
|
&user_shell,
|
|
Some(&PathBuf::from(if cfg!(windows) {
|
|
r"C:\opt\codex\zsh"
|
|
} else {
|
|
"/opt/codex/zsh"
|
|
})),
|
|
Some(&PathBuf::from(if cfg!(windows) {
|
|
r"C:\opt\codex\codex-execve-wrapper"
|
|
} else {
|
|
"/opt/codex/codex-execve-wrapper"
|
|
})),
|
|
)
|
|
.unified_exec_shell_mode,
|
|
if cfg!(unix) {
|
|
UnifiedExecShellMode::ZshFork(ZshForkConfig {
|
|
shell_zsh_path: AbsolutePathBuf::from_absolute_path("/opt/codex/zsh").unwrap(),
|
|
main_execve_wrapper_exe: AbsolutePathBuf::from_absolute_path(
|
|
"/opt/codex/codex-execve-wrapper",
|
|
)
|
|
.unwrap(),
|
|
})
|
|
} else {
|
|
UnifiedExecShellMode::Direct
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore]
|
|
fn test_parallel_support_flags() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
|
|
assert!(find_tool(&tools, "exec_command").supports_parallel_tool_calls);
|
|
assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls);
|
|
}
|
|
|
|
#[test]
|
|
fn test_test_model_info_includes_sync_tool() {
|
|
let _config = test_config();
|
|
let mut model_info = model_info_from_models_json("gpt-5-codex");
|
|
model_info.experimental_supported_tools = vec!["test_sync_tool".to_string()];
|
|
let features = Features::with_defaults();
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
|
|
assert!(
|
|
tools
|
|
.iter()
|
|
.any(|tool| tool_name(&tool.spec) == "test_sync_tool")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_mcp_tools_converted() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Live),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(
|
|
&tools_config,
|
|
Some(HashMap::from([(
|
|
"test_server/do_something_cool".to_string(),
|
|
mcp_tool(
|
|
"do_something_cool",
|
|
"Do something cool",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"string_argument": { "type": "string" },
|
|
"number_argument": { "type": "number" },
|
|
"object_argument": {
|
|
"type": "object",
|
|
"properties": {
|
|
"string_property": { "type": "string" },
|
|
"number_property": { "type": "number" },
|
|
},
|
|
"required": ["string_property", "number_property"],
|
|
"additionalProperties": false,
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
)])),
|
|
None,
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
let tool = find_tool(&tools, "test_server/do_something_cool");
|
|
assert_eq!(
|
|
&tool.spec,
|
|
&ToolSpec::Function(ResponsesApiTool {
|
|
name: "test_server/do_something_cool".to_string(),
|
|
parameters: JsonSchema::Object {
|
|
properties: BTreeMap::from([
|
|
(
|
|
"string_argument".to_string(),
|
|
JsonSchema::String { description: None }
|
|
),
|
|
(
|
|
"number_argument".to_string(),
|
|
JsonSchema::Number { description: None }
|
|
),
|
|
(
|
|
"object_argument".to_string(),
|
|
JsonSchema::Object {
|
|
properties: BTreeMap::from([
|
|
(
|
|
"string_property".to_string(),
|
|
JsonSchema::String { description: None }
|
|
),
|
|
(
|
|
"number_property".to_string(),
|
|
JsonSchema::Number { description: None }
|
|
),
|
|
]),
|
|
required: Some(vec![
|
|
"string_property".to_string(),
|
|
"number_property".to_string(),
|
|
]),
|
|
additional_properties: Some(false.into()),
|
|
},
|
|
),
|
|
]),
|
|
required: None,
|
|
additional_properties: None,
|
|
},
|
|
description: "Do something cool".to_string(),
|
|
strict: false,
|
|
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
|
defer_loading: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_build_specs_mcp_tools_sorted_by_name() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("o3", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
// Intentionally construct a map with keys that would sort alphabetically.
|
|
let tools_map: HashMap<String, rmcp::model::Tool> = HashMap::from([
|
|
(
|
|
"test_server/do".to_string(),
|
|
mcp_tool("a", "a", serde_json::json!({"type": "object"})),
|
|
),
|
|
(
|
|
"test_server/something".to_string(),
|
|
mcp_tool("b", "b", serde_json::json!({"type": "object"})),
|
|
),
|
|
(
|
|
"test_server/cool".to_string(),
|
|
mcp_tool("c", "c", serde_json::json!({"type": "object"})),
|
|
),
|
|
]);
|
|
|
|
let (tools, _) = build_specs(&tools_config, Some(tools_map), None, &[]).build();
|
|
|
|
// Only assert that the MCP tools themselves are sorted by fully-qualified name.
|
|
let mcp_names: Vec<_> = tools
|
|
.iter()
|
|
.map(|t| tool_name(&t.spec).to_string())
|
|
.filter(|n| n.starts_with("test_server/"))
|
|
.collect();
|
|
let expected = vec![
|
|
"test_server/cool".to_string(),
|
|
"test_server/do".to_string(),
|
|
"test_server/something".to_string(),
|
|
];
|
|
assert_eq!(mcp_names, expected);
|
|
}
|
|
|
|
#[test]
|
|
fn search_tool_description_lists_each_codex_apps_connector_once() {
|
|
let model_info = search_capable_model_info();
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::Apps);
|
|
features.enable(Feature::ToolSearch);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(
|
|
&tools_config,
|
|
Some(HashMap::from([
|
|
(
|
|
"mcp__codex_apps__calendar_create_event".to_string(),
|
|
mcp_tool(
|
|
"calendar_create_event",
|
|
"Create calendar event",
|
|
serde_json::json!({"type": "object"}),
|
|
),
|
|
),
|
|
(
|
|
"mcp__rmcp__echo".to_string(),
|
|
mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})),
|
|
),
|
|
])),
|
|
Some(HashMap::from([
|
|
(
|
|
"mcp__codex_apps__calendar_create_event".to_string(),
|
|
ToolInfo {
|
|
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
tool_name: "_create_event".to_string(),
|
|
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
|
tool: mcp_tool(
|
|
"calendar-create-event",
|
|
"Create calendar event",
|
|
serde_json::json!({"type": "object"}),
|
|
),
|
|
connector_id: Some("calendar".to_string()),
|
|
connector_name: Some("Calendar".to_string()),
|
|
plugin_display_names: Vec::new(),
|
|
connector_description: Some(
|
|
"Plan events and manage your calendar.".to_string(),
|
|
),
|
|
},
|
|
),
|
|
(
|
|
"mcp__codex_apps__calendar_list_events".to_string(),
|
|
ToolInfo {
|
|
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
tool_name: "_list_events".to_string(),
|
|
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
|
tool: mcp_tool(
|
|
"calendar-list-events",
|
|
"List calendar events",
|
|
serde_json::json!({"type": "object"}),
|
|
),
|
|
connector_id: Some("calendar".to_string()),
|
|
connector_name: Some("Calendar".to_string()),
|
|
plugin_display_names: Vec::new(),
|
|
connector_description: Some(
|
|
"Plan events and manage your calendar.".to_string(),
|
|
),
|
|
},
|
|
),
|
|
(
|
|
"mcp__codex_apps__gmail_search_threads".to_string(),
|
|
ToolInfo {
|
|
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
tool_name: "_search_threads".to_string(),
|
|
tool_namespace: "mcp__codex_apps__gmail".to_string(),
|
|
tool: mcp_tool(
|
|
"gmail-search-threads",
|
|
"Search email threads",
|
|
serde_json::json!({"type": "object"}),
|
|
),
|
|
connector_id: Some("gmail".to_string()),
|
|
connector_name: Some("Gmail".to_string()),
|
|
plugin_display_names: Vec::new(),
|
|
connector_description: Some("Find and summarize email threads.".to_string()),
|
|
},
|
|
),
|
|
(
|
|
"mcp__rmcp__echo".to_string(),
|
|
ToolInfo {
|
|
server_name: "rmcp".to_string(),
|
|
tool_name: "echo".to_string(),
|
|
tool_namespace: "rmcp".to_string(),
|
|
tool: mcp_tool("echo", "Echo", serde_json::json!({"type": "object"})),
|
|
connector_id: None,
|
|
connector_name: None,
|
|
plugin_display_names: Vec::new(),
|
|
connector_description: None,
|
|
},
|
|
),
|
|
])),
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME);
|
|
let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else {
|
|
panic!("expected tool_search tool");
|
|
};
|
|
let description = description.as_str();
|
|
assert!(description.contains("- Calendar: Plan events and manage your calendar."));
|
|
assert!(description.contains("- Gmail: Find and summarize email threads."));
|
|
assert_eq!(
|
|
description
|
|
.matches("- Calendar: Plan events and manage your calendar.")
|
|
.count(),
|
|
1
|
|
);
|
|
assert!(!description.contains("mcp__rmcp__echo"));
|
|
}
|
|
|
|
#[test]
|
|
fn search_tool_requires_model_capability_and_feature_flag() {
|
|
let model_info = search_capable_model_info();
|
|
let app_tools = Some(HashMap::from([(
|
|
"mcp__codex_apps__calendar_create_event".to_string(),
|
|
ToolInfo {
|
|
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
tool_name: "calendar_create_event".to_string(),
|
|
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
|
tool: mcp_tool(
|
|
"calendar_create_event",
|
|
"Create calendar event",
|
|
serde_json::json!({"type": "object"}),
|
|
),
|
|
connector_id: Some("calendar".to_string()),
|
|
connector_name: Some("Calendar".to_string()),
|
|
connector_description: None,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
)]));
|
|
|
|
let features = Features::with_defaults();
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &ModelInfo {
|
|
supports_search_tool: false,
|
|
..model_info.clone()
|
|
},
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
|
|
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
|
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
|
|
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
|
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::ToolSearch);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(&tools_config, None, app_tools, &[]).build();
|
|
assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_suggest_is_not_registered_without_feature_flag() {
|
|
let model_info = search_capable_model_info();
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::ToolSearch);
|
|
features.enable(Feature::Apps);
|
|
features.enable(Feature::Plugins);
|
|
features.disable(Feature::ToolSuggest);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs_with_discoverable_tools(
|
|
&tools_config,
|
|
None,
|
|
None,
|
|
Some(vec![discoverable_connector(
|
|
"connector_2128aebfecb84f64a069897515042a44",
|
|
"Google Calendar",
|
|
"Plan events and schedules.",
|
|
)]),
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
assert!(
|
|
!tools
|
|
.iter()
|
|
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tool_suggest_can_be_registered_without_search_tool() {
|
|
let model_info = ModelInfo {
|
|
supports_search_tool: false,
|
|
..search_capable_model_info()
|
|
};
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::Apps);
|
|
features.enable(Feature::Plugins);
|
|
features.enable(Feature::ToolSuggest);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs_with_discoverable_tools(
|
|
&tools_config,
|
|
None,
|
|
None,
|
|
Some(vec![discoverable_connector(
|
|
"connector_2128aebfecb84f64a069897515042a44",
|
|
"Google Calendar",
|
|
"Plan events and schedules.",
|
|
)]),
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
assert_contains_tool_names(&tools, &[TOOL_SUGGEST_TOOL_NAME]);
|
|
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
|
|
|
let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME);
|
|
let ToolSpec::Function(ResponsesApiTool { description, .. }) = &tool_suggest.spec else {
|
|
panic!("expected function tool");
|
|
};
|
|
assert!(description.contains(
|
|
"Suggests a missing connector in an installed plugin, or in narrower cases a not installed but discoverable plugin"
|
|
));
|
|
assert!(description.contains(
|
|
"You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means."
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_suggest_requires_apps_and_plugins_features() {
|
|
let model_info = search_capable_model_info();
|
|
let discoverable_tools = Some(vec![discoverable_connector(
|
|
"connector_2128aebfecb84f64a069897515042a44",
|
|
"Google Calendar",
|
|
"Plan events and schedules.",
|
|
)]);
|
|
let available_models = Vec::new();
|
|
|
|
for disabled_feature in [Feature::Apps, Feature::Plugins] {
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::ToolSearch);
|
|
features.enable(Feature::ToolSuggest);
|
|
features.enable(Feature::Apps);
|
|
features.enable(Feature::Plugins);
|
|
features.disable(disabled_feature);
|
|
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs_with_discoverable_tools(
|
|
&tools_config,
|
|
None,
|
|
None,
|
|
discoverable_tools.clone(),
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
assert!(
|
|
!tools
|
|
.iter()
|
|
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME),
|
|
"tool_suggest should be absent when {disabled_feature:?} is disabled"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn search_tool_description_handles_no_enabled_apps() {
|
|
let model_info = search_capable_model_info();
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::Apps);
|
|
features.enable(Feature::ToolSearch);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(&tools_config, None, Some(HashMap::new()), &[]).build();
|
|
let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME);
|
|
let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else {
|
|
panic!("expected tool_search tool");
|
|
};
|
|
|
|
assert!(description.contains("None currently enabled."));
|
|
assert!(!description.contains("{{app_descriptions}}"));
|
|
}
|
|
|
|
#[test]
|
|
fn search_tool_description_falls_back_to_connector_name_without_description() {
|
|
let model_info = search_capable_model_info();
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::Apps);
|
|
features.enable(Feature::ToolSearch);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(
|
|
&tools_config,
|
|
None,
|
|
Some(HashMap::from([(
|
|
"mcp__codex_apps__calendar_create_event".to_string(),
|
|
ToolInfo {
|
|
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
tool_name: "_create_event".to_string(),
|
|
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
|
tool: mcp_tool(
|
|
"calendar_create_event",
|
|
"Create calendar event",
|
|
serde_json::json!({"type": "object"}),
|
|
),
|
|
connector_id: Some("calendar".to_string()),
|
|
connector_name: Some("Calendar".to_string()),
|
|
plugin_display_names: Vec::new(),
|
|
connector_description: None,
|
|
},
|
|
)])),
|
|
&[],
|
|
)
|
|
.build();
|
|
let search_tool = find_tool(&tools, TOOL_SEARCH_TOOL_NAME);
|
|
let ToolSpec::ToolSearch { description, .. } = &search_tool.spec else {
|
|
panic!("expected tool_search tool");
|
|
};
|
|
|
|
assert!(description.contains("- Calendar"));
|
|
assert!(!description.contains("- Calendar:"));
|
|
}
|
|
|
|
#[test]
|
|
fn search_tool_registers_namespaced_app_tool_aliases() {
|
|
let model_info = search_capable_model_info();
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::Apps);
|
|
features.enable(Feature::ToolSearch);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (_, registry) = build_specs(
|
|
&tools_config,
|
|
None,
|
|
Some(HashMap::from([
|
|
(
|
|
"mcp__codex_apps__calendar_create_event".to_string(),
|
|
ToolInfo {
|
|
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
tool_name: "_create_event".to_string(),
|
|
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
|
tool: mcp_tool(
|
|
"calendar-create-event",
|
|
"Create calendar event",
|
|
serde_json::json!({"type": "object"}),
|
|
),
|
|
connector_id: Some("calendar".to_string()),
|
|
connector_name: Some("Calendar".to_string()),
|
|
connector_description: None,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
),
|
|
(
|
|
"mcp__codex_apps__calendar_list_events".to_string(),
|
|
ToolInfo {
|
|
server_name: crate::mcp::CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
|
tool_name: "_list_events".to_string(),
|
|
tool_namespace: "mcp__codex_apps__calendar".to_string(),
|
|
tool: mcp_tool(
|
|
"calendar-list-events",
|
|
"List calendar events",
|
|
serde_json::json!({"type": "object"}),
|
|
),
|
|
connector_id: Some("calendar".to_string()),
|
|
connector_name: Some("Calendar".to_string()),
|
|
connector_description: None,
|
|
plugin_display_names: Vec::new(),
|
|
},
|
|
),
|
|
])),
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
let alias = tool_handler_key("_create_event", Some("mcp__codex_apps__calendar"));
|
|
|
|
assert!(registry.has_handler(TOOL_SEARCH_TOOL_NAME, None));
|
|
assert!(registry.has_handler(alias.as_str(), None));
|
|
}
|
|
|
|
#[test]
|
|
fn tool_suggest_description_lists_discoverable_tools() {
|
|
let model_info = search_capable_model_info();
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::Apps);
|
|
features.enable(Feature::Plugins);
|
|
features.enable(Feature::ToolSearch);
|
|
features.enable(Feature::ToolSuggest);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let discoverable_tools = vec![
|
|
discoverable_connector(
|
|
"connector_2128aebfecb84f64a069897515042a44",
|
|
"Google Calendar",
|
|
"Plan events and schedules.",
|
|
),
|
|
discoverable_connector(
|
|
"connector_68df038e0ba48191908c8434991bbac2",
|
|
"Gmail",
|
|
"Find and summarize email threads.",
|
|
),
|
|
DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
|
|
id: "sample@test".to_string(),
|
|
name: "Sample Plugin".to_string(),
|
|
description: None,
|
|
has_skills: true,
|
|
mcp_server_names: vec!["sample-docs".to_string()],
|
|
app_connector_ids: vec!["connector_sample".to_string()],
|
|
})),
|
|
];
|
|
|
|
let (tools, _) = build_specs_with_discoverable_tools(
|
|
&tools_config,
|
|
None,
|
|
None,
|
|
Some(discoverable_tools),
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME);
|
|
let ToolSpec::Function(ResponsesApiTool {
|
|
description,
|
|
parameters,
|
|
..
|
|
}) = &tool_suggest.spec
|
|
else {
|
|
panic!("expected function tool");
|
|
};
|
|
assert!(description.contains(
|
|
"Suggests a missing connector in an installed plugin, or in narrower cases a not installed but discoverable plugin"
|
|
));
|
|
assert!(description.contains("Google Calendar"));
|
|
assert!(description.contains("Gmail"));
|
|
assert!(description.contains("Sample Plugin"));
|
|
assert!(description.contains("Plan events and schedules."));
|
|
assert!(description.contains("Find and summarize email threads."));
|
|
assert!(description.contains("id: `sample@test`, type: plugin, action: install"));
|
|
assert!(description.contains("`action_type`: `install` or `enable`"));
|
|
assert!(
|
|
description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample")
|
|
);
|
|
assert!(
|
|
description.contains(
|
|
"You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means."
|
|
)
|
|
);
|
|
assert!(description.contains(
|
|
"For connectors/apps that are not installed but needed for an installed plugin, suggest to install them if the task requirements match precisely."
|
|
));
|
|
assert!(description.contains(
|
|
"For plugins that are not installed but discoverable, only suggest discoverable and installable plugins when the user's intent very explicitly and unambiguously matches that plugin itself."
|
|
));
|
|
assert!(description.contains(
|
|
"Do not suggest a plugin just because one of its connectors or capabilities seems relevant."
|
|
));
|
|
assert!(description.contains(
|
|
"Apply the stricter explicit-and-unambiguous rule for *discoverable tools* like plugin install suggestions; *missing tools* like connector install suggestions continue to use the normal clear-fit standard."
|
|
));
|
|
assert!(description.contains("DO NOT explore or recommend tools that are not on this list."));
|
|
assert!(!description.contains("tool_search fails to find a good match"));
|
|
let JsonSchema::Object { required, .. } = parameters else {
|
|
panic!("expected object parameters");
|
|
};
|
|
assert_eq!(
|
|
required.as_ref(),
|
|
Some(&vec![
|
|
"tool_type".to_string(),
|
|
"action_type".to_string(),
|
|
"tool_id".to_string(),
|
|
"suggest_reason".to_string(),
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_tool_property_missing_type_defaults_to_string() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(
|
|
&tools_config,
|
|
Some(HashMap::from([(
|
|
"dash/search".to_string(),
|
|
mcp_tool(
|
|
"search",
|
|
"Search docs",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"description": "search query"}
|
|
}
|
|
}),
|
|
),
|
|
)])),
|
|
None,
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
let tool = find_tool(&tools, "dash/search");
|
|
assert_eq!(
|
|
tool.spec,
|
|
ToolSpec::Function(ResponsesApiTool {
|
|
name: "dash/search".to_string(),
|
|
parameters: JsonSchema::Object {
|
|
properties: BTreeMap::from([(
|
|
"query".to_string(),
|
|
JsonSchema::String {
|
|
description: Some("search query".to_string())
|
|
}
|
|
)]),
|
|
required: None,
|
|
additional_properties: None,
|
|
},
|
|
description: "Search docs".to_string(),
|
|
strict: false,
|
|
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
|
defer_loading: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_tool_integer_normalized_to_number() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(
|
|
&tools_config,
|
|
Some(HashMap::from([(
|
|
"dash/paginate".to_string(),
|
|
mcp_tool(
|
|
"paginate",
|
|
"Pagination",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {"page": {"type": "integer"}}
|
|
}),
|
|
),
|
|
)])),
|
|
None,
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
let tool = find_tool(&tools, "dash/paginate");
|
|
assert_eq!(
|
|
tool.spec,
|
|
ToolSpec::Function(ResponsesApiTool {
|
|
name: "dash/paginate".to_string(),
|
|
parameters: JsonSchema::Object {
|
|
properties: BTreeMap::from([(
|
|
"page".to_string(),
|
|
JsonSchema::Number { description: None }
|
|
)]),
|
|
required: None,
|
|
additional_properties: None,
|
|
},
|
|
description: "Pagination".to_string(),
|
|
strict: false,
|
|
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
|
defer_loading: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_tool_array_without_items_gets_default_string_items() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
features.enable(Feature::ApplyPatchFreeform);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(
|
|
&tools_config,
|
|
Some(HashMap::from([(
|
|
"dash/tags".to_string(),
|
|
mcp_tool(
|
|
"tags",
|
|
"Tags",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {"tags": {"type": "array"}}
|
|
}),
|
|
),
|
|
)])),
|
|
None,
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
let tool = find_tool(&tools, "dash/tags");
|
|
assert_eq!(
|
|
tool.spec,
|
|
ToolSpec::Function(ResponsesApiTool {
|
|
name: "dash/tags".to_string(),
|
|
parameters: JsonSchema::Object {
|
|
properties: BTreeMap::from([(
|
|
"tags".to_string(),
|
|
JsonSchema::Array {
|
|
items: Box::new(JsonSchema::String { description: None }),
|
|
description: None
|
|
}
|
|
)]),
|
|
required: None,
|
|
additional_properties: None,
|
|
},
|
|
description: "Tags".to_string(),
|
|
strict: false,
|
|
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
|
defer_loading: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_mcp_tool_anyof_defaults_to_string() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(
|
|
&tools_config,
|
|
Some(HashMap::from([(
|
|
"dash/value".to_string(),
|
|
mcp_tool(
|
|
"value",
|
|
"AnyOf Value",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"value": {"anyOf": [{"type": "string"}, {"type": "number"}]}
|
|
}
|
|
}),
|
|
),
|
|
)])),
|
|
None,
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
let tool = find_tool(&tools, "dash/value");
|
|
assert_eq!(
|
|
tool.spec,
|
|
ToolSpec::Function(ResponsesApiTool {
|
|
name: "dash/value".to_string(),
|
|
parameters: JsonSchema::Object {
|
|
properties: BTreeMap::from([(
|
|
"value".to_string(),
|
|
JsonSchema::String { description: None }
|
|
)]),
|
|
required: None,
|
|
additional_properties: None,
|
|
},
|
|
description: "AnyOf Value".to_string(),
|
|
strict: false,
|
|
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
|
defer_loading: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_tool() {
|
|
let tool = super::create_shell_tool(false);
|
|
let ToolSpec::Function(ResponsesApiTool {
|
|
description, name, ..
|
|
}) = &tool
|
|
else {
|
|
panic!("expected function tool");
|
|
};
|
|
assert_eq!(name, "shell");
|
|
|
|
let expected = if cfg!(windows) {
|
|
r#"Runs a Powershell command (Windows) and returns its output. Arguments to `shell` will be passed to CreateProcessW(). Most commands should be prefixed with ["powershell.exe", "-Command"].
|
|
|
|
Examples of valid command strings:
|
|
|
|
- ls -a (show hidden): ["powershell.exe", "-Command", "Get-ChildItem -Force"]
|
|
- recursive find by name: ["powershell.exe", "-Command", "Get-ChildItem -Recurse -Filter *.py"]
|
|
- recursive grep: ["powershell.exe", "-Command", "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"]
|
|
- ps aux | grep python: ["powershell.exe", "-Command", "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"]
|
|
- setting an env var: ["powershell.exe", "-Command", "$env:FOO='bar'; echo $env:FOO"]
|
|
- running an inline Python script: ["powershell.exe", "-Command", "@'\\nprint('Hello, world!')\\n'@ | python -"]"#
|
|
.to_string()
|
|
+ &windows_shell_safety_description()
|
|
} else {
|
|
r#"Runs a shell command and returns its output.
|
|
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
|
|
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary."#
|
|
.to_string()
|
|
};
|
|
assert_eq!(description, &expected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_exec_command_tool_windows_description_includes_shell_safety_guidance() {
|
|
let tool = super::create_exec_command_tool(true, false);
|
|
let ToolSpec::Function(ResponsesApiTool {
|
|
description, name, ..
|
|
}) = &tool
|
|
else {
|
|
panic!("expected function tool");
|
|
};
|
|
assert_eq!(name, "exec_command");
|
|
|
|
let expected = if cfg!(windows) {
|
|
format!(
|
|
"Runs a command in a PTY, returning output or a session ID for ongoing interaction.{}",
|
|
windows_shell_safety_description()
|
|
)
|
|
} else {
|
|
"Runs a command in a PTY, returning output or a session ID for ongoing interaction."
|
|
.to_string()
|
|
};
|
|
assert_eq!(description, &expected);
|
|
}
|
|
|
|
#[test]
|
|
fn shell_tool_with_request_permission_includes_additional_permissions() {
|
|
let tool = super::create_shell_tool(true);
|
|
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
|
|
panic!("expected function tool");
|
|
};
|
|
let JsonSchema::Object { properties, .. } = parameters else {
|
|
panic!("expected object parameters");
|
|
};
|
|
|
|
assert!(properties.contains_key("additional_permissions"));
|
|
|
|
let Some(JsonSchema::String {
|
|
description: Some(description),
|
|
}) = properties.get("sandbox_permissions")
|
|
else {
|
|
panic!("expected sandbox_permissions description");
|
|
};
|
|
assert!(description.contains("with_additional_permissions"));
|
|
assert!(description.contains("filesystem or network permissions"));
|
|
|
|
let Some(JsonSchema::Object {
|
|
properties: additional_properties,
|
|
..
|
|
}) = properties.get("additional_permissions")
|
|
else {
|
|
panic!("expected additional_permissions schema");
|
|
};
|
|
assert!(additional_properties.contains_key("network"));
|
|
assert!(additional_properties.contains_key("file_system"));
|
|
assert!(!additional_properties.contains_key("macos"));
|
|
}
|
|
|
|
#[test]
|
|
fn request_permissions_tool_includes_full_permission_schema() {
|
|
let tool = super::create_request_permissions_tool();
|
|
let ToolSpec::Function(ResponsesApiTool { parameters, .. }) = tool else {
|
|
panic!("expected function tool");
|
|
};
|
|
let JsonSchema::Object { properties, .. } = parameters else {
|
|
panic!("expected object parameters");
|
|
};
|
|
let Some(JsonSchema::Object {
|
|
properties: permission_properties,
|
|
additional_properties,
|
|
..
|
|
}) = properties.get("permissions")
|
|
else {
|
|
panic!("expected permissions object");
|
|
};
|
|
|
|
assert_eq!(additional_properties, &Some(false.into()));
|
|
assert!(permission_properties.contains_key("network"));
|
|
assert!(permission_properties.contains_key("file_system"));
|
|
assert!(!permission_properties.contains_key("macos"));
|
|
|
|
let Some(JsonSchema::Object {
|
|
properties: network_properties,
|
|
additional_properties,
|
|
..
|
|
}) = permission_properties.get("network")
|
|
else {
|
|
panic!("expected network object");
|
|
};
|
|
assert_eq!(additional_properties, &Some(false.into()));
|
|
assert!(network_properties.contains_key("enabled"));
|
|
|
|
let Some(JsonSchema::Object {
|
|
properties: file_system_properties,
|
|
additional_properties,
|
|
..
|
|
}) = permission_properties.get("file_system")
|
|
else {
|
|
panic!("expected file_system object");
|
|
};
|
|
assert_eq!(additional_properties, &Some(false.into()));
|
|
assert!(file_system_properties.contains_key("read"));
|
|
assert!(file_system_properties.contains_key("write"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_shell_command_tool() {
|
|
let tool = super::create_shell_command_tool(true, false);
|
|
let ToolSpec::Function(ResponsesApiTool {
|
|
description, name, ..
|
|
}) = &tool
|
|
else {
|
|
panic!("expected function tool");
|
|
};
|
|
assert_eq!(name, "shell_command");
|
|
|
|
let expected = if cfg!(windows) {
|
|
r#"Runs a Powershell command (Windows) and returns its output.
|
|
|
|
Examples of valid command strings:
|
|
|
|
- ls -a (show hidden): "Get-ChildItem -Force"
|
|
- recursive find by name: "Get-ChildItem -Recurse -Filter *.py"
|
|
- recursive grep: "Get-ChildItem -Path C:\\myrepo -Recurse | Select-String -Pattern 'TODO' -CaseSensitive"
|
|
- ps aux | grep python: "Get-Process | Where-Object { $_.ProcessName -like '*python*' }"
|
|
- setting an env var: "$env:FOO='bar'; echo $env:FOO"
|
|
- running an inline Python script: "@'\\nprint('Hello, world!')\\n'@ | python -""#
|
|
.to_string()
|
|
+ &windows_shell_safety_description()
|
|
} else {
|
|
r#"Runs a shell command and returns its output.
|
|
- Always set the `workdir` param when using the shell_command function. Do not use `cd` unless absolutely necessary."#.to_string()
|
|
};
|
|
assert_eq!(description, &expected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
let (tools, _) = build_specs(
|
|
&tools_config,
|
|
Some(HashMap::from([(
|
|
"test_server/do_something_cool".to_string(),
|
|
mcp_tool(
|
|
"do_something_cool",
|
|
"Do something cool",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"string_argument": {"type": "string"},
|
|
"number_argument": {"type": "number"},
|
|
"object_argument": {
|
|
"type": "object",
|
|
"properties": {
|
|
"string_property": {"type": "string"},
|
|
"number_property": {"type": "number"}
|
|
},
|
|
"required": ["string_property", "number_property"],
|
|
"additionalProperties": {
|
|
"type": "object",
|
|
"properties": {
|
|
"addtl_prop": {"type": "string"}
|
|
},
|
|
"required": ["addtl_prop"],
|
|
"additionalProperties": false
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
),
|
|
)])),
|
|
None,
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
let tool = find_tool(&tools, "test_server/do_something_cool");
|
|
assert_eq!(
|
|
tool.spec,
|
|
ToolSpec::Function(ResponsesApiTool {
|
|
name: "test_server/do_something_cool".to_string(),
|
|
parameters: JsonSchema::Object {
|
|
properties: BTreeMap::from([
|
|
(
|
|
"string_argument".to_string(),
|
|
JsonSchema::String { description: None }
|
|
),
|
|
(
|
|
"number_argument".to_string(),
|
|
JsonSchema::Number { description: None }
|
|
),
|
|
(
|
|
"object_argument".to_string(),
|
|
JsonSchema::Object {
|
|
properties: BTreeMap::from([
|
|
(
|
|
"string_property".to_string(),
|
|
JsonSchema::String { description: None }
|
|
),
|
|
(
|
|
"number_property".to_string(),
|
|
JsonSchema::Number { description: None }
|
|
),
|
|
]),
|
|
required: Some(vec![
|
|
"string_property".to_string(),
|
|
"number_property".to_string(),
|
|
]),
|
|
additional_properties: Some(
|
|
JsonSchema::Object {
|
|
properties: BTreeMap::from([(
|
|
"addtl_prop".to_string(),
|
|
JsonSchema::String { description: None }
|
|
),]),
|
|
required: Some(vec!["addtl_prop".to_string(),]),
|
|
additional_properties: Some(false.into()),
|
|
}
|
|
.into()
|
|
),
|
|
},
|
|
),
|
|
]),
|
|
required: None,
|
|
additional_properties: None,
|
|
},
|
|
description: "Do something cool".to_string(),
|
|
strict: false,
|
|
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
|
defer_loading: None,
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn code_mode_augments_builtin_tool_descriptions_with_typed_sample() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::CodeMode);
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
let ToolSpec::Function(ResponsesApiTool { description, .. }) =
|
|
&find_tool(&tools, "view_image").spec
|
|
else {
|
|
panic!("expected function tool");
|
|
};
|
|
|
|
assert_eq!(
|
|
description,
|
|
"View a local image from the filesystem (only use if given a full filepath by the user, and the image isn't already attached to the thread context within <image ...> tags).\n\nexec tool declaration:\n```ts\ndeclare const tools: { view_image(args: { path: string; }): Promise<{ detail: string | null; image_url: string; }>; };\n```"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn code_mode_augments_mcp_tool_descriptions_with_namespaced_sample() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::CodeMode);
|
|
features.enable(Feature::UnifiedExec);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(
|
|
&tools_config,
|
|
Some(HashMap::from([(
|
|
"mcp__sample__echo".to_string(),
|
|
mcp_tool(
|
|
"echo",
|
|
"Echo text",
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"message": {"type": "string"}
|
|
},
|
|
"required": ["message"],
|
|
"additionalProperties": false
|
|
}),
|
|
),
|
|
)])),
|
|
None,
|
|
&[],
|
|
)
|
|
.build();
|
|
|
|
let ToolSpec::Function(ResponsesApiTool { description, .. }) =
|
|
&find_tool(&tools, "mcp__sample__echo").spec
|
|
else {
|
|
panic!("expected function tool");
|
|
};
|
|
|
|
assert_eq!(
|
|
description,
|
|
"Echo text\n\nexec tool declaration:\n```ts\ndeclare const tools: { mcp__sample__echo(args: { message: string; }): Promise<{ _meta?: unknown; content: Array<unknown>; isError?: boolean; structuredContent?: unknown; }>; };\n```"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn code_mode_only_restricts_model_tools_to_exec_tools() {
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::CodeMode);
|
|
features.enable(Feature::CodeModeOnly);
|
|
|
|
assert_model_tools(
|
|
"gpt-5.1-codex",
|
|
&features,
|
|
Some(WebSearchMode::Live),
|
|
&["exec", "wait"],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn code_mode_only_exec_description_includes_full_nested_tool_details() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::CodeMode);
|
|
features.enable(Feature::CodeModeOnly);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
let ToolSpec::Freeform(FreeformTool { description, .. }) = &find_tool(&tools, "exec").spec
|
|
else {
|
|
panic!("expected freeform tool");
|
|
};
|
|
|
|
assert!(!description.contains("Enabled nested tools:"));
|
|
assert!(!description.contains("Nested tool reference:"));
|
|
assert!(description.starts_with(
|
|
"Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"
|
|
));
|
|
assert!(description.contains("### `update_plan` (`update_plan`)"));
|
|
assert!(description.contains("### `view_image` (`view_image`)"));
|
|
}
|
|
|
|
#[test]
|
|
fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only() {
|
|
let config = test_config();
|
|
let model_info = ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
|
let mut features = Features::with_defaults();
|
|
features.enable(Feature::CodeMode);
|
|
let available_models = Vec::new();
|
|
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
|
model_info: &model_info,
|
|
available_models: &available_models,
|
|
features: &features,
|
|
web_search_mode: Some(WebSearchMode::Cached),
|
|
session_source: SessionSource::Cli,
|
|
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
|
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
|
});
|
|
|
|
let (tools, _) = build_specs(&tools_config, None, None, &[]).build();
|
|
let ToolSpec::Freeform(FreeformTool { description, .. }) = &find_tool(&tools, "exec").spec
|
|
else {
|
|
panic!("expected freeform tool");
|
|
};
|
|
|
|
assert!(!description.starts_with(
|
|
"Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"
|
|
));
|
|
assert!(!description.contains("### `update_plan` (`update_plan`)"));
|
|
assert!(!description.contains("### `view_image` (`view_image`)"));
|
|
}
|
|
|
|
#[test]
|
|
fn chat_tools_include_top_level_name() {
|
|
let properties =
|
|
BTreeMap::from([("foo".to_string(), JsonSchema::String { description: None })]);
|
|
let tools = vec![ToolSpec::Function(ResponsesApiTool {
|
|
name: "demo".to_string(),
|
|
description: "A demo tool".to_string(),
|
|
strict: false,
|
|
defer_loading: None,
|
|
parameters: JsonSchema::Object {
|
|
properties,
|
|
required: None,
|
|
additional_properties: None,
|
|
},
|
|
output_schema: None,
|
|
})];
|
|
|
|
let responses_json = create_tools_json_for_responses_api(&tools).unwrap();
|
|
assert_eq!(
|
|
responses_json,
|
|
vec![json!({
|
|
"type": "function",
|
|
"name": "demo",
|
|
"description": "A demo tool",
|
|
"strict": false,
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"foo": { "type": "string" }
|
|
},
|
|
},
|
|
})]
|
|
);
|
|
}
|