mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Enable standalone web search in code mode (#26719)
## What - Consume plaintext `output` from standalone search while retaining optional `encrypted_output` parsing. - Expose `web.run` to code mode and return search output to nested JavaScript calls. - Cover direct and code-mode standalone search paths with integration tests. ## Why `/v1/alpha/search` now returns plaintext output, which code mode needs to consume standalone search results. ## Test plan - `just test -p codex-api` - `just test -p codex-web-search-extension` - `just test -p codex-core code_mode_can_call_standalone_web_search` - `just test -p codex-app-server standalone_web_search_round_trips_output`
This commit is contained in:
committed by
GitHub
Unverified
parent
5a440c03f2
commit
ed6e5cf919
@@ -41,7 +41,7 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
|
||||
async fn standalone_web_search_round_trips_output() -> Result<()> {
|
||||
let call_id = "web-run-1";
|
||||
let server = responses::start_mock_server().await;
|
||||
mount_search_response(&server).await;
|
||||
@@ -170,8 +170,8 @@ async fn standalone_web_search_round_trips_encrypted_output() -> Result<()> {
|
||||
"type": "function_call_output",
|
||||
"call_id": call_id,
|
||||
"output": [{
|
||||
"type": "encrypted_content",
|
||||
"encrypted_content": "ciphertext",
|
||||
"type": "input_text",
|
||||
"text": "Search result",
|
||||
}],
|
||||
})
|
||||
);
|
||||
@@ -259,6 +259,7 @@ async fn mount_search_response(server: &MockServer) {
|
||||
.and(path("/api/codex/alpha/search"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"encrypted_output": "ciphertext",
|
||||
"output": "Search result",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
|
||||
@@ -134,10 +134,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn search_posts_typed_request_and_parses_encrypted_output() {
|
||||
async fn search_posts_typed_request_and_parses_output() {
|
||||
let transport = CapturingTransport::new(
|
||||
serde_json::to_vec(&json!({"encrypted_output": "ciphertext"}))
|
||||
.expect("serialize response"),
|
||||
serde_json::to_vec(&json!({
|
||||
"encrypted_output": "ciphertext",
|
||||
"output": "search result",
|
||||
}))
|
||||
.expect("serialize response"),
|
||||
);
|
||||
let client = SearchClient::new(transport.clone(), provider(), Arc::new(DummyAuth));
|
||||
|
||||
@@ -203,7 +206,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
response,
|
||||
SearchResponse {
|
||||
encrypted_output: "ciphertext".to_string(),
|
||||
encrypted_output: Some("ciphertext".to_string()),
|
||||
output: "search result".to_string(),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -280,5 +280,6 @@ pub enum AllowedCaller {
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
pub struct SearchResponse {
|
||||
pub encrypted_output: String,
|
||||
pub encrypted_output: Option<String>,
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
@@ -6,9 +6,11 @@ use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_core::config::Config;
|
||||
use codex_extension_api::ExtensionRegistryBuilder;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_models_manager::bundled_models_response;
|
||||
use codex_protocol::config_types::WebSearchMode;
|
||||
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
@@ -17,6 +19,7 @@ use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_web_search_extension::install as install_web_search_extension;
|
||||
use core_test_support::apps_test_server::AppsTestServer;
|
||||
use core_test_support::apps_test_server::AppsTestToolLoading;
|
||||
use core_test_support::apps_test_server::DIRECT_CALENDAR_APP_ONLY_TOOL;
|
||||
@@ -45,9 +48,14 @@ use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
fn custom_tool_output_items(req: &ResponsesRequest, call_id: &str) -> Vec<Value> {
|
||||
match req.custom_tool_call_output(call_id).get("output") {
|
||||
@@ -191,6 +199,108 @@ async fn run_code_mode_turn_with_config(
|
||||
Ok((test, second_mock))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_call_standalone_web_search() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/alpha/search"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"output": "Search result",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call(
|
||||
"call-1",
|
||||
"exec",
|
||||
r#"
|
||||
const result = await tools.web__run({
|
||||
search_query: [{ q: "standalone web search" }],
|
||||
});
|
||||
text(result);
|
||||
"#,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let follow_up_mock = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let auth = CodexAuth::from_api_key("dummy");
|
||||
let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone());
|
||||
let mut extension_builder = ExtensionRegistryBuilder::<Config>::new();
|
||||
install_web_search_extension(&mut extension_builder, auth_manager);
|
||||
let mut builder = test_codex()
|
||||
.with_auth(auth)
|
||||
.with_extensions(Arc::new(extension_builder.build()))
|
||||
.with_model("test-gpt-5.1-codex")
|
||||
.with_config(|config| {
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodeMode)
|
||||
.expect("code mode should be enabled");
|
||||
config
|
||||
.features
|
||||
.enable(Feature::StandaloneWebSearch)
|
||||
.expect("standalone web search should be enabled");
|
||||
config
|
||||
.web_search_mode
|
||||
.set(WebSearchMode::Live)
|
||||
.expect("web search mode should be accepted");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
test.submit_turn("Search the web from code mode").await?;
|
||||
|
||||
let search_request = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("received requests should be available")
|
||||
.into_iter()
|
||||
.find(|request| request.url.path() == "/v1/alpha/search")
|
||||
.expect("standalone search request should be sent");
|
||||
let search_body = search_request
|
||||
.body_json::<Value>()
|
||||
.expect("search request body should be JSON");
|
||||
assert_eq!(
|
||||
search_body["model"],
|
||||
serde_json::json!("test-gpt-5.1-codex")
|
||||
);
|
||||
assert_eq!(
|
||||
search_body["commands"],
|
||||
serde_json::json!({
|
||||
"search_query": [{"q": "standalone web search"}],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
search_body["settings"],
|
||||
serde_json::json!({
|
||||
"allowed_callers": ["direct"],
|
||||
"external_web_access": true,
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
custom_tool_output_last_non_empty_text(&follow_up_mock.single_request(), "call-1"),
|
||||
Some("Search result".to_string())
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_code_mode_turn_with_rmcp(
|
||||
server: &MockServer,
|
||||
prompt: &str,
|
||||
|
||||
@@ -4,19 +4,19 @@ use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
|
||||
pub(crate) struct EncryptedSearchOutput {
|
||||
encrypted_output: String,
|
||||
pub(crate) struct SearchOutput {
|
||||
output: String,
|
||||
}
|
||||
|
||||
impl EncryptedSearchOutput {
|
||||
pub(crate) fn new(encrypted_output: String) -> Self {
|
||||
Self { encrypted_output }
|
||||
impl SearchOutput {
|
||||
pub(crate) fn new(output: String) -> Self {
|
||||
Self { output }
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolOutput for EncryptedSearchOutput {
|
||||
impl ToolOutput for SearchOutput {
|
||||
fn log_preview(&self) -> String {
|
||||
"[encrypted standalone web search output]".to_string()
|
||||
"[standalone web search output]".to_string()
|
||||
}
|
||||
|
||||
fn success_for_logging(&self) -> bool {
|
||||
@@ -29,8 +29,8 @@ impl ToolOutput for EncryptedSearchOutput {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id: call_id.to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::EncryptedContent {
|
||||
encrypted_content: self.encrypted_output.clone(),
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: self.output.clone(),
|
||||
},
|
||||
]),
|
||||
}
|
||||
@@ -45,12 +45,12 @@ mod tests {
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::EncryptedSearchOutput;
|
||||
use super::SearchOutput;
|
||||
use super::ToolOutput;
|
||||
|
||||
#[test]
|
||||
fn emits_encrypted_function_call_output() {
|
||||
let output = EncryptedSearchOutput::new("encrypted-search-output".to_string());
|
||||
fn emits_plaintext_function_call_output() {
|
||||
let output = SearchOutput::new("search output".to_string());
|
||||
|
||||
assert_eq!(
|
||||
output.to_response_item(
|
||||
@@ -62,8 +62,8 @@ mod tests {
|
||||
ResponseInputItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::EncryptedContent {
|
||||
encrypted_content: "encrypted-search-output".to_string(),
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "search output".to_string(),
|
||||
},
|
||||
]),
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ use http::HeaderMap;
|
||||
use url::Url;
|
||||
|
||||
use crate::history::recent_input;
|
||||
use crate::output::EncryptedSearchOutput;
|
||||
use crate::output::SearchOutput;
|
||||
use crate::schema::commands_schema;
|
||||
|
||||
pub(crate) const WEB_NAMESPACE: &str = "web";
|
||||
@@ -67,7 +67,7 @@ impl ToolExecutor<ToolCall> for WebSearchTool {
|
||||
}
|
||||
|
||||
fn exposure(&self) -> ToolExposure {
|
||||
ToolExposure::DirectModelOnly
|
||||
ToolExposure::Direct
|
||||
}
|
||||
|
||||
fn supports_parallel_tool_calls(&self) -> bool {
|
||||
@@ -114,9 +114,7 @@ impl ToolExecutor<ToolCall> for WebSearchTool {
|
||||
.emit_completed(web_search_item(&call.call_id, command_action))
|
||||
.await;
|
||||
|
||||
Ok(Box::new(EncryptedSearchOutput::new(
|
||||
response.encrypted_output,
|
||||
)))
|
||||
Ok(Box::new(SearchOutput::new(response.output)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user