mirror of
https://github.com/openai/codex.git
synced 2026-05-01 01:47:18 +00:00
Merge remote-tracking branch 'origin/rhan/surface-updates' into rhan/emittance
This commit is contained in:
@@ -12,6 +12,9 @@ use wiremock::matchers::path_regex;
|
||||
|
||||
const CONNECTOR_ID: &str = "calendar";
|
||||
const CONNECTOR_NAME: &str = "Calendar";
|
||||
const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44";
|
||||
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
|
||||
const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar.";
|
||||
const PROTOCOL_VERSION: &str = "2025-11-25";
|
||||
const SERVER_NAME: &str = "codex-apps-test";
|
||||
const SERVER_VERSION: &str = "1.0.0";
|
||||
@@ -31,7 +34,13 @@ impl AppsTestServer {
|
||||
connector_name: &str,
|
||||
) -> Result<Self> {
|
||||
mount_oauth_metadata(server).await;
|
||||
mount_streamable_http_json_rpc(server, connector_name.to_string()).await;
|
||||
mount_connectors_directory(server).await;
|
||||
mount_streamable_http_json_rpc(
|
||||
server,
|
||||
connector_name.to_string(),
|
||||
CONNECTOR_DESCRIPTION.to_string(),
|
||||
)
|
||||
.await;
|
||||
Ok(Self {
|
||||
chatgpt_base_url: server.uri(),
|
||||
})
|
||||
@@ -50,16 +59,55 @@ async fn mount_oauth_metadata(server: &MockServer) {
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_streamable_http_json_rpc(server: &MockServer, connector_name: String) {
|
||||
async fn mount_connectors_directory(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/connectors/directory/list"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"apps": [
|
||||
{
|
||||
"id": DISCOVERABLE_CALENDAR_ID,
|
||||
"name": "Google Calendar",
|
||||
"description": "Plan events and schedules.",
|
||||
},
|
||||
{
|
||||
"id": DISCOVERABLE_GMAIL_ID,
|
||||
"name": "Gmail",
|
||||
"description": "Find and summarize email threads.",
|
||||
}
|
||||
],
|
||||
"nextToken": null
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/connectors/directory/list_workspace"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"apps": [],
|
||||
"nextToken": null
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_streamable_http_json_rpc(
|
||||
server: &MockServer,
|
||||
connector_name: String,
|
||||
connector_description: String,
|
||||
) {
|
||||
Mock::given(method("POST"))
|
||||
.and(path_regex("^/api/codex/apps/?$"))
|
||||
.respond_with(CodexAppsJsonRpcResponder { connector_name })
|
||||
.respond_with(CodexAppsJsonRpcResponder {
|
||||
connector_name,
|
||||
connector_description,
|
||||
})
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
struct CodexAppsJsonRpcResponder {
|
||||
connector_name: String,
|
||||
connector_description: String,
|
||||
}
|
||||
|
||||
impl Respond for CodexAppsJsonRpcResponder {
|
||||
@@ -126,7 +174,8 @@ impl Respond for CodexAppsJsonRpcResponder {
|
||||
},
|
||||
"_meta": {
|
||||
"connector_id": CONNECTOR_ID,
|
||||
"connector_name": self.connector_name.clone()
|
||||
"connector_name": self.connector_name.clone(),
|
||||
"connector_description": self.connector_description.clone()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -142,7 +191,8 @@ impl Respond for CodexAppsJsonRpcResponder {
|
||||
},
|
||||
"_meta": {
|
||||
"connector_id": CONNECTOR_ID,
|
||||
"connector_name": self.connector_name.clone()
|
||||
"connector_name": self.connector_name.clone(),
|
||||
"connector_description": self.connector_description.clone()
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -150,6 +200,33 @@ impl Respond for CodexAppsJsonRpcResponder {
|
||||
}
|
||||
}))
|
||||
}
|
||||
"tools/call" => {
|
||||
let id = body.get("id").cloned().unwrap_or(Value::Null);
|
||||
let tool_name = body
|
||||
.pointer("/params/name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let title = body
|
||||
.pointer("/params/arguments/title")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let starts_at = body
|
||||
.pointer("/params/arguments/starts_at")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
|
||||
ResponseTemplate::new(200).set_body_json(json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": id,
|
||||
"result": {
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("called {tool_name} for {title} at {starts_at}")
|
||||
}],
|
||||
"isError": false
|
||||
}
|
||||
}))
|
||||
}
|
||||
method if method.starts_with("notifications/") => ResponseTemplate::new(202),
|
||||
_ => {
|
||||
let id = body.get("id").cloned().unwrap_or(Value::Null);
|
||||
|
||||
@@ -207,6 +207,10 @@ impl ResponsesRequest {
|
||||
self.call_output(call_id, "custom_tool_call_output")
|
||||
}
|
||||
|
||||
pub fn tool_search_output(&self, call_id: &str) -> Value {
|
||||
self.call_output(call_id, "tool_search_output")
|
||||
}
|
||||
|
||||
pub fn call_output(&self, call_id: &str, call_type: &str) -> Value {
|
||||
self.input()
|
||||
.iter()
|
||||
@@ -775,6 +779,18 @@ pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_tool_search_call(call_id: &str, arguments: &serde_json::Value) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "tool_search_call",
|
||||
"call_id": call_id,
|
||||
"execution": "client",
|
||||
"arguments": arguments,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_custom_tool_call(call_id: &str, name: &str, input: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.output_item.done",
|
||||
@@ -1485,11 +1501,13 @@ pub async fn mount_response_sequence(
|
||||
/// Validate invariants on the request body sent to `/v1/responses`.
|
||||
///
|
||||
/// - No `function_call_output`/`custom_tool_call_output` with missing/empty `call_id`.
|
||||
/// - `tool_search_output` must have a `call_id` unless it is a server-executed legacy item.
|
||||
/// - Every `function_call_output` must match a prior `function_call` or
|
||||
/// `local_shell_call` with the same `call_id` in the same `input`.
|
||||
/// - Every `custom_tool_call_output` must match a prior `custom_tool_call`.
|
||||
/// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call`
|
||||
/// in the `input` must have a matching output entry.
|
||||
/// - Every `tool_search_output` must match a prior `tool_search_call`.
|
||||
/// - Additionally, enforce symmetry: every `function_call`/`custom_tool_call`/
|
||||
/// `tool_search_call` in the `input` must have a matching output entry.
|
||||
fn validate_request_body_invariants(request: &wiremock::Request) {
|
||||
// Skip GET requests (e.g., /models)
|
||||
if request.method != "POST" || !request.url.path().ends_with("/responses") {
|
||||
@@ -1539,7 +1557,24 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn gather_tool_search_output_ids(items: &[Value]) -> HashSet<String> {
|
||||
items
|
||||
.iter()
|
||||
.filter(|item| item.get("type").and_then(Value::as_str) == Some("tool_search_output"))
|
||||
.filter_map(|item| {
|
||||
if let Some(id) = get_call_id(item) {
|
||||
return Some(id.to_string());
|
||||
}
|
||||
if item.get("execution").and_then(Value::as_str) == Some("server") {
|
||||
return None;
|
||||
}
|
||||
panic!("orphan tool_search_output with empty call_id should be dropped");
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
let function_calls = gather_ids(items, "function_call");
|
||||
let tool_search_calls = gather_ids(items, "tool_search_call");
|
||||
let custom_tool_calls = gather_ids(items, "custom_tool_call");
|
||||
let local_shell_calls = gather_ids(items, "local_shell_call");
|
||||
let function_call_outputs = gather_output_ids(
|
||||
@@ -1547,6 +1582,7 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
|
||||
"function_call_output",
|
||||
"orphan function_call_output with empty call_id should be dropped",
|
||||
);
|
||||
let tool_search_outputs = gather_tool_search_output_ids(items);
|
||||
let custom_tool_call_outputs = gather_output_ids(
|
||||
items,
|
||||
"custom_tool_call_output",
|
||||
@@ -1565,6 +1601,12 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
|
||||
"custom_tool_call_output without matching call in input: {cid}",
|
||||
);
|
||||
}
|
||||
for cid in &tool_search_outputs {
|
||||
assert!(
|
||||
tool_search_calls.contains(cid),
|
||||
"tool_search_output without matching call in input: {cid}",
|
||||
);
|
||||
}
|
||||
|
||||
for cid in &function_calls {
|
||||
assert!(
|
||||
@@ -1578,4 +1620,10 @@ fn validate_request_body_invariants(request: &wiremock::Request) {
|
||||
"Custom tool call output is missing for call id: {cid}",
|
||||
);
|
||||
}
|
||||
for cid in &tool_search_calls {
|
||||
assert!(
|
||||
tool_search_outputs.contains(cid),
|
||||
"Tool search output is missing for call id: {cid}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +202,7 @@ impl TestCodexBuilder {
|
||||
config.clone(),
|
||||
path,
|
||||
auth_manager,
|
||||
None,
|
||||
))
|
||||
.await?
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user