Merge openai/codex upstream (58 commits) into hanzo/dev

Accept upstream changes for codex-rs core (codex.rs, session.rs)
and search tool template. Brings in latest plugin system, blob-size
policy, and protocol schema updates.
This commit is contained in:
2026-03-11 18:58:34 -07:00
123 changed files with 5651 additions and 2248 deletions
+8
View File
@@ -0,0 +1,8 @@
# Paths are matched exactly, relative to the repository root.
# Keep this list short and limited to intentional large checked-in assets.
.github/codex-cli-splash.png
MODULE.bazel.lock
codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json
codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json
codex-rs/tui/tests/fixtures/oss-story.jsonl
+29
View File
@@ -0,0 +1,29 @@
name: blob-size-policy
on:
pull_request: {}
jobs:
check:
name: Blob size policy
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Determine PR comparison range
id: range
shell: bash
run: |
set -euo pipefail
echo "base=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT"
echo "head=$(git rev-parse HEAD^2)" >> "$GITHUB_OUTPUT"
- name: Check changed blob sizes
run: |
python3 scripts/check_blob_size.py \
--base "${{ steps.range.outputs.base }}" \
--head "${{ steps.range.outputs.head }}" \
--max-bytes 512000 \
--allowlist .github/blob-size-allowlist.txt
@@ -813,7 +813,7 @@
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
"type": "string"
},
@@ -1474,6 +1474,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -1491,6 +1497,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -1580,6 +1627,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -5286,6 +5286,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -5303,6 +5309,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -5392,6 +5439,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -5423,7 +5423,7 @@
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
"type": "string"
},
@@ -12749,6 +12749,13 @@
],
"type": "string"
},
"PluginAuthPolicy": {
"enum": [
"ON_INSTALL",
"ON_USE"
],
"type": "string"
},
"PluginInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -12766,6 +12773,14 @@
"title": "PluginInstallParams",
"type": "object"
},
"PluginInstallPolicy": {
"enum": [
"NOT_AVAILABLE",
"AVAILABLE",
"INSTALLED_BY_DEFAULT"
],
"type": "string"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -12774,6 +12789,16 @@
"$ref": "#/definitions/v2/AppSummary"
},
"type": "array"
},
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginAuthPolicy"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -12974,12 +12999,32 @@
},
"PluginSummary": {
"properties": {
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginAuthPolicy"
},
{
"type": "null"
}
]
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installPolicy": {
"anyOf": [
{
"$ref": "#/definitions/v2/PluginInstallPolicy"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -13756,6 +13801,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -13773,6 +13824,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -13862,6 +13954,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -7705,7 +7705,7 @@
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
"type": "string"
},
@@ -9097,6 +9097,13 @@
],
"type": "string"
},
"PluginAuthPolicy": {
"enum": [
"ON_INSTALL",
"ON_USE"
],
"type": "string"
},
"PluginInstallParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -9114,6 +9121,14 @@
"title": "PluginInstallParams",
"type": "object"
},
"PluginInstallPolicy": {
"enum": [
"NOT_AVAILABLE",
"AVAILABLE",
"INSTALLED_BY_DEFAULT"
],
"type": "string"
},
"PluginInstallResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -9122,6 +9137,16 @@
"$ref": "#/definitions/AppSummary"
},
"type": "array"
},
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -9322,12 +9347,32 @@
},
"PluginSummary": {
"properties": {
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicy"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -10366,6 +10411,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -10383,6 +10434,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -10472,6 +10564,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -31,7 +31,7 @@
"type": "boolean"
},
"optOutNotificationMethods": {
"description": "Exact notification method names that should be suppressed for this connection (for example `codex/event/session_configured`).",
"description": "Exact notification method names that should be suppressed for this connection (for example `thread/started`).",
"items": {
"type": "string"
},
@@ -28,6 +28,13 @@
"name"
],
"type": "object"
},
"PluginAuthPolicy": {
"enum": [
"ON_INSTALL",
"ON_USE"
],
"type": "string"
}
},
"properties": {
@@ -36,6 +43,16 @@
"$ref": "#/definitions/AppSummary"
},
"type": "array"
},
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -5,6 +5,21 @@
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"PluginAuthPolicy": {
"enum": [
"ON_INSTALL",
"ON_USE"
],
"type": "string"
},
"PluginInstallPolicy": {
"enum": [
"NOT_AVAILABLE",
"AVAILABLE",
"INSTALLED_BY_DEFAULT"
],
"type": "string"
},
"PluginInterface": {
"properties": {
"brandColor": {
@@ -154,12 +169,32 @@
},
"PluginSummary": {
"properties": {
"authPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginAuthPolicy"
},
{
"type": "null"
}
]
},
"enabled": {
"type": "boolean"
},
"id": {
"type": "string"
},
"installPolicy": {
"anyOf": [
{
"$ref": "#/definitions/PluginInstallPolicy"
},
{
"type": "null"
}
]
},
"installed": {
"type": "boolean"
},
@@ -496,6 +496,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -513,6 +519,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -602,6 +649,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -554,6 +554,12 @@
"name": {
"type": "string"
},
"namespace": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"function_call"
@@ -571,6 +577,47 @@
"title": "FunctionCallResponseItem",
"type": "object"
},
{
"properties": {
"arguments": true,
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"id": {
"type": [
"string",
"null"
],
"writeOnly": true
},
"status": {
"type": [
"string",
"null"
]
},
"type": {
"enum": [
"tool_search_call"
],
"title": "ToolSearchCallResponseItemType",
"type": "string"
}
},
"required": [
"arguments",
"execution",
"type"
],
"title": "ToolSearchCallResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
@@ -660,6 +707,41 @@
"title": "CustomToolCallOutputResponseItem",
"type": "object"
},
{
"properties": {
"call_id": {
"type": [
"string",
"null"
]
},
"execution": {
"type": "string"
},
"status": {
"type": "string"
},
"tools": {
"items": true,
"type": "array"
},
"type": {
"enum": [
"tool_search_output"
],
"title": "ToolSearchOutputResponseItemType",
"type": "string"
}
},
"required": [
"execution",
"status",
"tools",
"type"
],
"title": "ToolSearchOutputResponseItem",
"type": "object"
},
{
"properties": {
"action": {
@@ -12,6 +12,6 @@ export type InitializeCapabilities = {
experimentalApi: boolean,
/**
* Exact notification method names that should be suppressed for this
* connection (for example `codex/event/session_configured`).
* connection (for example `thread/started`).
*/
optOutNotificationMethods?: Array<string> | null, };
@@ -15,4 +15,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array<Con
/**
* Set when using the Responses API.
*/
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, arguments: string, call_id: string, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
call_id: string | null, status: LocalShellStatus, action: LocalShellAction, } | { "type": "function_call", name: string, namespace?: string, arguments: string, call_id: string, } | { "type": "tool_search_call", call_id: string | null, status?: string, execution: string, arguments: unknown, } | { "type": "function_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "custom_tool_call", status?: string, call_id: string, name: string, input: string, } | { "type": "custom_tool_call_output", call_id: string, output: FunctionCallOutputPayload, } | { "type": "tool_search_output", call_id: string | null, status: string, execution: string, tools: unknown[], } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "type": "image_generation_call", id: string, status: string, revised_prompt?: string, result: string, } | { "type": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginAuthPolicy = "ON_INSTALL" | "ON_USE";
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginInstallPolicy = "NOT_AVAILABLE" | "AVAILABLE" | "INSTALLED_BY_DEFAULT";
@@ -2,5 +2,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AppSummary } from "./AppSummary";
import type { PluginAuthPolicy } from "./PluginAuthPolicy";
export type PluginInstallResponse = { appsNeedingAuth: Array<AppSummary>, };
export type PluginInstallResponse = { authPolicy: PluginAuthPolicy | null, appsNeedingAuth: Array<AppSummary>, };
@@ -1,7 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { PluginAuthPolicy } from "./PluginAuthPolicy";
import type { PluginInstallPolicy } from "./PluginInstallPolicy";
import type { PluginInterface } from "./PluginInterface";
import type { PluginSource } from "./PluginSource";
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, interface: PluginInterface | null, };
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy | null, authPolicy: PluginAuthPolicy | null, interface: PluginInterface | null, };
@@ -175,7 +175,9 @@ export type { PermissionGrantScope } from "./PermissionGrantScope";
export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams";
export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse";
export type { PlanDeltaNotification } from "./PlanDeltaNotification";
export type { PluginAuthPolicy } from "./PluginAuthPolicy";
export type { PluginInstallParams } from "./PluginInstallParams";
export type { PluginInstallPolicy } from "./PluginInstallPolicy";
export type { PluginInstallResponse } from "./PluginInstallResponse";
export type { PluginInterface } from "./PluginInterface";
export type { PluginListParams } from "./PluginListParams";
@@ -955,7 +955,7 @@ mod tests {
capabilities: Some(v1::InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
"codex/event/session_configured".to_string(),
"thread/started".to_string(),
"item/agentMessage/delta".to_string(),
]),
}),
@@ -975,7 +975,7 @@ mod tests {
"capabilities": {
"experimentalApi": true,
"optOutNotificationMethods": [
"codex/event/session_configured",
"thread/started",
"item/agentMessage/delta"
]
}
@@ -1000,7 +1000,7 @@ mod tests {
"capabilities": {
"experimentalApi": true,
"optOutNotificationMethods": [
"codex/event/session_configured",
"thread/started",
"item/agentMessage/delta"
]
}
@@ -1020,7 +1020,7 @@ mod tests {
capabilities: Some(v1::InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
"codex/event/session_configured".to_string(),
"thread/started".to_string(),
"item/agentMessage/delta".to_string(),
]),
}),
@@ -54,7 +54,7 @@ pub struct InitializeCapabilities {
#[serde(default)]
pub experimental_api: bool,
/// Exact notification method names that should be suppressed for this
/// connection (for example `codex/event/session_configured`).
/// connection (for example `thread/started`).
#[ts(optional = nullable)]
pub opt_out_notification_methods: Option<Vec<String>>,
}
@@ -3053,6 +3053,31 @@ pub struct PluginMarketplaceEntry {
pub plugins: Vec<PluginSummary>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginInstallPolicy {
#[serde(rename = "NOT_AVAILABLE")]
#[ts(rename = "NOT_AVAILABLE")]
NotAvailable,
#[serde(rename = "AVAILABLE")]
#[ts(rename = "AVAILABLE")]
Available,
#[serde(rename = "INSTALLED_BY_DEFAULT")]
#[ts(rename = "INSTALLED_BY_DEFAULT")]
InstalledByDefault,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[ts(export_to = "v2/")]
pub enum PluginAuthPolicy {
#[serde(rename = "ON_INSTALL")]
#[ts(rename = "ON_INSTALL")]
OnInstall,
#[serde(rename = "ON_USE")]
#[ts(rename = "ON_USE")]
OnUse,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3062,6 +3087,8 @@ pub struct PluginSummary {
pub source: PluginSource,
pub installed: bool,
pub enabled: bool,
pub install_policy: Option<PluginInstallPolicy>,
pub auth_policy: Option<PluginAuthPolicy>,
pub interface: Option<PluginInterface>,
}
@@ -3122,6 +3149,7 @@ pub struct PluginInstallParams {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginInstallResponse {
pub auth_policy: Option<PluginAuthPolicy>,
pub apps_needing_auth: Vec<AppSummary>,
}
@@ -88,20 +88,6 @@ use url::Url;
use uuid::Uuid;
const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[
// Legacy codex/event (v1-style) deltas.
"codex/event/agent_message_content_delta",
"codex/event/agent_message_delta",
"codex/event/agent_reasoning_delta",
"codex/event/reasoning_content_delta",
"codex/event/reasoning_raw_content_delta",
"codex/event/exec_command_output_delta",
// Other legacy events.
"codex/event/exec_approval_request",
"codex/event/exec_command_begin",
"codex/event/exec_command_end",
"codex/event/exec_output",
"codex/event/item_started",
"codex/event/item_completed",
// v2 item deltas.
"command/exec/outputDelta",
"item/agentMessage/delta",
+5 -5
View File
@@ -115,7 +115,7 @@ Example with notification opt-out:
"capabilities": {
"experimentalApi": true,
"optOutNotificationMethods": [
"codex/event/session_configured",
"thread/started",
"item/agentMessage/delta"
]
}
@@ -157,13 +157,13 @@ Example with notification opt-out:
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `plugin/list` — list discovered plugin marketplaces and plugin state. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
- `plugin/list` — list discovered plugin marketplaces and plugin state, including marketplace install/auth policy metadata. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
- `skills/changed` — notification emitted when watched local skill files change.
- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**).
- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**).
- `app/list` — list available apps.
- `skills/config/write` — write user-level skill config by path.
- `plugin/install` — install a plugin from a discovered marketplace entry and return any apps that still need auth (**under development; do not call from production clients yet**).
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, and return the plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**).
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
@@ -711,12 +711,12 @@ Clients can suppress specific notifications per connection by sending exact meth
- Exact-match only: `item/agentMessage/delta` suppresses only that method.
- Unknown method names are ignored.
- Applies to both legacy (`codex/event/*`) and v2 (`thread/*`, `turn/*`, `item/*`, etc.) notifications.
- Applies to app-server typed notifications such as `thread/*`, `turn/*`, `item/*`, and `rawResponseItem/*`.
- Does not apply to requests/responses/errors.
Examples:
- Opt out of legacy session setup event: `codex/event/session_configured`
- Opt out of thread lifecycle notifications: `thread/started`
- Opt out of streamed agent text deltas: `item/agentMessage/delta`
### Fuzzy file search events (experimental)
@@ -4685,6 +4685,7 @@ impl CodexMessageProcessor {
}
MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::PluginNotAvailable { .. }
| MarketplaceError::InvalidPlugin(_) => {
self.send_invalid_request_error(request_id, err.to_string())
.await;
@@ -5399,6 +5400,8 @@ impl CodexMessageProcessor {
PluginSource::Local { path }
}
},
install_policy: plugin.install_policy.map(Into::into),
auth_policy: plugin.auth_policy.map(Into::into),
interface: plugin.interface.map(|interface| PluginInterface {
display_name: interface.display_name,
short_description: interface.short_description,
@@ -5648,7 +5651,13 @@ impl CodexMessageProcessor {
self.clear_plugin_related_caches();
self.outgoing
.send_response(request_id, PluginInstallResponse { apps_needing_auth })
.send_response(
request_id,
PluginInstallResponse {
auth_policy: result.auth_policy.map(Into::into),
apps_needing_auth,
},
)
.await;
}
Err(err) => {
@@ -6497,9 +6506,17 @@ impl CodexMessageProcessor {
};
// For now, we send a notification for every event,
// JSON-serializing the `Event` as-is, but these should
// be migrated to be variants of `ServerNotification`
// instead.
// Legacy `codex/event/*` notifications are still
// produced here because the in-process app-server lane
// (`codex exec` and other in-process consumers) still
// depends on them. External transports now drop
// `OutgoingMessage::Notification` in `transport.rs`,
// so stdio/websocket clients only observe the typed
// `ServerNotification` translations emitted below.
//
// TODO: remove this raw legacy-notification emission
// entirely once the remaining in-process consumers are
// migrated off `codex/event/*`.
let event_formatted = match &event.msg {
EventMsg::TurnStarted(_) => "task_started",
EventMsg::TurnComplete(_) => "task_complete",
+1
View File
@@ -384,6 +384,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle {
Arc::clone(&outbound_initialized),
Arc::clone(&outbound_experimental_api_enabled),
Arc::clone(&outbound_opted_out_notification_methods),
true,
None,
),
);
+6
View File
@@ -103,6 +103,8 @@ enum OutboundControlEvent {
Opened {
connection_id: ConnectionId,
writer: mpsc::Sender<crate::outgoing_message::OutgoingMessage>,
// Allow codex/event/* notifications to be emitted.
allow_legacy_notifications: bool,
disconnect_sender: Option<CancellationToken>,
initialized: Arc<AtomicBool>,
experimental_api_enabled: Arc<AtomicBool>,
@@ -541,6 +543,7 @@ pub async fn run_main_with_transport(
OutboundControlEvent::Opened {
connection_id,
writer,
allow_legacy_notifications,
disconnect_sender,
initialized,
experimental_api_enabled,
@@ -553,6 +556,7 @@ pub async fn run_main_with_transport(
initialized,
experimental_api_enabled,
opted_out_notification_methods,
allow_legacy_notifications,
disconnect_sender,
),
);
@@ -650,6 +654,7 @@ pub async fn run_main_with_transport(
TransportEvent::ConnectionOpened {
connection_id,
writer,
allow_legacy_notifications,
disconnect_sender,
} => {
let outbound_initialized = Arc::new(AtomicBool::new(false));
@@ -661,6 +666,7 @@ pub async fn run_main_with_transport(
.send(OutboundControlEvent::Opened {
connection_id,
writer,
allow_legacy_notifications,
disconnect_sender,
initialized: Arc::clone(&outbound_initialized),
experimental_api_enabled: Arc::clone(
+116 -24
View File
@@ -166,6 +166,7 @@ pub(crate) enum TransportEvent {
ConnectionOpened {
connection_id: ConnectionId,
writer: mpsc::Sender<OutgoingMessage>,
allow_legacy_notifications: bool,
disconnect_sender: Option<CancellationToken>,
},
ConnectionClosed {
@@ -203,6 +204,7 @@ pub(crate) struct OutboundConnectionState {
pub(crate) initialized: Arc<AtomicBool>,
pub(crate) experimental_api_enabled: Arc<AtomicBool>,
pub(crate) opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
pub(crate) allow_legacy_notifications: bool,
pub(crate) writer: mpsc::Sender<OutgoingMessage>,
disconnect_sender: Option<CancellationToken>,
}
@@ -213,12 +215,14 @@ impl OutboundConnectionState {
initialized: Arc<AtomicBool>,
experimental_api_enabled: Arc<AtomicBool>,
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
allow_legacy_notifications: bool,
disconnect_sender: Option<CancellationToken>,
) -> Self {
Self {
initialized,
experimental_api_enabled,
opted_out_notification_methods,
allow_legacy_notifications,
writer,
disconnect_sender,
}
@@ -246,6 +250,7 @@ pub(crate) async fn start_stdio_connection(
.send(TransportEvent::ConnectionOpened {
connection_id,
writer: writer_tx,
allow_legacy_notifications: false,
disconnect_sender: None,
})
.await
@@ -348,6 +353,7 @@ async fn run_websocket_connection(
.send(TransportEvent::ConnectionOpened {
connection_id,
writer: writer_tx,
allow_legacy_notifications: false,
disconnect_sender: Some(disconnect_token.clone()),
})
.await
@@ -555,6 +561,16 @@ fn should_skip_notification_for_connection(
connection_state: &OutboundConnectionState,
message: &OutgoingMessage,
) -> bool {
if !connection_state.allow_legacy_notifications
&& matches!(message, OutgoingMessage::Notification(_))
{
// Raw legacy `codex/event/*` notifications are still emitted upstream
// for in-process compatibility, but they are no longer part of the
// external app-server contract. Keep dropping them here until the
// producer path can be deleted entirely.
return true;
}
let Ok(opted_out_notification_methods) = connection_state.opted_out_notification_methods.read()
else {
warn!("failed to read outbound opted-out notifications");
@@ -931,6 +947,7 @@ mod tests {
initialized,
Arc::new(AtomicBool::new(true)),
opted_out_notification_methods,
false,
None,
),
);
@@ -955,6 +972,89 @@ mod tests {
);
}
#[tokio::test]
async fn to_connection_legacy_notifications_are_dropped_for_external_clients() {
let connection_id = ConnectionId(10);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
false,
None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Notification(
crate::outgoing_message::OutgoingNotification {
method: "codex/event/task_started".to_string(),
params: None,
},
),
},
)
.await;
assert!(
writer_rx.try_recv().is_err(),
"legacy notifications should not reach external clients"
);
}
#[tokio::test]
async fn to_connection_legacy_notifications_are_preserved_for_in_process_clients() {
let connection_id = ConnectionId(11);
let (writer_tx, mut writer_rx) = mpsc::channel(1);
let mut connections = HashMap::new();
connections.insert(
connection_id,
OutboundConnectionState::new(
writer_tx,
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
true,
None,
),
);
route_outgoing_envelope(
&mut connections,
OutgoingEnvelope::ToConnection {
connection_id,
message: OutgoingMessage::Notification(
crate::outgoing_message::OutgoingNotification {
method: "codex/event/task_started".to_string(),
params: None,
},
),
},
)
.await;
let message = writer_rx
.recv()
.await
.expect("legacy notification should reach in-process clients");
assert!(matches!(
message,
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method,
params: None,
}) if method == "codex/event/task_started"
));
}
#[tokio::test]
async fn command_execution_request_approval_strips_experimental_fields_without_capability() {
let connection_id = ConnectionId(8);
@@ -968,6 +1068,7 @@ mod tests {
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(false)),
Arc::new(RwLock::new(HashSet::new())),
false,
None,
),
);
@@ -1034,6 +1135,7 @@ mod tests {
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
false,
None,
),
);
@@ -1121,6 +1223,7 @@ mod tests {
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
false,
Some(fast_disconnect_token.clone()),
),
);
@@ -1131,6 +1234,7 @@ mod tests {
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
false,
Some(slow_disconnect_token.clone()),
),
);
@@ -1159,20 +1263,14 @@ mod tests {
),
)
.await
.expect("broadcast should not block on a full writer");
assert!(!connections.contains_key(&slow_connection_id));
assert!(slow_disconnect_token.is_cancelled());
.expect("broadcast should return even when legacy notifications are dropped");
assert!(connections.contains_key(&slow_connection_id));
assert!(!slow_disconnect_token.is_cancelled());
assert!(!fast_disconnect_token.is_cancelled());
let fast_message = fast_writer_rx
.try_recv()
.expect("fast connection should receive broadcast");
assert!(matches!(
fast_message,
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method,
params: None,
}) if method == "codex/event/test"
));
assert!(
fast_writer_rx.try_recv().is_err(),
"broadcast legacy notification should be dropped for fast connections"
);
let slow_message = slow_writer_rx
.try_recv()
@@ -1208,6 +1306,7 @@ mod tests {
Arc::new(AtomicBool::new(true)),
Arc::new(AtomicBool::new(true)),
Arc::new(RwLock::new(HashSet::new())),
false,
None,
),
);
@@ -1232,14 +1331,9 @@ mod tests {
.await
.expect("first queued message should be readable")
.expect("first queued message should exist");
let second = timeout(Duration::from_millis(100), writer_rx.recv())
.await
.expect("second message should eventually be delivered")
.expect("second message should exist");
timeout(Duration::from_millis(100), route_task)
.await
.expect("routing should finish after writer drains")
.expect("routing should finish immediately when legacy notifications are dropped")
.expect("routing task should succeed");
assert!(matches!(
@@ -1250,11 +1344,9 @@ mod tests {
}) if method == "queued"
));
assert!(matches!(
second,
OutgoingMessage::Notification(crate::outgoing_message::OutgoingNotification {
method,
params: None,
}) if method == "second"
writer_rx.try_recv(),
Err(tokio::sync::mpsc::error::TryRecvError::Empty)
| Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)
));
}
}
@@ -594,7 +594,7 @@ impl McpProcess {
/// Deterministically clean up an intentionally in-flight turn.
///
/// Some tests assert behavior while a turn is still running. Returning from those tests
/// without an explicit interrupt + `codex/event/turn_aborted` wait can leave in-flight work
/// without an explicit interrupt + terminal turn notification wait can leave in-flight work
/// racing teardown and intermittently show up as `LEAK` in nextest.
///
/// In rare races, the turn can also fail or complete on its own after we send
@@ -631,18 +631,19 @@ impl McpProcess {
}
match tokio::time::timeout(
read_timeout,
self.read_stream_until_notification_message("codex/event/turn_aborted"),
self.read_stream_until_notification_message("turn/completed"),
)
.await
{
Ok(result) => {
result.with_context(|| "failed while waiting for turn aborted notification")?;
result.with_context(|| "failed while waiting for terminal turn notification")?;
}
Err(err) => {
if self.pending_turn_completed_notification(&thread_id, &turn_id) {
return Ok(());
}
return Err(err).with_context(|| "timed out waiting for turn aborted notification");
return Err(err)
.with_context(|| "timed out waiting for terminal turn notification");
}
}
Ok(())
@@ -139,10 +139,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
},
Some(InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
"thread/started".to_string(),
"codex/event/session_configured".to_string(),
]),
opt_out_notification_methods: Some(vec!["thread/started".to_string()]),
}),
),
)
@@ -66,7 +66,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs
const CONNECTOR_ID: &str = "calendar";
const CONNECTOR_NAME: &str = "Calendar";
const TOOL_NAME: &str = "calendar_confirm_action";
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar_confirm_action";
const QUALIFIED_TOOL_NAME: &str = "mcp__codex_apps__calendar-confirm-action";
const TOOL_CALL_ID: &str = "call-calendar-confirm";
const ELICITATION_MESSAGE: &str = "Allow this request?";
@@ -19,6 +19,7 @@ use axum::routing::get;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginInstallResponse;
use codex_app_server_protocol::RequestId;
@@ -98,6 +99,43 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -
Ok(())
}
#[tokio::test]
async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
let repo_root = TempDir::new()?;
write_plugin_marketplace(
repo_root.path(),
"debug",
"sample-plugin",
"./sample-plugin",
Some("NOT_AVAILABLE"),
None,
)?;
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
let marketplace_path =
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path,
plugin_name: "sample-plugin".to_string(),
})
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("not available for install"));
Ok(())
}
#[tokio::test]
async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
let connectors = vec![
@@ -152,6 +190,8 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
"debug",
"sample-plugin",
"./sample-plugin",
None,
Some("ON_INSTALL"),
)?;
write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?;
let marketplace_path =
@@ -177,6 +217,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
assert_eq!(
response,
PluginInstallResponse {
auth_policy: Some(PluginAuthPolicy::OnInstall),
apps_needing_auth: vec![AppSummary {
id: "alpha".to_string(),
name: "Alpha".to_string(),
@@ -227,6 +268,8 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
"debug",
"sample-plugin",
"./sample-plugin",
None,
Some("ON_USE"),
)?;
write_plugin_source(
repo_root.path(),
@@ -256,6 +299,7 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
assert_eq!(
response,
PluginInstallResponse {
auth_policy: Some(PluginAuthPolicy::OnUse),
apps_needing_auth: vec![AppSummary {
id: "alpha".to_string(),
name: "Alpha".to_string(),
@@ -422,7 +466,15 @@ fn write_plugin_marketplace(
marketplace_name: &str,
plugin_name: &str,
source_path: &str,
install_policy: Option<&str>,
auth_policy: Option<&str>,
) -> std::io::Result<()> {
let install_policy = install_policy
.map(|install_policy| format!(",\n \"installPolicy\": \"{install_policy}\""))
.unwrap_or_default();
let auth_policy = auth_policy
.map(|auth_policy| format!(",\n \"authPolicy\": \"{auth_policy}\""))
.unwrap_or_default();
std::fs::create_dir_all(repo_root.join(".git"))?;
std::fs::create_dir_all(repo_root.join(".agents/plugins"))?;
std::fs::write(
@@ -436,7 +488,7 @@ fn write_plugin_marketplace(
"source": {{
"source": "local",
"path": "{source_path}"
}}
}}{install_policy}{auth_policy}
}}
]
}}"#
@@ -6,6 +6,8 @@ use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::RequestId;
@@ -358,7 +360,10 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
"category": "Design"
}
]
}"#,
@@ -413,6 +418,8 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
assert_eq!(plugin.id, "demo-plugin@codex-curated");
assert_eq!(plugin.installed, false);
assert_eq!(plugin.enabled, false);
assert_eq!(plugin.install_policy, Some(PluginInstallPolicy::Available));
assert_eq!(plugin.auth_policy, Some(PluginAuthPolicy::OnInstall));
let interface = plugin
.interface
.as_ref()
@@ -421,6 +428,7 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
interface.display_name.as_deref(),
Some("Plugin Display Name")
);
assert_eq!(interface.category.as_deref(), Some("Design"));
assert_eq!(
interface.website_url.as_deref(),
Some("https://openai.com/")
@@ -1152,11 +1152,6 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> {
.await??;
// Ensure we do NOT receive a CommandExecutionRequestApproval request before task completes
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
@@ -1462,7 +1457,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> {
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -1651,7 +1646,7 @@ async fn turn_start_file_change_approval_v2() -> Result<()> {
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -1782,7 +1777,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -1840,7 +1835,7 @@ async fn turn_start_file_change_approval_accept_for_session_persists_v2() -> Res
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -1991,7 +1986,7 @@ async fn turn_start_file_change_approval_decline_v2() -> Result<()> {
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -303,7 +303,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> {
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_complete"),
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
@@ -133,7 +133,7 @@ async fn turn_steer_rejects_oversized_text_input() -> Result<()> {
let _task_started: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_started"),
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
@@ -236,7 +236,7 @@ async fn turn_steer_returns_active_turn_id() -> Result<()> {
let _task_started: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_started"),
mcp.read_stream_until_notification_message("turn/started"),
)
.await??;
@@ -21,6 +21,7 @@ pub(crate) fn attach_item_ids(payload_json: &mut Value, original_items: &[Respon
| ResponseItem::Message { id: Some(id), .. }
| ResponseItem::WebSearchCall { id: Some(id), .. }
| ResponseItem::FunctionCall { id: Some(id), .. }
| ResponseItem::ToolSearchCall { id: Some(id), .. }
| ResponseItem::LocalShellCall { id: Some(id), .. }
| ResponseItem::CustomToolCall { id: Some(id), .. } = item
{
+36
View File
@@ -641,6 +641,42 @@ mod tests {
}
}
#[tokio::test]
async fn parses_tool_search_call_items() {
let events = run_sse(vec![
json!({
"type": "response.output_item.done",
"item": {
"type": "tool_search_call",
"call_id": "search-1",
"execution": "client",
"arguments": {
"query": "calendar create",
"limit": 1
}
}
}),
json!({
"type": "response.completed",
"response": { "id": "resp1" }
}),
])
.await;
assert_eq!(events.len(), 2);
assert_matches!(
&events[0],
ResponseEvent::OutputItemDone(ResponseItem::ToolSearchCall {
call_id,
execution,
arguments,
..
}) if call_id.as_deref() == Some("search-1")
&& execution == "client"
&& arguments == &json!({"query": "calendar create", "limit": 1})
);
}
#[tokio::test]
async fn emits_completed_without_stream_end() {
let completed = json!({
+3 -2
View File
@@ -591,10 +591,11 @@
"type": "object"
},
"FileSystemAccessMode": {
"description": "Access mode for a filesystem entry.\n\nWhen two equally specific entries target the same path, we compare these by conflict precedence rather than by capability breadth: `none` beats `write`, and `write` beats `read`.",
"enum": [
"none",
"read",
"write"
"write",
"none"
],
"type": "string"
},
+3
View File
@@ -878,6 +878,7 @@ mod tests {
let parent_spawn_call = ResponseItem::FunctionCall {
id: None,
name: "spawn_agent".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: parent_spawn_call_id.clone(),
};
@@ -960,6 +961,7 @@ mod tests {
let parent_spawn_call = ResponseItem::FunctionCall {
id: None,
name: "spawn_agent".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: parent_spawn_call_id.clone(),
};
@@ -1035,6 +1037,7 @@ mod tests {
let parent_spawn_call = ResponseItem::FunctionCall {
id: None,
name: "spawn_agent".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: parent_spawn_call_id.clone(),
};
+87 -5
View File
@@ -180,17 +180,49 @@ pub(crate) mod spawn_tool_spec {
}
format!(
r#"Optional type name for the new agent. If omitted, `{DEFAULT_ROLE_NAME}` is used.
Available roles:
{}
"#,
"Optional type name for the new agent. If omitted, `{DEFAULT_ROLE_NAME}` is used.\nAvailable roles:\n{}",
formatted_roles.join("\n"),
)
}
fn format_role(name: &str, declaration: &AgentRoleConfig) -> String {
if let Some(description) = &declaration.description {
format!("{name}: {{\n{description}\n}}")
let locked_settings_note = declaration
.config_file
.as_ref()
.and_then(|config_file| {
built_in::config_file_contents(config_file)
.map(str::to_owned)
.or_else(|| std::fs::read_to_string(config_file).ok())
})
.and_then(|contents| toml::from_str::<TomlValue>(&contents).ok())
.map(|role_toml| {
let model = role_toml
.get("model")
.and_then(TomlValue::as_str);
let reasoning_effort = role_toml
.get("model_reasoning_effort")
.and_then(TomlValue::as_str);
match (model, reasoning_effort) {
(Some(model), Some(reasoning_effort)) => format!(
"\n- This role's model is set to `{model}` and its reasoning effort is set to `{reasoning_effort}`. These settings cannot be changed."
),
(Some(model), None) => {
format!(
"\n- This role's model is set to `{model}` and cannot be changed."
)
}
(None, Some(reasoning_effort)) => {
format!(
"\n- This role's reasoning effort is set to `{reasoning_effort}` and cannot be changed."
)
}
(None, None) => String::new(),
}
})
.unwrap_or_default();
format!("{name}: {{\n{description}{locked_settings_note}\n}}")
} else {
format!("{name}: no description")
}
@@ -901,6 +933,56 @@ enabled = false
assert!(user_index < built_in_index);
}
#[test]
fn spawn_tool_spec_marks_role_locked_model_and_reasoning_effort() {
let tempdir = TempDir::new().expect("create temp dir");
let role_path = tempdir.path().join("researcher.toml");
fs::write(
&role_path,
"developer_instructions = \"Research carefully\"\nmodel = \"gpt-5\"\nmodel_reasoning_effort = \"high\"\n",
)
.expect("write role config");
let user_defined_roles = BTreeMap::from([(
"researcher".to_string(),
AgentRoleConfig {
description: Some("Research carefully.".to_string()),
config_file: Some(role_path),
nickname_candidates: None,
},
)]);
let spec = spawn_tool_spec::build(&user_defined_roles);
assert!(spec.contains(
"Research carefully.\n- This role's model is set to `gpt-5` and its reasoning effort is set to `high`. These settings cannot be changed."
));
}
#[test]
fn spawn_tool_spec_marks_role_locked_reasoning_effort_only() {
let tempdir = TempDir::new().expect("create temp dir");
let role_path = tempdir.path().join("reviewer.toml");
fs::write(
&role_path,
"developer_instructions = \"Review carefully\"\nmodel_reasoning_effort = \"medium\"\n",
)
.expect("write role config");
let user_defined_roles = BTreeMap::from([(
"reviewer".to_string(),
AgentRoleConfig {
description: Some("Review carefully.".to_string()),
config_file: Some(role_path),
nickname_candidates: None,
},
)]);
let spec = spawn_tool_spec::build(&user_defined_roles);
assert!(spec.contains(
"Review carefully.\n- This role's reasoning effort is set to `medium` and cannot be changed."
));
}
#[test]
fn built_in_config_file_contents_resolves_explorer_only() {
assert_eq!(
+3
View File
@@ -391,9 +391,11 @@ fn build_arc_monitor_message_item(
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Compaction { .. }
@@ -553,6 +555,7 @@ mod tests {
&[ResponseItem::FunctionCall {
id: None,
name: "old_tool".to_string(),
namespace: None,
arguments: "{\"old\":true}".to_string(),
call_id: "call_old".to_string(),
}],
+81
View File
@@ -169,6 +169,12 @@ pub(crate) mod tools {
pub(crate) enum ToolSpec {
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "tool_search")]
ToolSearch {
execution: String,
description: String,
parameters: JsonSchema,
},
#[serde(rename = "local_shell")]
LocalShell {},
#[serde(rename = "image_generation")]
@@ -198,6 +204,7 @@ pub(crate) mod tools {
pub(crate) fn name(&self) -> &str {
match self {
ToolSpec::Function(tool) => tool.name.as_str(),
ToolSpec::ToolSearch { .. } => "tool_search",
ToolSpec::LocalShell {} => "local_shell",
ToolSpec::ImageGeneration { .. } => "image_generation",
ToolSpec::WebSearch { .. } => "web_search",
@@ -268,10 +275,36 @@ pub(crate) mod tools {
/// `required` and `additional_properties` must be present. All fields in
/// `properties` must be present in `required`.
pub(crate) strict: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) defer_loading: Option<bool>,
pub(crate) parameters: JsonSchema,
#[serde(skip)]
pub(crate) output_schema: Option<Value>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub(crate) enum ToolSearchOutputTool {
#[allow(dead_code)]
#[serde(rename = "function")]
Function(ResponsesApiTool),
#[serde(rename = "namespace")]
Namespace(ResponsesApiNamespace),
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub(crate) struct ResponsesApiNamespace {
pub(crate) name: String,
pub(crate) description: String,
pub(crate) tools: Vec<ResponsesApiNamespaceTool>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
#[serde(tag = "type")]
pub(crate) enum ResponsesApiNamespaceTool {
#[serde(rename = "function")]
Function(ResponsesApiTool),
}
}
pub struct ResponseStream {
@@ -434,6 +467,7 @@ mod tests {
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@@ -462,6 +496,7 @@ mod tests {
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@@ -483,4 +518,50 @@ mod tests {
]
);
}
#[test]
fn tool_search_output_namespace_serializes_with_deferred_child_tools() {
let namespace = tools::ToolSearchOutputTool::Namespace(tools::ResponsesApiNamespace {
name: "mcp__codex_apps__calendar".to_string(),
description: "Plan events".to_string(),
tools: vec![tools::ResponsesApiNamespaceTool::Function(
tools::ResponsesApiTool {
name: "create_event".to_string(),
description: "Create a calendar event.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
},
)],
});
let value = serde_json::to_value(namespace).expect("serialize namespace");
assert_eq!(
value,
serde_json::json!({
"type": "namespace",
"name": "mcp__codex_apps__calendar",
"description": "Plan events",
"tools": [
{
"type": "function",
"name": "create_event",
"description": "Create a calendar event.",
"strict": false,
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {}
}
}
]
})
);
}
}
+89 -111
View File
@@ -32,6 +32,7 @@ use crate::features::maybe_push_unstable_features_warning;
#[cfg(test)]
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::models_manager::manager::ModelsManager;
use crate::models_manager::manager::RefreshStrategy;
use crate::parse_command::parse_command;
use crate::parse_turn_item;
use crate::realtime_conversation::RealtimeConversationManager;
@@ -60,6 +61,7 @@ use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_hooks::HookEvent;
use codex_hooks::HookEventAfterAgent;
use codex_hooks::HookPayload;
use codex_hooks::HookResult;
use codex_hooks::Hooks;
use codex_hooks::HooksConfig;
use codex_network_proxy::NetworkProxy;
@@ -205,8 +207,6 @@ use crate::mcp::maybe_prompt_and_install_mcp_dependencies;
use crate::mcp::with_codex_apps_mcp;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::mcp_connection_manager::filter_codex_apps_mcp_tools_only;
use crate::mcp_connection_manager::filter_mcp_tools_by_name;
use crate::mcp_connection_manager::filter_non_codex_apps_mcp_tools_only;
use crate::memories;
use crate::mentions::build_connector_slug_counts;
@@ -285,7 +285,6 @@ use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::handlers::SEARCH_TOOL_BM25_TOOL_NAME;
use crate::tools::js_repl::JsReplHandle;
use crate::tools::js_repl::resolve_compatible_node;
use crate::tools::network_approval::NetworkApprovalService;
@@ -775,6 +774,9 @@ impl TurnContext {
let features = self.features.clone();
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &models_manager
.list_models(RefreshStrategy::OnlineIfUncached)
.await,
features: &features,
web_search_mode: self.tools_config.web_search_mode,
session_source: self.session_source.clone(),
@@ -1162,6 +1164,7 @@ impl Session {
session_configuration: &SessionConfiguration,
per_turn_config: Config,
model_info: ModelInfo,
models_manager: &ModelsManager,
network: Option<NetworkProxy>,
sub_id: String,
js_repl: Arc<JsReplHandle>,
@@ -1183,6 +1186,7 @@ impl Session {
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
available_models: &models_manager.try_list_models().unwrap_or_default(),
features: &per_turn_config.features,
web_search_mode: Some(per_turn_config.web_search_mode.value()),
session_source: session_source.clone(),
@@ -1873,27 +1877,6 @@ impl Session {
}
}
pub(crate) async fn merge_mcp_tool_selection(&self, tool_names: Vec<String>) -> Vec<String> {
let mut state = self.state.lock().await;
state.merge_mcp_tool_selection(tool_names)
}
pub(crate) async fn set_mcp_tool_selection(&self, tool_names: Vec<String>) {
let mut state = self.state.lock().await;
state.clear_mcp_tool_selection();
state.merge_mcp_tool_selection(tool_names);
}
pub(crate) async fn get_mcp_tool_selection(&self) -> Option<Vec<String>> {
let state = self.state.lock().await;
state.get_mcp_tool_selection()
}
pub(crate) async fn clear_mcp_tool_selection(&self) {
let mut state = self.state.lock().await;
state.clear_mcp_tool_selection();
}
// Merges connector IDs into the session-level explicit connector selection.
pub(crate) async fn merge_connector_selection(
&self,
@@ -1917,7 +1900,6 @@ impl Session {
async fn record_initial_history(&self, conversation_history: InitialHistory) {
let turn_context = self.new_default_turn().await;
self.clear_mcp_tool_selection().await;
let is_subagent = {
let state = self.state.lock().await;
matches!(
@@ -1927,26 +1909,12 @@ impl Session {
};
match conversation_history {
InitialHistory::New => {
// Build and record initial items (user instructions + environment context)
// TODO(ccunningham): Defer initial context insertion until the first real turn
// starts so it reflects the actual first-turn settings (permissions, etc.) and
// we do not emit model-visible "diff" updates before the first user message.
let items = self.build_initial_context(&turn_context).await;
self.record_conversation_items(&turn_context, &items).await;
{
let mut state = self.state.lock().await;
state.set_reference_context_item(Some(turn_context.to_turn_context_item()));
}
// Defer initial context insertion until the first real turn starts so
// turn/start overrides can be merged before we write model-visible context.
self.set_previous_turn_settings(None).await;
// Ensure initial items are visible to immediate readers (e.g., tests, forks).
if !is_subagent {
self.flush_rollout().await;
}
}
InitialHistory::Resumed(resumed_history) => {
let rollout_items = resumed_history.history;
let restored_tool_selection =
Self::extract_mcp_tool_selection_from_rollout(&rollout_items);
let reconstructed_rollout = self
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
@@ -1992,9 +1960,6 @@ impl Session {
let mut state = self.state.lock().await;
state.set_token_info(Some(info));
}
if let Some(selected_tools) = restored_tool_selection {
self.set_mcp_tool_selection(selected_tools).await;
}
// Defer seeding the session's initial context until the first turn starts so
// turn/start overrides can be merged before we write to the rollout.
@@ -2003,9 +1968,6 @@ impl Session {
}
}
InitialHistory::Forked(rollout_items) => {
let restored_tool_selection =
Self::extract_mcp_tool_selection_from_rollout(&rollout_items);
let reconstructed_rollout = self
.reconstruct_history_from_rollout(&turn_context, &rollout_items)
.await;
@@ -2033,9 +1995,6 @@ impl Session {
let mut state = self.state.lock().await;
state.set_token_info(Some(info));
}
if let Some(selected_tools) = restored_tool_selection {
self.set_mcp_tool_selection(selected_tools).await;
}
// If persisting, persist all rollout items as-is (recorder filters)
if !rollout_items.is_empty() {
@@ -2069,54 +2028,6 @@ impl Session {
})
}
fn extract_mcp_tool_selection_from_rollout(
rollout_items: &[RolloutItem],
) -> Option<Vec<String>> {
let mut search_call_ids = HashSet::new();
let mut active_selected_tools: Option<Vec<String>> = None;
for item in rollout_items {
let RolloutItem::ResponseItem(response_item) = item else {
continue;
};
match response_item {
ResponseItem::FunctionCall { name, call_id, .. } => {
if name == SEARCH_TOOL_BM25_TOOL_NAME {
search_call_ids.insert(call_id.clone());
}
}
ResponseItem::FunctionCallOutput { call_id, output } => {
if !search_call_ids.contains(call_id) {
continue;
}
let Some(content) = output.body.to_text() else {
continue;
};
let Ok(payload) = serde_json::from_str::<Value>(&content) else {
continue;
};
let Some(selected_tools) = payload
.get("active_selected_tools")
.and_then(Value::as_array)
else {
continue;
};
let Some(selected_tools) = selected_tools
.iter()
.map(|value| value.as_str().map(str::to_string))
.collect::<Option<Vec<_>>>()
else {
continue;
};
active_selected_tools = Some(selected_tools);
}
_ => {}
}
}
active_selected_tools
}
async fn previous_turn_settings(&self) -> Option<PreviousTurnSettings> {
let state = self.state.lock().await;
state.previous_turn_settings()
@@ -2310,6 +2221,7 @@ impl Session {
&session_configuration,
per_turn_config,
model_info,
&self.services.models_manager,
self.services
.network_proxy
.as_ref()
@@ -2328,8 +2240,19 @@ impl Session {
turn_context
}
pub(crate) async fn maybe_emit_unknown_model_warning_for_turn(&self, _tc: &TurnContext) {
// Model fallback metadata tracking was removed upstream.
pub(crate) async fn maybe_emit_unknown_model_warning_for_turn(&self, tc: &TurnContext) {
if tc.model_info.used_fallback_model_metadata {
self.send_event(
tc,
EventMsg::Warning(WarningEvent {
message: format!(
"Model metadata for `{}` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.",
tc.model_info.slug
),
}),
)
.await;
}
}
pub(crate) async fn new_default_turn(&self) -> Arc<TurnContext> {
@@ -3846,7 +3769,20 @@ impl Session {
.await
}
pub(crate) async fn parse_mcp_tool_name(&self, tool_name: &str) -> Option<(String, String)> {
pub(crate) async fn parse_mcp_tool_name(
&self,
name: &str,
namespace: &Option<String>,
) -> Option<(String, String)> {
let tool_name = if let Some(namespace) = namespace {
if name.starts_with(namespace.as_str()) {
name
} else {
&format!("{namespace}{name}")
}
} else {
name
};
self.services
.mcp_connection_manager
.read()
@@ -5136,6 +5072,11 @@ async fn spawn_review_thread(
let review_web_search_mode = WebSearchMode::Disabled;
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &review_model_info,
available_models: &sess
.services
.models_manager
.list_models(RefreshStrategy::OnlineIfUncached)
.await,
features: &review_features,
web_search_mode: Some(review_web_search_mode),
session_source: parent_turn_context.session_source.clone(),
@@ -5730,7 +5671,8 @@ pub(crate) async fn run_turn(
if stop_outcome.should_stop {
break;
}
sess.hooks()
let hook_outcomes = sess
.hooks()
.dispatch(HookPayload {
session_id: sess.conversation_id,
cwd: turn_context.cwd.clone(),
@@ -5746,6 +5688,47 @@ pub(crate) async fn run_turn(
},
})
.await;
let mut abort_message = None;
for hook_outcome in hook_outcomes {
let hook_name = hook_outcome.hook_name;
match hook_outcome.result {
HookResult::Success => {}
HookResult::FailedContinue(error) => {
warn!(
turn_id = %turn_context.sub_id,
hook_name = %hook_name,
error = %error,
"after_agent hook failed; continuing"
);
}
HookResult::FailedAbort(error) => {
let message = format!(
"after_agent hook '{hook_name}' failed and aborted turn completion: {error}"
);
warn!(
turn_id = %turn_context.sub_id,
hook_name = %hook_name,
error = %error,
"after_agent hook failed; aborting operation"
);
if abort_message.is_none() {
abort_message = Some(message);
}
}
}
}
if let Some(message) = abort_message {
sess.send_event(
&turn_context,
EventMsg::Error(ErrorEvent {
message,
codex_error_info: None,
}),
)
.await;
return None;
}
break;
}
continue;
@@ -6015,7 +5998,7 @@ fn filter_codex_apps_mcp_tools(
.iter()
.filter(|(_, tool)| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return true;
return false;
}
let Some(connector_id) = codex_apps_connector_id(tool) else {
return false;
@@ -6231,18 +6214,13 @@ async fn built_tools(
);
let mut selected_mcp_tools = filter_non_codex_apps_mcp_tools_only(&mcp_tools);
if let Some(selected_tools) = sess.get_mcp_tool_selection().await {
selected_mcp_tools.extend(filter_mcp_tools_by_name(&mcp_tools, &selected_tools));
}
selected_mcp_tools.extend(filter_codex_apps_mcp_tools_only(
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
explicitly_enabled.as_ref(),
&turn_context.config,
));
mcp_tools =
connectors::filter_codex_apps_tools_by_policy(selected_mcp_tools, &turn_context.config);
mcp_tools = selected_mcp_tools;
}
Ok(Arc::new(ToolRouter::from_config(
+58 -126
View File
@@ -259,9 +259,19 @@ fn make_mcp_tool(
connector_id: Option<&str>,
connector_name: Option<&str>,
) -> ToolInfo {
let tool_namespace = if server_name == CODEX_APPS_MCP_SERVER_NAME {
connector_name
.map(crate::connectors::sanitize_name)
.map(|connector_name| format!("mcp__{server_name}__{connector_name}"))
.unwrap_or_else(|| server_name.to_string())
} else {
server_name.to_string()
};
ToolInfo {
server_name: server_name.to_string(),
tool_name: tool_name.to_string(),
tool_namespace,
tool: Tool {
name: tool_name.to_string().into(),
title: None,
@@ -276,25 +286,10 @@ fn make_mcp_tool(
connector_id: connector_id.map(str::to_string),
connector_name: connector_name.map(str::to_string),
plugin_display_names: Vec::new(),
connector_description: None,
}
}
fn function_call_rollout_item(name: &str, call_id: &str) -> RolloutItem {
RolloutItem::ResponseItem(ResponseItem::FunctionCall {
id: None,
name: name.to_string(),
arguments: "{}".to_string(),
call_id: call_id.to_string(),
})
}
fn function_call_output_rollout_item(call_id: &str, output: &str) -> RolloutItem {
RolloutItem::ResponseItem(ResponseItem::FunctionCallOutput {
call_id: call_id.to_string(),
output: FunctionCallOutputPayload::from_text(output.to_string()),
})
}
#[test]
fn validated_network_policy_amendment_host_allows_normalized_match() {
let amendment = NetworkPolicyAmendment {
@@ -547,8 +542,12 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() {
&explicitly_enabled_connectors,
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let config = test_config();
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
&connectors,
&config,
));
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
@@ -557,7 +556,7 @@ fn non_app_mcp_tools_remain_visible_without_search_selection() {
#[test]
fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
let selected_tool_names = vec![
let selected_tool_names = [
"mcp__codex_apps__calendar_create_event".to_string(),
"mcp__rmcp__echo".to_string(),
];
@@ -577,7 +576,11 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
),
]);
let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names);
let mut selected_mcp_tools = mcp_tools
.iter()
.filter(|(name, _)| selected_tool_names.contains(name))
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect::<HashMap<_, _>>();
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
let explicitly_enabled_connectors = HashSet::new();
let connectors = filter_connectors_for_input(
@@ -586,8 +589,12 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
&explicitly_enabled_connectors,
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let config = test_config();
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
&connectors,
&config,
));
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
@@ -602,7 +609,7 @@ fn search_tool_selection_keeps_codex_apps_tools_without_mentions() {
#[test]
fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
let selected_tool_names = vec!["mcp__rmcp__echo".to_string()];
let selected_tool_names = ["mcp__rmcp__echo".to_string()];
let mcp_tools = HashMap::from([
(
"mcp__codex_apps__calendar_create_event".to_string(),
@@ -619,7 +626,11 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
),
]);
let mut selected_mcp_tools = filter_mcp_tools_by_name(&mcp_tools, &selected_tool_names);
let mut selected_mcp_tools = mcp_tools
.iter()
.filter(|(name, _)| selected_tool_names.contains(name))
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect::<HashMap<_, _>>();
let connectors = connectors::accessible_connectors_from_mcp_tools(&mcp_tools);
let explicitly_enabled_connectors = HashSet::new();
let connectors = filter_connectors_for_input(
@@ -628,8 +639,12 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
&explicitly_enabled_connectors,
&HashMap::new(),
);
let apps_mcp_tools = filter_codex_apps_mcp_tools_only(&mcp_tools, &connectors);
selected_mcp_tools.extend(apps_mcp_tools);
let config = test_config();
selected_mcp_tools.extend(filter_codex_apps_mcp_tools(
&mcp_tools,
&connectors,
&config,
));
let mut tool_names: Vec<String> = selected_mcp_tools.into_keys().collect();
tool_names.sort();
@@ -642,106 +657,6 @@ fn apps_mentions_add_codex_apps_tools_to_search_selected_set() {
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_reads_search_tool_output() {
let rollout_items = vec![
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"),
function_call_output_rollout_item(
"search-1",
&json!({
"active_selected_tools": [
"mcp__codex_apps__calendar_create_event",
"mcp__codex_apps__calendar_list_events",
],
})
.to_string(),
),
];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(
selected,
Some(vec![
"mcp__codex_apps__calendar_create_event".to_string(),
"mcp__codex_apps__calendar_list_events".to_string(),
])
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_latest_valid_payload_wins() {
let rollout_items = vec![
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"),
function_call_output_rollout_item(
"search-1",
&json!({
"active_selected_tools": ["mcp__codex_apps__calendar_create_event"],
})
.to_string(),
),
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-2"),
function_call_output_rollout_item(
"search-2",
&json!({
"active_selected_tools": ["mcp__codex_apps__calendar_delete_event"],
})
.to_string(),
),
];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(
selected,
Some(vec!["mcp__codex_apps__calendar_delete_event".to_string(),])
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_ignores_non_search_and_malformed_payloads() {
let rollout_items = vec![
function_call_rollout_item("shell", "shell-1"),
function_call_output_rollout_item(
"shell-1",
&json!({
"active_selected_tools": ["mcp__codex_apps__should_be_ignored"],
})
.to_string(),
),
function_call_rollout_item(SEARCH_TOOL_BM25_TOOL_NAME, "search-1"),
function_call_output_rollout_item("search-1", "{not-json"),
function_call_output_rollout_item(
"unknown-search-call",
&json!({
"active_selected_tools": ["mcp__codex_apps__also_ignored"],
})
.to_string(),
),
function_call_output_rollout_item(
"search-1",
&json!({
"active_selected_tools": ["mcp__codex_apps__calendar_list_events"],
})
.to_string(),
),
];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(
selected,
Some(vec!["mcp__codex_apps__calendar_list_events".to_string(),])
);
}
#[test]
fn extract_mcp_tool_selection_from_rollout_returns_none_without_valid_search_output() {
let rollout_items = vec![function_call_rollout_item(
SEARCH_TOOL_BM25_TOOL_NAME,
"search-1",
)];
let selected = Session::extract_mcp_tool_selection_from_rollout(&rollout_items);
assert_eq!(selected, None);
}
#[tokio::test]
async fn reconstruct_history_matches_live_compactions() {
let (session, turn_context) = make_session_and_context().await;
@@ -808,6 +723,18 @@ async fn record_initial_history_reconstructs_resumed_transcript() {
assert_eq!(expected, history.raw_items());
}
#[tokio::test]
async fn record_initial_history_new_defers_initial_context_until_first_turn() {
let (session, _turn_context) = make_session_and_context().await;
session.record_initial_history(InitialHistory::New).await;
let history = session.clone_history().await;
assert_eq!(history.raw_items().to_vec(), Vec::<ResponseItem>::new());
assert!(session.reference_context_item().await.is_none());
assert_eq!(session.previous_turn_settings().await, None);
}
#[tokio::test]
async fn resumed_history_injects_initial_context_on_first_context_update_only() {
let (session, turn_context) = make_session_and_context().await;
@@ -2250,6 +2177,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
&session_configuration,
per_turn_config,
model_info,
&models_manager,
None,
"turn_id".to_string(),
Arc::clone(&js_repl),
@@ -2810,6 +2738,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
&session_configuration,
per_turn_config,
model_info,
&models_manager,
None,
"turn_id".to_string(),
Arc::clone(&js_repl),
@@ -4224,6 +4153,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
tracker: Arc::clone(&turn_diff_tracker),
call_id,
tool_name: tool_name.to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": params.command.clone(),
@@ -4267,6 +4197,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
tracker: Arc::clone(&turn_diff_tracker),
call_id: "test-call-2".to_string(),
tool_name: tool_name.to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": params2.command.clone(),
@@ -4322,6 +4253,7 @@ async fn unified_exec_rejects_escalated_permissions_when_policy_not_on_request()
tracker: Arc::clone(&tracker),
call_id: "exec-call".to_string(),
tool_name: "exec_command".to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"cmd": "echo hi",
@@ -137,6 +137,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid
tracker: Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())),
call_id: "test-call".to_string(),
tool_name: "shell".to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"command": params.command.clone(),
@@ -204,6 +205,7 @@ async fn guardian_allows_unified_exec_additional_permissions_requests_past_polic
tracker: Arc::clone(&tracker),
call_id: "exec-call".to_string(),
tool_name: "exec_command".to_string(),
tool_namespace: None,
payload: ToolPayload::Function {
arguments: serde_json::json!({
"cmd": "echo hi",
+2
View File
@@ -202,7 +202,9 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::WebSearchCall { .. }
+94 -42
View File
@@ -294,7 +294,7 @@ pub fn connector_display_label(connector: &AppInfo) -> String {
}
pub fn connector_mention_slug(connector: &AppInfo) -> String {
connector_name_slug(&connector_display_label(connector))
sanitize_name(&connector_display_label(connector))
}
pub(crate) fn accessible_connectors_from_mcp_tools(
@@ -307,10 +307,10 @@ pub(crate) fn accessible_connectors_from_mcp_tools(
return None;
}
let connector_id = tool.connector_id.as_deref()?;
let connector_name = normalize_connector_value(tool.connector_name.as_deref());
Some((
connector_id.to_string(),
connector_name,
normalize_connector_value(tool.connector_name.as_deref()),
normalize_connector_value(tool.connector_description.as_deref()),
tool.plugin_display_names.clone(),
))
});
@@ -467,21 +467,13 @@ pub(crate) fn codex_app_tool_is_enabled(
app_tool_policy(
config,
tool_info.connector_id.as_deref(),
&tool_info.tool_name,
&tool_info.tool.name,
tool_info.tool.title.as_deref(),
tool_info.tool.annotations.as_ref(),
)
.enabled
}
pub(crate) fn filter_codex_apps_tools_by_policy(
mut mcp_tools: HashMap<String, crate::mcp_connection_manager::ToolInfo>,
config: &Config,
) -> HashMap<String, crate::mcp_connection_manager::ToolInfo> {
mcp_tools.retain(|_, tool_info| codex_app_tool_is_enabled(config, tool_info));
mcp_tools
}
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
"asdk_app_6938a94a61d881918ef32cb999ff937c",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
@@ -611,23 +603,38 @@ fn app_tool_policy_from_apps_config(
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
where
I: IntoIterator<Item = (String, Option<String>, Vec<String>)>,
I: IntoIterator<Item = (String, Option<String>, Option<String>, Vec<String>)>,
{
let mut connectors: HashMap<String, (String, BTreeSet<String>)> = HashMap::new();
for (connector_id, connector_name, plugin_display_names) in tools {
let mut connectors: HashMap<String, (AppInfo, BTreeSet<String>)> = HashMap::new();
for (connector_id, connector_name, connector_description, plugin_display_names) in tools {
let connector_name = connector_name.unwrap_or_else(|| connector_id.clone());
if let Some((existing_name, existing_plugin_display_names)) =
connectors.get_mut(&connector_id)
{
if existing_name == &connector_id && connector_name != connector_id {
*existing_name = connector_name;
if let Some((existing, existing_plugin_display_names)) = connectors.get_mut(&connector_id) {
if existing.name == connector_id && connector_name != connector_id {
existing.name = connector_name;
}
if existing.description.is_none() && connector_description.is_some() {
existing.description = connector_description;
}
existing_plugin_display_names.extend(plugin_display_names);
} else {
connectors.insert(
connector_id,
connector_id.clone(),
(
connector_name,
AppInfo {
id: connector_id.clone(),
name: connector_name,
description: connector_description,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
},
plugin_display_names
.into_iter()
.collect::<BTreeSet<String>>(),
@@ -636,24 +643,12 @@ where
}
}
let mut accessible: Vec<AppInfo> = connectors
.into_iter()
.map(
|(connector_id, (connector_name, plugin_display_names))| AppInfo {
id: connector_id.clone(),
name: connector_name.clone(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some(connector_install_url(&connector_name, &connector_id)),
is_accessible: true,
is_enabled: true,
plugin_display_names: plugin_display_names.into_iter().collect(),
},
)
.into_values()
.map(|(mut connector, plugin_display_names)| {
connector.plugin_display_names = plugin_display_names.into_iter().collect();
connector.install_url = Some(connector_install_url(&connector.name, &connector.id));
connector
})
.collect();
accessible.sort_by(|left, right| {
right
@@ -696,11 +691,11 @@ fn normalize_connector_value(value: Option<&str>) -> Option<String> {
}
pub fn connector_install_url(name: &str, connector_id: &str) -> String {
let slug = connector_name_slug(name);
let slug = sanitize_name(name);
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
}
pub fn connector_name_slug(name: &str) -> String {
pub fn sanitize_name(name: &str) -> String {
let mut normalized = String::with_capacity(name.len());
for character in name.chars() {
if character.is_ascii_alphanumeric() {
@@ -728,10 +723,12 @@ mod tests {
use crate::config::types::AppToolConfig;
use crate::config::types::AppToolsConfig;
use crate::config::types::AppsDefaultConfig;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use std::collections::HashMap;
use std::sync::Arc;
fn annotations(
@@ -807,12 +804,19 @@ mod tests {
connector_name: Option<&str>,
plugin_display_names: &[&str],
) -> ToolInfo {
let tool_namespace = connector_name
.map(sanitize_name)
.map(|connector_name| format!("mcp__{CODEX_APPS_MCP_SERVER_NAME}__{connector_name}"))
.unwrap_or_else(|| CODEX_APPS_MCP_SERVER_NAME.to_string());
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: tool_name.to_string(),
tool_namespace,
tool: test_tool_definition(tool_name),
connector_id: Some(connector_id.to_string()),
connector_name: connector_name.map(ToOwned::to_owned),
connector_description: None,
plugin_display_names: plugin_names(plugin_display_names),
}
}
@@ -871,9 +875,11 @@ mod tests {
ToolInfo {
server_name: "sample".to_string(),
tool_name: "echo".to_string(),
tool_namespace: "sample".to_string(),
tool: test_tool_definition("echo"),
connector_id: None,
connector_name: None,
connector_description: None,
plugin_display_names: plugin_names(&["ignored"]),
},
),
@@ -930,6 +936,52 @@ mod tests {
);
}
#[test]
fn accessible_connectors_from_mcp_tools_preserves_description() {
let mcp_tools = HashMap::from([(
"mcp__codex_apps__calendar_create_event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "calendar_create_event".to_string(),
tool_namespace: "mcp__codex_apps__calendar".to_string(),
tool: Tool {
name: "calendar_create_event".to_string().into(),
title: None,
description: Some("Create a calendar event".into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
connector_description: Some("Plan events".to_string()),
plugin_display_names: Vec::new(),
},
)]);
assert_eq!(
accessible_connectors_from_mcp_tools(&mcp_tools),
vec![AppInfo {
id: "calendar".to_string(),
name: "Calendar".to_string(),
description: Some("Plan events".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some(connector_install_url("Calendar", "calendar")),
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
}]
);
}
#[test]
fn app_tool_policy_uses_global_defaults_for_destructive_hints() {
let apps_config = AppsConfigToml {
+9 -1
View File
@@ -376,6 +376,8 @@ impl ContextManager {
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::CustomToolCall { .. }
@@ -413,6 +415,8 @@ fn is_api_message(message: &ResponseItem) -> bool {
ResponseItem::Message { role, .. } => role.as_str() != "system",
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::LocalShellCall { .. }
@@ -605,12 +609,14 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
ResponseItem::Message { role, .. } => role == "assistant",
ResponseItem::Reasoning { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::Compaction { .. } => true,
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Other => false,
@@ -620,7 +626,9 @@ fn is_model_generated_item(item: &ResponseItem) -> bool {
pub(crate) fn is_codex_generated_item(item: &ResponseItem) -> bool {
matches!(
item,
ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. }
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
) || matches!(item, ResponseItem::Message { role, .. } if role == "developer")
}
@@ -271,6 +271,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
ResponseItem::FunctionCall {
id: None,
name: "view_image".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@@ -332,6 +333,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
ResponseItem::FunctionCall {
id: None,
name: "view_image".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@@ -547,6 +549,7 @@ fn remove_first_item_removes_matching_output_for_function_call() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
@@ -570,6 +573,7 @@ fn remove_first_item_removes_matching_call_for_output() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-2".to_string(),
},
@@ -586,6 +590,7 @@ fn remove_last_item_removes_matching_call_for_output() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-delete-last".to_string(),
},
@@ -1059,6 +1064,7 @@ fn normalize_adds_missing_output_for_function_call() {
let items = vec![ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
}];
@@ -1072,6 +1078,7 @@ fn normalize_adds_missing_output_for_function_call() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
},
@@ -1193,6 +1200,7 @@ fn normalize_mixed_inserts_and_removals() {
ResponseItem::FunctionCall {
id: None,
name: "f1".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
@@ -1233,6 +1241,7 @@ fn normalize_mixed_inserts_and_removals() {
ResponseItem::FunctionCall {
id: None,
name: "f1".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
@@ -1276,6 +1285,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
let items = vec![ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
}];
@@ -1287,6 +1297,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
ResponseItem::FunctionCall {
id: None,
name: "do_it".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-x".to_string(),
},
@@ -1298,6 +1309,39 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() {
);
}
#[test]
fn normalize_adds_missing_output_for_tool_search_call() {
let items = vec![ResponseItem::ToolSearchCall {
id: None,
call_id: Some("search-call-x".to_string()),
status: Some("completed".to_string()),
execution: "client".to_string(),
arguments: "{}".into(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
assert_eq!(
h.raw_items(),
vec![
ResponseItem::ToolSearchCall {
id: None,
call_id: Some("search-call-x".to_string()),
status: Some("completed".to_string()),
execution: "client".to_string(),
arguments: "{}".into(),
},
ResponseItem::ToolSearchOutput {
call_id: Some("search-call-x".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
]
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
@@ -1357,6 +1401,59 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() {
h.normalize_history(&default_input_modalities());
}
#[cfg(not(debug_assertions))]
#[test]
fn normalize_removes_orphan_client_tool_search_output() {
let items = vec![ResponseItem::ToolSearchOutput {
call_id: Some("orphan-search".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
assert_eq!(h.raw_items(), vec![]);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() {
let items = vec![ResponseItem::ToolSearchOutput {
call_id: Some("orphan-search".to_string()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
}
#[test]
fn normalize_keeps_server_tool_search_output_without_matching_call() {
let items = vec![ResponseItem::ToolSearchOutput {
call_id: Some("server-search".to_string()),
status: "completed".to_string(),
execution: "server".to_string(),
tools: Vec::new(),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
assert_eq!(
h.raw_items(),
vec![ResponseItem::ToolSearchOutput {
call_id: Some("server-search".to_string()),
status: "completed".to_string(),
execution: "server".to_string(),
tools: Vec::new(),
}]
);
}
#[cfg(debug_assertions)]
#[test]
#[should_panic]
@@ -1365,6 +1462,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() {
ResponseItem::FunctionCall {
id: None,
name: "f1".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "c1".to_string(),
},
@@ -38,6 +38,31 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
));
}
}
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
..
} => {
let has_output = items.iter().any(|i| match i {
ResponseItem::ToolSearchOutput {
call_id: Some(existing),
..
} => existing == call_id,
_ => false,
});
if !has_output {
info!("Tool search output is missing for call id: {call_id}");
missing_outputs_to_insert.push((
idx,
ResponseItem::ToolSearchOutput {
call_id: Some(call_id.clone()),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
));
}
}
ResponseItem::CustomToolCall { call_id, .. } => {
let has_output = items.iter().any(|i| match i {
ResponseItem::CustomToolCallOutput {
@@ -102,6 +127,17 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec<ResponseItem>) {
})
.collect();
let tool_search_call_ids: HashSet<String> = items
.iter()
.filter_map(|i| match i {
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
..
} => Some(call_id.clone()),
_ => None,
})
.collect();
let local_shell_call_ids: HashSet<String> = items
.iter()
.filter_map(|i| match i {
@@ -141,6 +177,18 @@ pub(crate) fn remove_orphan_outputs(items: &mut Vec<ResponseItem>) {
}
has_match
}
ResponseItem::ToolSearchOutput { execution, .. } if execution == "server" => true,
ResponseItem::ToolSearchOutput {
call_id: Some(call_id),
..
} => {
let has_match = tool_search_call_ids.contains(call_id);
if !has_match {
error_or_panic(format!("Orphan tool search output for call id: {call_id}"));
}
has_match
}
ResponseItem::ToolSearchOutput { call_id: None, .. } => true,
_ => true,
});
}
@@ -168,6 +216,37 @@ pub(crate) fn remove_corresponding_for(items: &mut Vec<ResponseItem>, item: &Res
items.remove(pos);
}
}
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
..
} => {
remove_first_matching(items, |i| {
matches!(
i,
ResponseItem::ToolSearchOutput {
call_id: Some(existing),
..
} if existing == call_id
)
});
}
ResponseItem::ToolSearchOutput {
call_id: Some(call_id),
..
} => {
remove_first_matching(
items,
|i| {
matches!(
i,
ResponseItem::ToolSearchCall {
call_id: Some(existing),
..
} if existing == call_id
)
},
);
}
ResponseItem::CustomToolCall { call_id, .. } => {
remove_first_matching(items, |i| {
matches!(
+10 -1
View File
@@ -130,7 +130,7 @@ pub enum Feature {
MemoryTool,
/// Append additional AGENTS.md guidance to user instructions.
ChildAgentsMd,
/// Allow `detail: "original"` image outputs on supported models.
/// Allow the model to request `detail: "original"` image outputs on supported models.
ImageDetailOriginal,
/// Enforce UTF8 output in Powershell.
PowershellUtf8,
@@ -1002,6 +1002,15 @@ mod tests {
assert_eq!(Feature::ImageGeneration.default_enabled(), false);
}
#[test]
fn image_detail_original_feature_is_under_development() {
assert_eq!(
Feature::ImageDetailOriginal.stage(),
Stage::UnderDevelopment
);
assert_eq!(Feature::ImageDetailOriginal.default_enabled(), false);
}
#[test]
fn collab_is_legacy_alias_for_multi_agent() {
assert_eq!(feature_for_key("multi_agent"), Some(Feature::Collab));
+2
View File
@@ -105,6 +105,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() {
ResponseItem::FunctionCall {
id: None,
name: "read_file".to_string(),
namespace: None,
arguments: "{\"path\":\"README.md\"}".to_string(),
call_id: "call-1".to_string(),
},
@@ -319,6 +320,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
ResponseItem::FunctionCall {
id: None,
name: "gh_repo_view".to_string(),
namespace: None,
arguments: "{\"repo\":\"openai/codex\"}".to_string(),
call_id: "call-1".to_string(),
},
+1
View File
@@ -50,6 +50,7 @@ mod mcp_connection_manager;
pub mod models_manager;
mod network_policy_decision;
pub mod network_proxy_loader;
mod original_image_detail;
pub use mcp_connection_manager::MCP_SANDBOX_STATE_CAPABILITY;
pub use mcp_connection_manager::MCP_SANDBOX_STATE_METHOD;
pub use mcp_connection_manager::SandboxState;
+83 -44
View File
@@ -82,6 +82,8 @@ use crate::codex::INITIAL_SUBMIT_ID;
use crate::config::types::McpServerConfig;
use crate::config::types::McpServerTransportConfig;
use crate::connectors::is_connector_id_allowed;
use crate::connectors::sanitize_name;
/// Delimiter used to separate the server name from the tool name in a fully
/// qualified tool name.
///
@@ -158,10 +160,14 @@ where
let mut seen_raw_names = HashSet::new();
let mut qualified_tools = HashMap::new();
for tool in tools {
let qualified_name_raw = format!(
"mcp{}{}{}{}",
MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name
);
let qualified_name_raw = if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
format!(
"mcp{}{}{}{}",
MCP_TOOL_NAME_DELIMITER, tool.server_name, MCP_TOOL_NAME_DELIMITER, tool.tool_name
)
} else {
format!("{}{}", tool.tool_namespace, tool.tool_name)
};
if !seen_raw_names.insert(qualified_name_raw.clone()) {
warn!("skipping duplicated tool {}", qualified_name_raw);
continue;
@@ -196,11 +202,13 @@ where
pub(crate) struct ToolInfo {
pub(crate) server_name: String,
pub(crate) tool_name: String,
pub(crate) tool_namespace: String,
pub(crate) tool: Tool,
pub(crate) connector_id: Option<String>,
pub(crate) connector_name: Option<String>,
#[serde(default)]
pub(crate) plugin_display_names: Vec<String>,
pub(crate) connector_description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -1086,7 +1094,7 @@ impl McpConnectionManager {
self.list_all_tools()
.await
.get(tool_name)
.map(|tool| (tool.server_name.clone(), tool.tool_name.clone()))
.map(|tool| (tool.server_name.clone(), tool.tool.name.to_string()))
}
pub async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> {
@@ -1168,31 +1176,7 @@ impl ToolFilter {
fn filter_tools(tools: Vec<ToolInfo>, filter: &ToolFilter) -> Vec<ToolInfo> {
tools
.into_iter()
.filter(|tool| filter.allows(&tool.tool_name))
.collect()
}
pub(crate) fn filter_codex_apps_mcp_tools_only(
mcp_tools: &HashMap<String, ToolInfo>,
connectors: &[crate::connectors::AppInfo],
) -> HashMap<String, ToolInfo> {
let allowed: HashSet<&str> = connectors
.iter()
.map(|connector| connector.id.as_str())
.collect();
mcp_tools
.iter()
.filter(|(_, tool)| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return false;
}
let Some(connector_id) = tool.connector_id.as_deref() else {
return false;
};
allowed.contains(connector_id)
})
.map(|(name, tool)| (name.clone(), tool.clone()))
.filter(|tool| filter.allows(&tool.tool.name))
.collect()
}
@@ -1206,19 +1190,6 @@ pub(crate) fn filter_non_codex_apps_mcp_tools_only(
.collect()
}
pub(crate) fn filter_mcp_tools_by_name(
mcp_tools: &HashMap<String, ToolInfo>,
selected_tools: &[String],
) -> HashMap<String, ToolInfo> {
let allowed: HashSet<&str> = selected_tools.iter().map(String::as_str).collect();
mcp_tools
.iter()
.filter(|(name, _)| allowed.contains(name.as_str()))
.map(|(name, tool)| (name.clone(), tool.clone()))
.collect()
}
fn normalize_codex_apps_tool_title(
server_name: &str,
connector_name: Option<&str>,
@@ -1245,6 +1216,57 @@ fn normalize_codex_apps_tool_title(
value.to_string()
}
fn normalize_codex_apps_tool_name(
server_name: &str,
tool_name: &str,
connector_id: Option<&str>,
connector_name: Option<&str>,
) -> String {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
return tool_name.to_string();
}
let tool_name = sanitize_name(tool_name);
if let Some(connector_name) = connector_name
.map(str::trim)
.map(sanitize_name)
.filter(|name| !name.is_empty())
&& let Some(stripped) = tool_name.strip_prefix(&connector_name)
&& !stripped.is_empty()
{
return stripped.to_string();
}
if let Some(connector_id) = connector_id
.map(str::trim)
.map(sanitize_name)
.filter(|name| !name.is_empty())
&& let Some(stripped) = tool_name.strip_prefix(&connector_id)
&& !stripped.is_empty()
{
return stripped.to_string();
}
tool_name
}
fn normalize_codex_apps_namespace(server_name: &str, connector_name: Option<&str>) -> String {
if server_name != CODEX_APPS_MCP_SERVER_NAME {
server_name.to_string()
} else if let Some(connector_name) = connector_name {
format!(
"mcp{}{}{}{}",
MCP_TOOL_NAME_DELIMITER,
server_name,
MCP_TOOL_NAME_DELIMITER,
sanitize_name(connector_name)
)
} else {
server_name.to_string()
}
}
fn resolve_bearer_token(
server_name: &str,
bearer_token_env_var: Option<&str>,
@@ -1563,7 +1585,16 @@ async fn list_tools_for_client_uncached(
.tools
.into_iter()
.map(|tool| {
let tool_name = normalize_codex_apps_tool_name(
server_name,
&tool.tool.name,
tool.connector_id.as_deref(),
tool.connector_name.as_deref(),
);
let tool_namespace =
normalize_codex_apps_namespace(server_name, tool.connector_name.as_deref());
let connector_name = tool.connector_name;
let connector_description = tool.connector_description;
let mut tool_def = tool.tool;
if let Some(title) = tool_def.title.as_deref() {
let normalized_title =
@@ -1574,11 +1605,13 @@ async fn list_tools_for_client_uncached(
}
ToolInfo {
server_name: server_name.to_owned(),
tool_name: tool_def.name.to_string(),
tool_name,
tool_namespace,
tool: tool_def,
connector_id: tool.connector_id,
connector_name,
plugin_display_names: Vec::new(),
connector_description,
}
})
.collect();
@@ -1679,6 +1712,11 @@ mod tests {
ToolInfo {
server_name: server_name.to_string(),
tool_name: tool_name.to_string(),
tool_namespace: if server_name == CODEX_APPS_MCP_SERVER_NAME {
format!("mcp__{server_name}__")
} else {
server_name.to_string()
},
tool: Tool {
name: tool_name.to_string().into(),
title: None,
@@ -1693,6 +1731,7 @@ mod tests {
connector_id: None,
connector_name: None,
plugin_display_names: Vec::new(),
connector_description: None,
}
}
+2 -2
View File
@@ -674,7 +674,7 @@ async fn lookup_mcp_tool_metadata(
let tool_info = tools
.into_values()
.find(|tool_info| tool_info.server_name == server && tool_info.tool_name == tool_name)?;
.find(|tool_info| tool_info.server_name == server && tool_info.tool.name == tool_name)?;
let connector_description = if server == CODEX_APPS_MCP_SERVER_NAME {
let connectors = match connectors::list_cached_accessible_connectors_from_mcp_tools(
turn_context.config.as_ref(),
@@ -723,7 +723,7 @@ async fn lookup_mcp_app_usage_metadata(
.await;
tools.into_values().find_map(|tool_info| {
if tool_info.server_name == server && tool_info.tool_name == tool_name {
if tool_info.server_name == server && tool_info.tool.name == tool_name {
Some(McpAppUsageMetadata {
connector_id: tool_info.connector_id,
app_name: tool_info.connector_name,
@@ -0,0 +1,91 @@
use crate::features::Feature;
use crate::features::Features;
use codex_protocol::models::ImageDetail;
use codex_protocol::openai_models::ModelInfo;
pub(crate) fn can_request_original_image_detail(
features: &Features,
model_info: &ModelInfo,
) -> bool {
model_info.supports_image_detail_original && features.enabled(Feature::ImageDetailOriginal)
}
pub(crate) fn normalize_output_image_detail(
features: &Features,
model_info: &ModelInfo,
detail: Option<ImageDetail>,
) -> Option<ImageDetail> {
match detail {
Some(ImageDetail::Original) if can_request_original_image_detail(features, model_info) => {
Some(ImageDetail::Original)
}
Some(ImageDetail::Original) | Some(_) | None => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::test_config;
use crate::features::Features;
use crate::models_manager::manager::ModelsManager;
use pretty_assertions::assert_eq;
#[test]
fn image_detail_original_feature_enables_explicit_original_without_force() {
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);
assert!(can_request_original_image_detail(&features, &model_info));
assert_eq!(
normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Original)),
Some(ImageDetail::Original)
);
assert_eq!(
normalize_output_image_detail(&features, &model_info, None),
None
);
}
#[test]
fn explicit_original_is_dropped_without_feature_or_model_support() {
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();
assert_eq!(
normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Original)),
None
);
let mut features = Features::with_defaults();
features.enable(Feature::ImageDetailOriginal);
model_info.supports_image_detail_original = false;
assert_eq!(
normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Original)),
None
);
}
#[test]
fn unsupported_non_original_detail_is_dropped() {
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);
assert_eq!(
normalize_output_image_detail(&features, &model_info, Some(ImageDetail::Low)),
None
);
}
}
+41 -6
View File
@@ -3,6 +3,8 @@ use super::curated_plugins_repo_path;
use super::load_plugin_manifest;
use super::manifest::PluginManifestInterfaceSummary;
use super::marketplace::MarketplaceError;
use super::marketplace::MarketplacePluginAuthPolicy;
use super::marketplace::MarketplacePluginInstallPolicy;
use super::marketplace::MarketplacePluginSourceSummary;
use super::marketplace::list_marketplaces;
use super::marketplace::load_marketplace_summary;
@@ -12,7 +14,7 @@ use super::plugin_manifest_paths;
use super::store::DEFAULT_PLUGIN_VERSION;
use super::store::PluginId;
use super::store::PluginIdError;
use super::store::PluginInstallResult;
use super::store::PluginInstallResult as StorePluginInstallResult;
use super::store::PluginStore;
use super::store::PluginStoreError;
use super::sync_openai_plugins_repo;
@@ -68,6 +70,14 @@ pub struct PluginInstallRequest {
pub marketplace_path: AbsolutePathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallOutcome {
pub plugin_id: PluginId,
pub plugin_version: String,
pub installed_path: AbsolutePathBuf,
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredMarketplaceSummary {
pub name: String,
@@ -80,6 +90,8 @@ pub struct ConfiguredMarketplacePluginSummary {
pub id: String,
pub name: String,
pub source: MarketplacePluginSourceSummary,
pub install_policy: Option<MarketplacePluginInstallPolicy>,
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
pub interface: Option<PluginManifestInterfaceSummary>,
pub installed: bool,
pub enabled: bool,
@@ -380,10 +392,11 @@ impl PluginsManager {
pub async fn install_plugin(
&self,
request: PluginInstallRequest,
) -> Result<PluginInstallResult, PluginInstallError> {
) -> Result<PluginInstallOutcome, PluginInstallError> {
let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
let auth_policy = resolved.auth_policy;
let store = self.store.clone();
let result = tokio::task::spawn_blocking(move || {
let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || {
store.install(resolved.source_path, resolved.plugin_id)
})
.await
@@ -403,7 +416,12 @@ impl PluginsManager {
.map(|_| ())
.map_err(PluginInstallError::from)?;
Ok(result)
Ok(PluginInstallOutcome {
plugin_id: result.plugin_id,
plugin_version: result.plugin_version,
installed_path: result.installed_path,
auth_policy,
})
}
pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> {
@@ -634,6 +652,8 @@ impl PluginsManager {
.unwrap_or(false),
name: plugin.name,
source: plugin.source,
install_policy: plugin.install_policy,
auth_policy: plugin.auth_policy,
interface: plugin.interface,
})
})
@@ -760,6 +780,7 @@ impl PluginInstallError {
MarketplaceError::MarketplaceNotFound { .. }
| MarketplaceError::InvalidMarketplaceFile { .. }
| MarketplaceError::PluginNotFound { .. }
| MarketplaceError::PluginNotAvailable { .. }
| MarketplaceError::InvalidPlugin(_)
) | Self::Store(PluginStoreError::Invalid(_))
)
@@ -1925,7 +1946,8 @@ mod tests {
"source": {
"source": "local",
"path": "./sample-plugin"
}
},
"authPolicy": "ON_USE"
}
]
}"#,
@@ -1946,10 +1968,11 @@ mod tests {
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
assert_eq!(
result,
PluginInstallResult {
PluginInstallOutcome {
plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(),
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(),
auth_policy: Some(MarketplacePluginAuthPolicy::OnUse),
}
);
@@ -2079,6 +2102,8 @@ enabled = false
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: true,
enabled: true,
@@ -2092,6 +2117,8 @@ enabled = false
)
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: true,
enabled: false,
@@ -2157,6 +2184,8 @@ enabled = false
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: false,
enabled: false,
@@ -2255,6 +2284,8 @@ enabled = false
source: MarketplacePluginSourceSummary::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: false,
enabled: true,
@@ -2279,6 +2310,8 @@ enabled = false
source: MarketplacePluginSourceSummary::Local {
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: false,
enabled: false,
@@ -2356,6 +2389,8 @@ enabled = true
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
installed: false,
enabled: true,
+1 -1
View File
@@ -32,7 +32,7 @@ pub struct PluginManifestPaths {
pub apps: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginManifestInterfaceSummary {
pub display_name: Option<String>,
pub short_description: Option<String>,
+123 -7
View File
@@ -4,6 +4,8 @@ use super::plugin_manifest_interface;
use super::store::PluginId;
use super::store::PluginIdError;
use crate::git_info::get_git_repo_root;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;
use serde::Deserialize;
@@ -19,6 +21,7 @@ const MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
pub struct ResolvedMarketplacePlugin {
pub plugin_id: PluginId,
pub source_path: AbsolutePathBuf,
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -32,6 +35,8 @@ pub struct MarketplaceSummary {
pub struct MarketplacePluginSummary {
pub name: String,
pub source: MarketplacePluginSourceSummary,
pub install_policy: Option<MarketplacePluginInstallPolicy>,
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
pub interface: Option<PluginManifestInterfaceSummary>,
}
@@ -40,6 +45,43 @@ pub enum MarketplacePluginSourceSummary {
Local { path: AbsolutePathBuf },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum MarketplacePluginInstallPolicy {
#[serde(rename = "NOT_AVAILABLE")]
NotAvailable,
#[serde(rename = "AVAILABLE")]
Available,
#[serde(rename = "INSTALLED_BY_DEFAULT")]
InstalledByDefault,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum MarketplacePluginAuthPolicy {
#[serde(rename = "ON_INSTALL")]
OnInstall,
#[serde(rename = "ON_USE")]
OnUse,
}
impl From<MarketplacePluginInstallPolicy> for PluginInstallPolicy {
fn from(value: MarketplacePluginInstallPolicy) -> Self {
match value {
MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable,
MarketplacePluginInstallPolicy::Available => Self::Available,
MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault,
}
}
}
impl From<MarketplacePluginAuthPolicy> for PluginAuthPolicy {
fn from(value: MarketplacePluginAuthPolicy) -> Self {
match value {
MarketplacePluginAuthPolicy::OnInstall => Self::OnInstall,
MarketplacePluginAuthPolicy::OnUse => Self::OnUse,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum MarketplaceError {
#[error("{context}: {source}")]
@@ -61,6 +103,14 @@ pub enum MarketplaceError {
marketplace_name: String,
},
#[error(
"plugin `{plugin_name}` is not available for install in marketplace `{marketplace_name}`"
)]
PluginNotAvailable {
plugin_name: String,
marketplace_name: String,
},
#[error("{0}")]
InvalidPlugin(String),
}
@@ -91,12 +141,27 @@ pub fn resolve_marketplace_plugin(
});
};
let plugin_id = PluginId::new(plugin.name, marketplace_name).map_err(|err| match err {
let MarketplacePlugin {
name,
source,
install_policy,
auth_policy,
..
} = plugin;
if install_policy == Some(MarketplacePluginInstallPolicy::NotAvailable) {
return Err(MarketplaceError::PluginNotAvailable {
plugin_name: name,
marketplace_name,
});
}
let plugin_id = PluginId::new(name, marketplace_name).map_err(|err| match err {
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
})?;
Ok(ResolvedMarketplacePlugin {
plugin_id,
source_path: resolve_plugin_source_path(marketplace_path, plugin.source)?,
source_path: resolve_plugin_source_path(marketplace_path, source)?,
auth_policy,
})
}
@@ -113,16 +178,31 @@ pub(crate) fn load_marketplace_summary(
let mut plugins = Vec::new();
for plugin in marketplace.plugins {
let source_path = resolve_plugin_source_path(path, plugin.source)?;
let MarketplacePlugin {
name,
source,
install_policy,
auth_policy,
category,
} = plugin;
let source_path = resolve_plugin_source_path(path, source)?;
let source = MarketplacePluginSourceSummary::Local {
path: source_path.clone(),
};
let interface = load_plugin_manifest(source_path.as_path())
let mut interface = load_plugin_manifest(source_path.as_path())
.and_then(|manifest| plugin_manifest_interface(&manifest, source_path.as_path()));
if let Some(category) = category {
// Marketplace taxonomy wins when both sources provide a category.
interface
.get_or_insert_with(PluginManifestInterfaceSummary::default)
.category = Some(category);
}
plugins.push(MarketplacePluginSummary {
name: plugin.name,
name,
source,
install_policy,
auth_policy,
interface,
});
}
@@ -280,9 +360,16 @@ struct MarketplaceFile {
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct MarketplacePlugin {
name: String,
source: MarketplacePluginSource,
#[serde(default)]
install_policy: Option<MarketplacePluginInstallPolicy>,
#[serde(default)]
auth_policy: Option<MarketplacePluginAuthPolicy>,
#[serde(default)]
category: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -333,6 +420,7 @@ mod tests {
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
.unwrap(),
source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
auth_policy: None,
}
);
}
@@ -439,6 +527,8 @@ mod tests {
path: AbsolutePathBuf::try_from(home_root.join("home-shared"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
},
MarketplacePluginSummary {
@@ -447,6 +537,8 @@ mod tests {
path: AbsolutePathBuf::try_from(home_root.join("home-only"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
},
],
@@ -464,6 +556,8 @@ mod tests {
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
},
MarketplacePluginSummary {
@@ -472,6 +566,8 @@ mod tests {
path: AbsolutePathBuf::try_from(repo_root.join("repo-only"))
.unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
},
],
@@ -542,6 +638,8 @@ mod tests {
source: MarketplacePluginSourceSummary::Local {
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
}],
},
@@ -553,6 +651,8 @@ mod tests {
source: MarketplacePluginSourceSummary::Local {
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
}],
},
@@ -617,6 +717,8 @@ mod tests {
source: MarketplacePluginSourceSummary::Local {
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
},
install_policy: None,
auth_policy: None,
interface: None,
}],
}]
@@ -641,7 +743,10 @@ mod tests {
"source": {
"source": "local",
"path": "./plugins/demo-plugin"
}
},
"installPolicy": "AVAILABLE",
"authPolicy": "ON_INSTALL",
"category": "Design"
}
]
}"#,
@@ -653,6 +758,7 @@ mod tests {
"name": "demo-plugin",
"interface": {
"displayName": "Demo",
"category": "Productivity",
"capabilities": ["Interactive", "Write"],
"composerIcon": "./assets/icon.png",
"logo": "./assets/logo.png",
@@ -666,6 +772,14 @@ mod tests {
list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None)
.unwrap();
assert_eq!(
marketplaces[0].plugins[0].install_policy,
Some(MarketplacePluginInstallPolicy::Available)
);
assert_eq!(
marketplaces[0].plugins[0].auth_policy,
Some(MarketplacePluginAuthPolicy::OnInstall)
);
assert_eq!(
marketplaces[0].plugins[0].interface,
Some(PluginManifestInterfaceSummary {
@@ -673,7 +787,7 @@ mod tests {
short_description: None,
long_description: None,
developer_name: None,
category: None,
category: Some("Design".to_string()),
capabilities: vec!["Interactive".to_string(), "Write".to_string()],
website_url: None,
privacy_policy_url: None,
@@ -754,6 +868,8 @@ mod tests {
screenshots: Vec::new(),
})
);
assert_eq!(marketplaces[0].plugins[0].install_policy, None);
assert_eq!(marketplaces[0].plugins[0].auth_policy, None);
}
#[test]
+3 -1
View File
@@ -15,6 +15,7 @@ pub use manager::ConfiguredMarketplaceSummary;
pub use manager::LoadedPlugin;
pub use manager::PluginCapabilitySummary;
pub use manager::PluginInstallError;
pub use manager::PluginInstallOutcome;
pub use manager::PluginInstallRequest;
pub use manager::PluginLoadOutcome;
pub use manager::PluginRemoteSyncError;
@@ -30,8 +31,9 @@ pub(crate) use manifest::plugin_manifest_interface;
pub(crate) use manifest::plugin_manifest_name;
pub(crate) use manifest::plugin_manifest_paths;
pub use marketplace::MarketplaceError;
pub use marketplace::MarketplacePluginAuthPolicy;
pub use marketplace::MarketplacePluginInstallPolicy;
pub use marketplace::MarketplacePluginSourceSummary;
pub(crate) use render::render_explicit_plugin_instructions;
pub(crate) use render::render_plugins_section;
pub use store::PluginId;
pub use store::PluginInstallResult;
+7 -6
View File
@@ -56,12 +56,13 @@ fn render_js_repl_instructions(config: &Config) -> Option<String> {
);
section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n");
section.push_str(
"- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n",
"- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n",
);
section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n");
section.push_str("- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n");
section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n");
section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n");
section.push_str("- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n");
section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n");
section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n");
section.push_str("- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n");
section.push_str("- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n");
section.push_str("- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n");
@@ -498,7 +499,7 @@ mod tests {
let res = get_user_instructions(&cfg, None, None)
.await
.expect("js_repl instructions expected");
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
assert_eq!(res, expected);
}
@@ -517,7 +518,7 @@ mod tests {
let res = get_user_instructions(&cfg, None, None)
.await
.expect("js_repl instructions expected");
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
assert_eq!(res, expected);
}
@@ -536,7 +537,7 @@ mod tests {
let res = get_user_instructions(&cfg, None, None)
.await
.expect("js_repl instructions expected");
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
assert_eq!(res, expected);
}
+4
View File
@@ -31,7 +31,9 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool {
| ResponseItem::Reasoning { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::WebSearchCall { .. }
@@ -49,7 +51,9 @@ pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) ->
ResponseItem::Message { role, .. } => role != "developer",
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::WebSearchCall { .. } => true,
+3 -2
View File
@@ -120,9 +120,10 @@ mod tests {
},
ResponseItem::FunctionCall {
id: None,
name: "tool".to_string(),
arguments: "{}".to_string(),
call_id: "c1".to_string(),
name: "tool".to_string(),
namespace: None,
arguments: "{}".to_string(),
},
assistant_msg("a4"),
];
+10 -108
View File
@@ -13,6 +13,7 @@ use crate::error::Result as CodexResult;
use crate::protocol::RateLimitSnapshot;
use crate::protocol::TokenUsage;
use crate::protocol::TokenUsageInfo;
use crate::sandboxing::merge_permission_profiles;
use crate::tasks::RegularTask;
use crate::truncate::TruncationPolicy;
use codex_protocol::protocol::TurnContextItem;
@@ -31,7 +32,6 @@ pub(crate) struct SessionState {
previous_turn_settings: Option<PreviousTurnSettings>,
/// Startup regular task pre-created during session initialization.
pub(crate) startup_regular_task: Option<JoinHandle<CodexResult<RegularTask>>>,
pub(crate) active_mcp_tool_selection: Option<Vec<String>>,
pub(crate) active_connector_selection: HashSet<String>,
pub(crate) pending_session_start_source: Option<codex_hooks::SessionStartSource>,
granted_permissions: Option<PermissionProfile>,
@@ -50,7 +50,6 @@ impl SessionState {
mcp_dependency_prompted: HashSet::new(),
previous_turn_settings: None,
startup_regular_task: None,
active_mcp_tool_selection: None,
active_connector_selection: HashSet::new(),
pending_session_start_source: None,
granted_permissions: None,
@@ -176,43 +175,6 @@ impl SessionState {
self.startup_regular_task.take()
}
pub(crate) fn merge_mcp_tool_selection(&mut self, tool_names: Vec<String>) -> Vec<String> {
if tool_names.is_empty() {
return self.active_mcp_tool_selection.clone().unwrap_or_default();
}
let mut merged = self.active_mcp_tool_selection.take().unwrap_or_default();
let mut seen: HashSet<String> = merged.iter().cloned().collect();
for tool_name in tool_names {
if seen.insert(tool_name.clone()) {
merged.push(tool_name);
}
}
self.active_mcp_tool_selection = Some(merged.clone());
merged
}
pub(crate) fn get_mcp_tool_selection(&self) -> Option<Vec<String>> {
self.active_mcp_tool_selection.clone()
}
pub(crate) fn clear_mcp_tool_selection(&mut self) {
self.active_mcp_tool_selection = None;
}
pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) {
self.granted_permissions = crate::sandboxing::merge_permission_profiles(
self.granted_permissions.as_ref(),
Some(&permissions),
);
}
pub(crate) fn granted_permissions(&self) -> Option<PermissionProfile> {
self.granted_permissions.clone()
}
// Adds connector IDs to the active set and returns the merged selection.
pub(crate) fn merge_connector_selection<I>(&mut self, connector_ids: I) -> HashSet<String>
where
@@ -244,6 +206,15 @@ impl SessionState {
) -> Option<codex_hooks::SessionStartSource> {
self.pending_session_start_source.take()
}
pub(crate) fn record_granted_permissions(&mut self, permissions: PermissionProfile) {
self.granted_permissions =
merge_permission_profiles(self.granted_permissions.as_ref(), Some(&permissions));
}
pub(crate) fn granted_permissions(&self) -> Option<PermissionProfile> {
self.granted_permissions.clone()
}
}
// Sometimes new snapshots don't include credits or plan information.
@@ -272,75 +243,6 @@ mod tests {
use crate::protocol::RateLimitWindow;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn merge_mcp_tool_selection_deduplicates_and_preserves_order() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
let merged = state.merge_mcp_tool_selection(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
"mcp__rmcp__echo".to_string(),
]);
assert_eq!(
merged,
vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
]
);
let merged = state.merge_mcp_tool_selection(vec![
"mcp__rmcp__image".to_string(),
"mcp__rmcp__search".to_string(),
]);
assert_eq!(
merged,
vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
"mcp__rmcp__search".to_string(),
]
);
}
#[tokio::test]
async fn merge_mcp_tool_selection_empty_input_is_noop() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.merge_mcp_tool_selection(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
]);
let merged = state.merge_mcp_tool_selection(Vec::new());
assert_eq!(
merged,
vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
]
);
assert_eq!(
state.get_mcp_tool_selection(),
Some(vec![
"mcp__rmcp__echo".to_string(),
"mcp__rmcp__image".to_string(),
])
);
}
#[tokio::test]
async fn clear_mcp_tool_selection_removes_selection() {
let session_configuration = make_session_configuration_for_tests().await;
let mut state = SessionState::new(session_configuration);
state.merge_mcp_tool_selection(vec!["mcp__rmcp__echo".to_string()]);
state.clear_mcp_tool_selection();
assert_eq!(state.get_mcp_tool_selection(), None);
}
#[tokio::test]
// Verifies connector merging deduplicates repeated IDs.
async fn merge_connector_selection_deduplicates_entries() {
+14 -1
View File
@@ -335,7 +335,9 @@ pub(crate) async fn handle_non_tool_response_item(
}
Some(turn_item)
}
ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } => {
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. } => {
debug!("unexpected tool output from stream");
None
}
@@ -381,6 +383,17 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti
output,
})
}
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => Some(ResponseItem::ToolSearchOutput {
call_id: Some(call_id.clone()),
status: status.clone(),
execution: execution.clone(),
tools: tools.clone(),
}),
_ => None,
}
}
+150 -1
View File
@@ -33,7 +33,9 @@ use crate::protocol::TurnCompleteEvent;
use crate::state::ActiveTurn;
use crate::state::RunningTask;
use crate::state::TaskKind;
use codex_otel::SessionTelemetry;
use codex_otel::metrics::names::TURN_E2E_DURATION_METRIC;
use codex_otel::metrics::names::TURN_NETWORK_PROXY_METRIC;
use codex_otel::metrics::names::TURN_TOKEN_USAGE_METRIC;
use codex_otel::metrics::names::TURN_TOOL_CALL_METRIC;
use codex_protocol::items::TurnItem;
@@ -56,6 +58,19 @@ pub(crate) use user_shell::execute_user_shell_command;
const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. Any running unified exec processes were terminated. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
fn emit_turn_network_proxy_metric(
session_telemetry: &SessionTelemetry,
network_proxy_active: bool,
tmp_mem: (&str, &str),
) {
let active = if network_proxy_active {
"true"
} else {
"false"
};
session_telemetry.counter(TURN_NETWORK_PROXY_METRIC, 1, &[("active", active), tmp_mem]);
}
/// Thin wrapper that exposes the parts of [`Session`] task runners need.
#[derive(Clone)]
pub(crate) struct SessionTaskContext {
@@ -281,6 +296,25 @@ impl Session {
"false"
},
);
let network_proxy_active = match self.services.network_proxy.as_ref() {
Some(started_network_proxy) => {
match started_network_proxy.proxy().current_cfg().await {
Ok(config) => config.network.enabled,
Err(err) => {
warn!(
"failed to read managed network proxy state for turn metrics: {err:#}"
);
false
}
}
}
None => false,
};
emit_turn_network_proxy_metric(
&self.services.session_telemetry,
network_proxy_active,
tmp_mem,
);
self.services.session_telemetry.histogram(
TURN_TOOL_CALL_METRIC,
i64::try_from(turn_tool_calls).unwrap_or(i64::MAX),
@@ -421,4 +455,119 @@ impl Session {
}
#[cfg(test)]
mod tests {}
mod tests {
use super::emit_turn_network_proxy_metric;
use codex_otel::SessionTelemetry;
use codex_otel::metrics::MetricsClient;
use codex_otel::metrics::MetricsConfig;
use codex_otel::metrics::names::TURN_NETWORK_PROXY_METRIC;
use codex_protocol::ThreadId;
use codex_protocol::protocol::SessionSource;
use opentelemetry::KeyValue;
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
use opentelemetry_sdk::metrics::data::AggregatedMetrics;
use opentelemetry_sdk::metrics::data::Metric;
use opentelemetry_sdk::metrics::data::MetricData;
use opentelemetry_sdk::metrics::data::ResourceMetrics;
use pretty_assertions::assert_eq;
use std::collections::BTreeMap;
fn test_session_telemetry() -> SessionTelemetry {
let exporter = InMemoryMetricExporter::default();
let metrics = MetricsClient::new(
MetricsConfig::in_memory("test", "codex-core", env!("CARGO_PKG_VERSION"), exporter)
.with_runtime_reader(),
)
.expect("in-memory metrics client");
SessionTelemetry::new(
ThreadId::new(),
"gpt-5.1",
"gpt-5.1",
None,
None,
None,
"test_originator".to_string(),
false,
"tty".to_string(),
SessionSource::Cli,
)
.with_metrics_without_metadata_tags(metrics)
}
fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric {
for scope_metrics in resource_metrics.scope_metrics() {
for metric in scope_metrics.metrics() {
if metric.name() == name {
return metric;
}
}
}
panic!("metric {name} missing");
}
fn attributes_to_map<'a>(
attributes: impl Iterator<Item = &'a KeyValue>,
) -> BTreeMap<String, String> {
attributes
.map(|kv| (kv.key.as_str().to_string(), kv.value.as_str().to_string()))
.collect()
}
fn metric_point(resource_metrics: &ResourceMetrics) -> (BTreeMap<String, String>, u64) {
let metric = find_metric(resource_metrics, TURN_NETWORK_PROXY_METRIC);
match metric.data() {
AggregatedMetrics::U64(data) => match data {
MetricData::Sum(sum) => {
let points: Vec<_> = sum.data_points().collect();
assert_eq!(points.len(), 1);
let point = points[0];
(attributes_to_map(point.attributes()), point.value())
}
_ => panic!("unexpected counter aggregation"),
},
_ => panic!("unexpected counter data type"),
}
}
#[test]
fn emit_turn_network_proxy_metric_records_active_turn() {
let session_telemetry = test_session_telemetry();
emit_turn_network_proxy_metric(&session_telemetry, true, ("tmp_mem_enabled", "true"));
let snapshot = session_telemetry
.snapshot_metrics()
.expect("runtime metrics snapshot");
let (attrs, value) = metric_point(&snapshot);
assert_eq!(value, 1);
assert_eq!(
attrs,
BTreeMap::from([
("active".to_string(), "true".to_string()),
("tmp_mem_enabled".to_string(), "true".to_string()),
])
);
}
#[test]
fn emit_turn_network_proxy_metric_records_inactive_turn() {
let session_telemetry = test_session_telemetry();
emit_turn_network_proxy_metric(&session_telemetry, false, ("tmp_mem_enabled", "false"));
let snapshot = session_telemetry
.snapshot_metrics()
.expect("runtime metrics snapshot");
let (attrs, value) = metric_point(&snapshot);
assert_eq!(value, 1);
assert_eq!(
attrs,
BTreeMap::from([
("active".to_string(), "false".to_string()),
("tmp_mem_enabled".to_string(), "false".to_string()),
])
);
}
}
+1
View File
@@ -721,6 +721,7 @@ mod tests {
id: None,
call_id: "c1".to_string(),
name: "tool".to_string(),
namespace: None,
arguments: "{}".to_string(),
},
assistant_msg("a4"),
+57 -32
View File
@@ -10,6 +10,7 @@ use crate::exec_env::create_env;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::tools::ToolRouter;
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
use crate::tools::code_mode_description::code_mode_tool_reference;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
@@ -51,8 +52,11 @@ enum CodeModeToolKind {
#[derive(Clone, Debug, Serialize)]
struct EnabledTool {
tool_name: String,
#[serde(rename = "module")]
module_path: String,
namespace: Vec<String>,
name: String,
description: String,
kind: CodeModeToolKind,
}
@@ -107,7 +111,7 @@ pub(crate) fn instructions(config: &Config) -> Option<String> {
section.push_str(&format!(
"- `{PUBLIC_TOOL_NAME}` uses the same Node runtime resolution as `js_repl`. If needed, point `js_repl_node_path` at the Node binary you want Codex to use.\n",
));
section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { tools } from \"tools.js\"`. Namespaced tools are also available from `tools/<namespace...>.js`; MCP tools use `tools/mcp/<server>.js`, for example `import { append_notebook_logs_chart } from \"tools/mcp/ologs.js\"`. `tools[name]` and identifier wrappers like `await exec_command(args)` remain available for compatibility. Nested tool calls resolve to their code-mode result values.\n");
section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { ALL_TOOLS } from \"tools.js\"` to inspect the available `{ module, name, description }` entries. Namespaced tools are also available from `tools/<namespace...>.js`; MCP tools use `tools/mcp/<server>.js`, for example `import { append_notebook_logs_chart } from \"tools/mcp/ologs.js\"`. Nested tool calls resolve to their code-mode result values.\n");
section.push_str(&format!(
"- Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, store, load }}` from `@openai/code_mode` (or `\"openai/code_mode\"`). `output_text(value)` surfaces text back to the model and stringifies non-string objects with `JSON.stringify(...)` when possible. `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs. `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, and `load(key)` returns a cloned stored value or `undefined`. `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate the final Rust-side result of the current `{PUBLIC_TOOL_NAME}` execution; the default is `10000`. This guards the overall `{PUBLIC_TOOL_NAME}` output, not individual nested tool invocations. The returned content starts with a separate `Script completed` or `Script failed` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker.\n",
));
@@ -348,27 +352,46 @@ fn truncate_code_mode_result(
async fn build_enabled_tools(exec: &ExecContext) -> Vec<EnabledTool> {
let router = build_nested_router(exec).await;
let mut out = Vec::new();
for spec in router.specs() {
let tool_name = spec.name().to_string();
if tool_name == PUBLIC_TOOL_NAME {
continue;
}
let reference = code_mode_tool_reference(&tool_name);
out.push(EnabledTool {
tool_name,
namespace: reference.namespace,
name: reference.tool_key,
kind: tool_kind_for_spec(&spec),
});
}
let mut out = router
.specs()
.into_iter()
.map(|spec| augment_tool_spec_for_code_mode(spec, true))
.filter_map(enabled_tool_from_spec)
.collect::<Vec<_>>();
out.sort_by(|left, right| left.tool_name.cmp(&right.tool_name));
out.dedup_by(|left, right| left.tool_name == right.tool_name);
out
}
fn enabled_tool_from_spec(spec: ToolSpec) -> Option<EnabledTool> {
let tool_name = spec.name().to_string();
if tool_name == PUBLIC_TOOL_NAME {
return None;
}
let reference = code_mode_tool_reference(&tool_name);
let (description, kind) = match spec {
ToolSpec::Function(tool) => (tool.description, CodeModeToolKind::Function),
ToolSpec::Freeform(tool) => (tool.description, CodeModeToolKind::Freeform),
ToolSpec::LocalShell {}
| ToolSpec::ImageGeneration { .. }
| ToolSpec::ToolSearch { .. }
| ToolSpec::WebSearch { .. } => {
return None;
}
};
Some(EnabledTool {
tool_name,
module_path: reference.module_path,
namespace: reference.namespace,
name: reference.tool_key,
description,
kind,
})
}
async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
let nested_tools_config = exec.turn.tools_config.for_code_mode_nested_tools();
let mcp_tools = exec
@@ -403,25 +426,27 @@ async fn call_nested_tool(
let router = build_nested_router(&exec).await;
let specs = router.specs();
let payload = if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name).await {
match serialize_function_tool_arguments(&tool_name, input) {
Ok(raw_arguments) => ToolPayload::Mcp {
server,
tool,
raw_arguments,
},
Err(error) => return JsonValue::String(error),
}
} else {
match build_nested_tool_payload(&specs, &tool_name, input) {
Ok(payload) => payload,
Err(error) => return JsonValue::String(error),
}
};
let payload =
if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&tool_name, &None).await {
match serialize_function_tool_arguments(&tool_name, input) {
Ok(raw_arguments) => ToolPayload::Mcp {
server,
tool,
raw_arguments,
},
Err(error) => return JsonValue::String(error),
}
} else {
match build_nested_tool_payload(&specs, &tool_name, input) {
Ok(payload) => payload,
Err(error) => return JsonValue::String(error),
}
};
let call = ToolCall {
tool_name: tool_name.clone(),
call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()),
tool_namespace: None,
payload,
};
let result = router
+1 -8
View File
@@ -55,13 +55,6 @@ globalThis.add_content = (value) => {
return contentItems;
};
globalThis.tools = new Proxy(Object.create(null), {
get(_target, prop) {
const name = String(prop);
return async (args) => __codex_tool_call(name, args);
},
});
globalThis.console = Object.freeze({
log() {},
info() {},
@@ -71,7 +64,7 @@ globalThis.console = Object.freeze({
});
for (const name of __codexEnabledToolNames) {
if (/^[A-Za-z_$][0-9A-Za-z_$]*$/.test(name) && !(name in globalThis)) {
if (!(name in globalThis)) {
Object.defineProperty(globalThis, name, {
value: async (args) => __codex_tool_call(name, args),
configurable: true,
@@ -75,11 +75,9 @@ fn append_code_mode_sample(
output_type: String,
) -> String {
let reference = code_mode_tool_reference(tool_name);
let local_name = code_mode_local_name(&reference.tool_key);
format!(
"{description}\n\nCode mode declaration:\n```ts\nimport {{ tools }} from \"{}\";\ndeclare function {local_name}({input_name}: {input_type}): Promise<{output_type}>;\n```",
reference.module_path
"{description}\n\nCode mode declaration:\n```ts\nimport {{ {} }} from \"{}\";\ndeclare function {}({input_name}: {input_type}): Promise<{output_type}>;\n```",
reference.tool_key, reference.module_path, reference.tool_key
)
}
@@ -100,22 +98,6 @@ fn code_mode_local_name(tool_key: &str) -> String {
}
}
if identifier.is_empty() {
return "tool_call".to_string();
}
if identifier == "tools" {
identifier.push_str("_tool");
}
if identifier
.chars()
.next()
.is_some_and(|ch| ch.is_ascii_digit())
{
identifier.insert(0, '_');
}
identifier
}
+20 -14
View File
@@ -108,10 +108,6 @@ function formatErrorText(error) {
return String(error && error.stack ? error.stack : error);
}
function isValidIdentifier(name) {
return /^[A-Za-z_$][0-9A-Za-z_$]*$/.test(name);
}
function cloneJsonValue(value) {
return JSON.parse(JSON.stringify(value));
}
@@ -139,12 +135,25 @@ function createToolsNamespace(callTool, enabledTools) {
return Object.freeze(tools);
}
function createAllToolsMetadata(enabledTools) {
return Object.freeze(
enabledTools.map(({ module: modulePath, name, description }) =>
Object.freeze({
module: modulePath,
name,
description,
})
)
);
}
function createToolsModule(context, callTool, enabledTools) {
const tools = createToolsNamespace(callTool, enabledTools);
const exportNames = ['tools'];
const allTools = createAllToolsMetadata(enabledTools);
const exportNames = ['ALL_TOOLS'];
for (const { tool_name } of enabledTools) {
if (tool_name !== 'tools' && isValidIdentifier(tool_name)) {
if (tool_name !== 'ALL_TOOLS') {
exportNames.push(tool_name);
}
}
@@ -154,9 +163,9 @@ function createToolsModule(context, callTool, enabledTools) {
return new SyntheticModule(
uniqueExportNames,
function initToolsModule() {
this.setExport('tools', tools);
this.setExport('ALL_TOOLS', allTools);
for (const exportName of uniqueExportNames) {
if (exportName !== 'tools') {
if (exportName !== 'ALL_TOOLS') {
this.setExport(exportName, tools[exportName]);
}
}
@@ -283,10 +292,10 @@ function createNamespacedToolsNamespace(callTool, enabledTools, namespace) {
function createNamespacedToolsModule(context, callTool, enabledTools, namespace) {
const tools = createNamespacedToolsNamespace(callTool, enabledTools, namespace);
const exportNames = ['tools'];
const exportNames = [];
for (const exportName of Object.keys(tools)) {
if (exportName !== 'tools' && isValidIdentifier(exportName)) {
if (exportName !== 'ALL_TOOLS') {
exportNames.push(exportName);
}
}
@@ -296,11 +305,8 @@ function createNamespacedToolsModule(context, callTool, enabledTools, namespace)
return new SyntheticModule(
uniqueExportNames,
function initNamespacedToolsModule() {
this.setExport('tools', tools);
for (const exportName of uniqueExportNames) {
if (exportName !== 'tools') {
this.setExport(exportName, tools[exportName]);
}
this.setExport(exportName, tools[exportName]);
}
},
{ context }
+105
View File
@@ -1,3 +1,4 @@
use crate::client_common::tools::ToolSearchOutputTool;
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES;
@@ -12,6 +13,7 @@ use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::SearchToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use codex_protocol::models::function_call_output_content_items_to_text;
use codex_utils_string::take_bytes_at_char_boundary;
@@ -38,6 +40,7 @@ pub struct ToolInvocation {
pub tracker: SharedTurnDiffTracker,
pub call_id: String,
pub tool_name: String,
pub tool_namespace: Option<String>,
pub payload: ToolPayload,
}
@@ -46,6 +49,9 @@ pub enum ToolPayload {
Function {
arguments: String,
},
ToolSearch {
arguments: SearchToolCallParams,
},
Custom {
input: String,
},
@@ -63,6 +69,7 @@ impl ToolPayload {
pub fn log_payload(&self) -> Cow<'_, str> {
match self {
ToolPayload::Function { arguments } => Cow::Borrowed(arguments),
ToolPayload::ToolSearch { arguments } => Cow::Owned(arguments.query.clone()),
ToolPayload::Custom { input } => Cow::Borrowed(input),
ToolPayload::LocalShell { params } => Cow::Owned(params.command.join(" ")),
ToolPayload::Mcp { raw_arguments, .. } => Cow::Borrowed(raw_arguments),
@@ -107,6 +114,47 @@ impl ToolOutput for CallToolResult {
}
}
#[derive(Clone)]
pub struct ToolSearchOutput {
pub tools: Vec<ToolSearchOutputTool>,
}
impl ToolOutput for ToolSearchOutput {
fn log_preview(&self) -> String {
let tools = self
.tools
.iter()
.map(|tool| {
serde_json::to_value(tool).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize tool_search output: {err}"))
})
})
.collect();
telemetry_preview(&JsonValue::Array(tools).to_string())
}
fn success_for_logging(&self) -> bool {
true
}
fn to_response_item(&self, call_id: &str, _payload: &ToolPayload) -> ResponseInputItem {
ResponseInputItem::ToolSearchOutput {
call_id: call_id.to_string(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: self
.tools
.iter()
.map(|tool| {
serde_json::to_value(tool).unwrap_or_else(|err| {
JsonValue::String(format!("failed to serialize tool_search output: {err}"))
})
})
.collect(),
}
}
}
pub struct FunctionToolOutput {
pub body: Vec<FunctionCallOutputContentItem>,
pub success: Option<bool>,
@@ -277,6 +325,7 @@ fn response_input_to_code_mode_result(response: ResponseInputItem) -> JsonValue
content_items_to_code_mode_result(&items)
}
},
ResponseInputItem::ToolSearchOutput { tools, .. } => JsonValue::Array(tools),
ResponseInputItem::McpToolCallOutput { output, .. } => {
output.code_mode_result(&ToolPayload::Mcp {
server: String::new(),
@@ -379,6 +428,7 @@ mod tests {
use super::*;
use core_test_support::assert_regex_match;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn custom_tool_calls_should_roundtrip_as_custom_outputs() {
@@ -505,6 +555,61 @@ mod tests {
}
}
#[test]
fn tool_search_payloads_roundtrip_as_tool_search_outputs() {
let payload = ToolPayload::ToolSearch {
arguments: SearchToolCallParams {
query: "calendar".to_string(),
limit: None,
},
};
let response = ToolSearchOutput {
tools: vec![ToolSearchOutputTool::Function(
crate::client_common::tools::ResponsesApiTool {
name: "create_event".to_string(),
description: String::new(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
},
)],
}
.to_response_item("search-1", &payload);
match response {
ResponseInputItem::ToolSearchOutput {
call_id,
status,
execution,
tools,
} => {
assert_eq!(call_id, "search-1");
assert_eq!(status, "completed");
assert_eq!(execution, "client");
assert_eq!(
tools,
vec![json!({
"type": "function",
"name": "create_event",
"description": "",
"strict": false,
"defer_loading": true,
"parameters": {
"type": "object",
"properties": {}
}
})]
);
}
other => panic!("expected ToolSearchOutput, got {other:?}"),
}
}
#[test]
fn log_preview_uses_content_items_when_plain_text_is_missing() {
let output = FunctionToolOutput::from_content(
@@ -422,6 +422,7 @@ It is important to remember:
"#
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["input".to_string()]),
@@ -1117,6 +1117,7 @@ mod tests {
tracker: Arc::new(Mutex::new(TurnDiffTracker::default())),
call_id: "call-1".to_string(),
tool_name: tool_name.to_string(),
tool_namespace: None,
payload,
}
}
+4 -4
View File
@@ -13,9 +13,9 @@ mod plan;
mod read_file;
mod request_permissions;
mod request_user_input;
mod search_tool_bm25;
mod shell;
mod test_sync;
mod tool_search;
pub(crate) mod unified_exec;
mod view_image;
@@ -50,12 +50,12 @@ pub use request_permissions::RequestPermissionsHandler;
pub(crate) use request_permissions::request_permissions_tool_description;
pub use request_user_input::RequestUserInputHandler;
pub(crate) use request_user_input::request_user_input_tool_description;
pub(crate) use search_tool_bm25::DEFAULT_LIMIT as SEARCH_TOOL_BM25_DEFAULT_LIMIT;
pub(crate) use search_tool_bm25::SEARCH_TOOL_BM25_TOOL_NAME;
pub use search_tool_bm25::SearchToolBm25Handler;
pub use shell::ShellCommandHandler;
pub use shell::ShellHandler;
pub use test_sync::TestSyncHandler;
pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT;
pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME;
pub use tool_search::ToolSearchHandler;
pub use unified_exec::UnifiedExecHandler;
pub use view_image::ViewImageHandler;
+1
View File
@@ -52,6 +52,7 @@ At most one step can be in_progress at a time.
"#
.to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["plan".to_string()]),
@@ -1,349 +0,0 @@
use async_trait::async_trait;
use bm25::Document;
use bm25::Language;
use bm25::SearchEngineBuilder;
use codex_app_server_protocol::AppInfo;
use serde::Deserialize;
use serde_json::json;
use std::collections::HashMap;
use std::collections::HashSet;
use crate::connectors;
use crate::function_tool::FunctionCallError;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
pub struct SearchToolBm25Handler;
pub(crate) const SEARCH_TOOL_BM25_TOOL_NAME: &str = "search_tool_bm25";
pub(crate) const DEFAULT_LIMIT: usize = 8;
fn default_limit() -> usize {
DEFAULT_LIMIT
}
#[derive(Deserialize)]
struct SearchToolBm25Args {
query: String,
#[serde(default = "default_limit")]
limit: usize,
}
#[derive(Clone)]
struct ToolEntry {
name: String,
server_name: String,
title: Option<String>,
description: Option<String>,
connector_name: Option<String>,
input_keys: Vec<String>,
search_text: String,
}
impl ToolEntry {
fn new(name: String, info: ToolInfo) -> Self {
let input_keys = info
.tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.map(|map| map.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let search_text = build_search_text(&name, &info, &input_keys);
Self {
name,
server_name: info.server_name,
title: info.tool.title,
description: info
.tool
.description
.map(|description| description.to_string()),
connector_name: info.connector_name,
input_keys,
search_text,
}
}
}
#[async_trait]
impl ToolHandler for SearchToolBm25Handler {
type Output = FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
let ToolInvocation {
payload,
session,
turn,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(format!(
"{SEARCH_TOOL_BM25_TOOL_NAME} handler received unsupported payload"
)));
}
};
let args: SearchToolBm25Args = parse_arguments(&arguments)?;
let query = args.query.trim();
if query.is_empty() {
return Err(FunctionCallError::RespondToModel(
"query must not be empty".to_string(),
));
}
if args.limit == 0 {
return Err(FunctionCallError::RespondToModel(
"limit must be greater than zero".to_string(),
));
}
let limit = args.limit;
let mcp_tools = session
.services
.mcp_connection_manager
.read()
.await
.list_all_tools()
.await;
let connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
let mcp_tools = filter_codex_apps_mcp_tools(mcp_tools, &connectors);
let mcp_tools = connectors::filter_codex_apps_tools_by_policy(mcp_tools, &turn.config);
let mut entries: Vec<ToolEntry> = mcp_tools
.into_iter()
.map(|(name, info)| ToolEntry::new(name, info))
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
if entries.is_empty() {
let active_selected_tools = session.get_mcp_tool_selection().await.unwrap_or_default();
let content = json!({
"query": query,
"total_tools": 0,
"active_selected_tools": active_selected_tools,
"tools": [],
})
.to_string();
return Ok(FunctionToolOutput::from_text(content, Some(true)));
}
let documents: Vec<Document<usize>> = entries
.iter()
.enumerate()
.map(|(idx, entry)| Document::new(idx, entry.search_text.clone()))
.collect();
let search_engine =
SearchEngineBuilder::<usize>::with_documents(Language::English, documents).build();
let results = search_engine.search(query, limit);
let mut selected_tools = Vec::new();
let mut result_payloads = Vec::new();
for result in results {
let Some(entry) = entries.get(result.document.id) else {
continue;
};
selected_tools.push(entry.name.clone());
result_payloads.push(json!({
"name": entry.name.clone(),
"server": entry.server_name.clone(),
"title": entry.title.clone(),
"description": entry.description.clone(),
"connector_name": entry.connector_name.clone(),
"input_keys": entry.input_keys.clone(),
"score": result.score,
}));
}
let active_selected_tools = session.merge_mcp_tool_selection(selected_tools).await;
let content = json!({
"query": query,
"total_tools": entries.len(),
"active_selected_tools": active_selected_tools,
"tools": result_payloads,
})
.to_string();
Ok(FunctionToolOutput::from_text(content, Some(true)))
}
}
fn filter_codex_apps_mcp_tools(
mut mcp_tools: HashMap<String, ToolInfo>,
connectors: &[AppInfo],
) -> HashMap<String, ToolInfo> {
let enabled_connectors: HashSet<&str> = connectors
.iter()
.filter(|connector| connector.is_enabled)
.map(|connector| connector.id.as_str())
.collect();
mcp_tools.retain(|_, tool| {
if tool.server_name != CODEX_APPS_MCP_SERVER_NAME {
return false;
}
tool.connector_id
.as_deref()
.is_some_and(|connector_id| enabled_connectors.contains(connector_id))
});
mcp_tools
}
fn build_search_text(name: &str, info: &ToolInfo, input_keys: &[String]) -> String {
let mut parts = vec![
name.to_string(),
info.tool_name.clone(),
info.server_name.clone(),
];
if let Some(title) = info.tool.title.as_deref()
&& !title.trim().is_empty()
{
parts.push(title.to_string());
}
if let Some(description) = info.tool.description.as_deref()
&& !description.trim().is_empty()
{
parts.push(description.to_string());
}
if let Some(connector_name) = info.connector_name.as_deref()
&& !connector_name.trim().is_empty()
{
parts.push(connector_name.to_string());
}
if !input_keys.is_empty() {
parts.extend(input_keys.iter().cloned());
}
parts.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::AppInfo;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use std::sync::Arc;
fn make_connector(id: &str, enabled: bool) -> AppInfo {
AppInfo {
id: id.to_string(),
name: id.to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: enabled,
plugin_display_names: Vec::new(),
}
}
fn make_tool(
qualified_name: &str,
server_name: &str,
tool_name: &str,
connector_id: Option<&str>,
) -> (String, ToolInfo) {
(
qualified_name.to_string(),
ToolInfo {
server_name: server_name.to_string(),
tool_name: tool_name.to_string(),
tool: Tool {
name: tool_name.to_string().into(),
title: None,
description: Some(format!("Test tool: {tool_name}").into()),
input_schema: Arc::new(JsonObject::default()),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: connector_id.map(str::to_string),
connector_name: connector_id.map(str::to_string),
plugin_display_names: Vec::new(),
},
)
}
#[test]
fn filter_codex_apps_mcp_tools_keeps_enabled_apps_only() {
let mcp_tools = HashMap::from([
make_tool(
"mcp__codex_apps__calendar_create_event",
CODEX_APPS_MCP_SERVER_NAME,
"calendar_create_event",
Some("calendar"),
),
make_tool(
"mcp__codex_apps__drive_search",
CODEX_APPS_MCP_SERVER_NAME,
"drive_search",
Some("drive"),
),
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
]);
let connectors = vec![
make_connector("calendar", false),
make_connector("drive", true),
];
let mut filtered: Vec<String> = filter_codex_apps_mcp_tools(mcp_tools, &connectors)
.into_keys()
.collect();
filtered.sort();
assert_eq!(filtered, vec!["mcp__codex_apps__drive_search".to_string()]);
}
#[test]
fn filter_codex_apps_mcp_tools_drops_apps_without_connector_id() {
let mcp_tools = HashMap::from([
make_tool(
"mcp__codex_apps__unknown",
CODEX_APPS_MCP_SERVER_NAME,
"unknown",
None,
),
make_tool("mcp__rmcp__echo", "rmcp", "echo", None),
]);
let mut filtered: Vec<String> =
filter_codex_apps_mcp_tools(mcp_tools, &[make_connector("calendar", true)])
.into_keys()
.collect();
filtered.sort();
assert_eq!(filtered, Vec::<String>::new());
}
}
@@ -0,0 +1,390 @@
use crate::client_common::tools::ResponsesApiNamespace;
use crate::client_common::tools::ResponsesApiNamespaceTool;
use crate::client_common::tools::ToolSearchOutputTool;
use crate::function_tool::FunctionCallError;
use crate::mcp_connection_manager::ToolInfo;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::ToolSearchOutput;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use crate::tools::spec::mcp_tool_to_deferred_openai_tool;
use async_trait::async_trait;
use bm25::Document;
use bm25::Language;
use bm25::SearchEngineBuilder;
use std::collections::BTreeMap;
use std::collections::HashMap;
#[cfg(test)]
use crate::client_common::tools::ResponsesApiTool;
pub struct ToolSearchHandler {
tools: HashMap<String, ToolInfo>,
}
pub(crate) const TOOL_SEARCH_TOOL_NAME: &str = "tool_search";
pub(crate) const DEFAULT_LIMIT: usize = 8;
impl ToolSearchHandler {
pub fn new(tools: HashMap<String, ToolInfo>) -> Self {
Self { tools }
}
}
#[async_trait]
impl ToolHandler for ToolSearchHandler {
type Output = ToolSearchOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(
&self,
invocation: ToolInvocation,
) -> Result<ToolSearchOutput, FunctionCallError> {
let ToolInvocation { payload, .. } = invocation;
let args = match payload {
ToolPayload::ToolSearch { arguments } => arguments,
_ => {
return Err(FunctionCallError::Fatal(format!(
"{TOOL_SEARCH_TOOL_NAME} handler received unsupported payload"
)));
}
};
let query = args.query.trim();
if query.is_empty() {
return Err(FunctionCallError::RespondToModel(
"query must not be empty".to_string(),
));
}
let limit = args.limit.unwrap_or(DEFAULT_LIMIT);
if limit == 0 {
return Err(FunctionCallError::RespondToModel(
"limit must be greater than zero".to_string(),
));
}
let mut entries: Vec<(String, ToolInfo)> = self.tools.clone().into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
if entries.is_empty() {
return Ok(ToolSearchOutput { tools: Vec::new() });
}
let documents: Vec<Document<usize>> = entries
.iter()
.enumerate()
.map(|(idx, (name, info))| Document::new(idx, build_search_text(name, info)))
.collect();
let search_engine =
SearchEngineBuilder::<usize>::with_documents(Language::English, documents).build();
let results = search_engine.search(query, limit);
let matched_entries = results
.into_iter()
.filter_map(|result| entries.get(result.document.id))
.collect::<Vec<_>>();
let tools = serialize_tool_search_output_tools(&matched_entries).map_err(|err| {
FunctionCallError::Fatal(format!("failed to encode tool_search output: {err}"))
})?;
Ok(ToolSearchOutput { tools })
}
}
fn serialize_tool_search_output_tools(
matched_entries: &[&(String, ToolInfo)],
) -> Result<Vec<ToolSearchOutputTool>, serde_json::Error> {
let grouped: BTreeMap<String, Vec<ToolInfo>> =
matched_entries
.iter()
.fold(BTreeMap::new(), |mut acc, (_name, tool)| {
acc.entry(tool.tool_namespace.clone())
.or_default()
.push(tool.clone());
acc
});
let mut results = Vec::with_capacity(grouped.len());
for (namespace, tools) in grouped {
let Some(first_tool) = tools.first() else {
continue;
};
let description = first_tool.connector_description.clone().or_else(|| {
first_tool
.connector_name
.as_deref()
.map(str::trim)
.filter(|connector_name| !connector_name.is_empty())
.map(|connector_name| format!("Tools for working with {connector_name}."))
});
let tools = tools
.iter()
.map(|tool| {
mcp_tool_to_deferred_openai_tool(tool.tool_name.clone(), tool.tool.clone())
.map(ResponsesApiNamespaceTool::Function)
})
.collect::<Result<Vec<_>, _>>()?;
results.push(ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
name: namespace,
description: description.unwrap_or_default(),
tools,
}));
}
Ok(results)
}
fn build_search_text(name: &str, info: &ToolInfo) -> String {
let mut parts = vec![
name.to_string(),
info.tool_name.clone(),
info.server_name.clone(),
];
if let Some(title) = info.tool.title.as_deref()
&& !title.trim().is_empty()
{
parts.push(title.to_string());
}
if let Some(description) = info.tool.description.as_deref()
&& !description.trim().is_empty()
{
parts.push(description.to_string());
}
if let Some(connector_name) = info.connector_name.as_deref()
&& !connector_name.trim().is_empty()
{
parts.push(connector_name.to_string());
}
if let Some(connector_description) = info.connector_description.as_deref()
&& !connector_description.trim().is_empty()
{
parts.push(connector_description.to_string());
}
parts.extend(
info.tool
.input_schema
.get("properties")
.and_then(serde_json::Value::as_object)
.map(|map| map.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default(),
);
parts.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use serde_json::json;
use std::sync::Arc;
#[test]
fn serialize_tool_search_output_tools_groups_results_by_namespace() {
let entries = [
(
"mcp__codex_apps__calendar-create-event".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "-create-event".to_string(),
tool_namespace: "mcp__codex_apps__calendar".to_string(),
tool: Tool {
name: "calendar-create-event".to_string().into(),
title: None,
description: Some("Create a calendar event.".into()),
input_schema: Arc::new(JsonObject::from_iter([(
"type".to_string(),
json!("object"),
)])),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
plugin_display_names: Vec::new(),
connector_description: Some("Plan events".to_string()),
},
),
(
"mcp__codex_apps__gmail-read-email".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "-read-email".to_string(),
tool_namespace: "mcp__codex_apps__gmail".to_string(),
tool: Tool {
name: "gmail-read-email".to_string().into(),
title: None,
description: Some("Read an email.".into()),
input_schema: Arc::new(JsonObject::from_iter([(
"type".to_string(),
json!("object"),
)])),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("gmail".to_string()),
connector_name: Some("Gmail".to_string()),
plugin_display_names: Vec::new(),
connector_description: Some("Read mail".to_string()),
},
),
(
"mcp__codex_apps__calendar-list-events".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "-list-events".to_string(),
tool_namespace: "mcp__codex_apps__calendar".to_string(),
tool: Tool {
name: "calendar-list-events".to_string().into(),
title: None,
description: Some("List calendar events.".into()),
input_schema: Arc::new(JsonObject::from_iter([(
"type".to_string(),
json!("object"),
)])),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("calendar".to_string()),
connector_name: Some("Calendar".to_string()),
plugin_display_names: Vec::new(),
connector_description: Some("Plan events".to_string()),
},
),
];
let tools = serialize_tool_search_output_tools(&[&entries[0], &entries[1], &entries[2]])
.expect("serialize tool search output");
assert_eq!(
tools,
vec![
ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
name: "mcp__codex_apps__calendar".to_string(),
description: "Plan events".to_string(),
tools: vec![
ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "-create-event".to_string(),
description: "Create a calendar event.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
}),
ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "-list-events".to_string(),
description: "List calendar events.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
}),
],
}),
ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
name: "mcp__codex_apps__gmail".to_string(),
description: "Read mail".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "-read-email".to_string(),
description: "Read an email.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
})],
})
]
);
}
#[test]
fn serialize_tool_search_output_tools_falls_back_to_connector_name_description() {
let entries = [(
"mcp__codex_apps__gmail-batch-read-email".to_string(),
ToolInfo {
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
tool_name: "-batch-read-email".to_string(),
tool_namespace: "mcp__codex_apps__gmail".to_string(),
tool: Tool {
name: "gmail-batch-read-email".to_string().into(),
title: None,
description: Some("Read multiple emails.".into()),
input_schema: Arc::new(JsonObject::from_iter([(
"type".to_string(),
json!("object"),
)])),
output_schema: None,
annotations: None,
execution: None,
icons: None,
meta: None,
},
connector_id: Some("connector_gmail_456".to_string()),
connector_name: Some("Gmail".to_string()),
plugin_display_names: Vec::new(),
connector_description: None,
},
)];
let tools = serialize_tool_search_output_tools(&[&entries[0]]).expect("serialize");
assert_eq!(
tools,
vec![ToolSearchOutputTool::Namespace(ResponsesApiNamespace {
name: "mcp__codex_apps__gmail".to_string(),
description: "Tools for working with Gmail.".to_string(),
tools: vec![ResponsesApiNamespaceTool::Function(ResponsesApiTool {
name: "-batch-read-email".to_string(),
description: "Read multiple emails.".to_string(),
strict: false,
defer_loading: Some(true),
parameters: crate::tools::spec::JsonSchema::Object {
properties: Default::default(),
required: None,
additional_properties: None,
},
output_schema: None,
})],
})]
);
}
}
+24 -3
View File
@@ -8,8 +8,8 @@ use codex_utils_image::PromptImageMode;
use serde::Deserialize;
use tokio::fs;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::original_image_detail::can_request_original_image_detail;
use crate::protocol::EventMsg;
use crate::protocol::ViewImageToolCallEvent;
use crate::tools::context::FunctionToolOutput;
@@ -27,6 +27,12 @@ const VIEW_IMAGE_UNSUPPORTED_MESSAGE: &str =
#[derive(Deserialize)]
struct ViewImageArgs {
path: String,
detail: Option<String>,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum ViewImageDetail {
Original,
}
#[async_trait]
@@ -67,6 +73,19 @@ impl ToolHandler for ViewImageHandler {
};
let args: ViewImageArgs = parse_arguments(&arguments)?;
// `view_image` accepts only its documented detail values: omit
// `detail` for the default path or set it to `original`.
// Other string values remain invalid rather than being silently
// reinterpreted.
let detail = match args.detail.as_deref() {
None => None,
Some("original") => Some(ViewImageDetail::Original),
Some(detail) => {
return Err(FunctionCallError::RespondToModel(format!(
"view_image.detail only supports `original`; omit `detail` for default resized behavior, got `{detail}`"
)));
}
};
let abs_path = turn.resolve_path(Some(args.path));
@@ -85,8 +104,10 @@ impl ToolHandler for ViewImageHandler {
}
let event_path = abs_path.clone();
let use_original_detail = turn.config.features.enabled(Feature::ImageDetailOriginal)
&& turn.model_info.supports_image_detail_original;
let can_request_original_detail =
can_request_original_image_detail(turn.features.get(), &turn.model_info);
let use_original_detail =
can_request_original_detail && matches!(detail, Some(ViewImageDetail::Original));
let image_mode = if use_original_detail {
PromptImageMode::Original
} else {
+8 -12
View File
@@ -127,7 +127,9 @@ const pendingTool = new Map();
const pendingEmitImage = new Map();
let toolCounter = 0;
let emitImageCounter = 0;
const tmpDir = process.env.CODEX_JS_TMP_DIR || process.cwd();
const cwd = process.cwd();
const tmpDir = process.env.CODEX_JS_TMP_DIR || cwd;
const homeDir = process.env.HOME ?? null;
const nodeModuleDirEnv = process.env.CODEX_JS_REPL_NODE_MODULE_DIRS ?? "";
const moduleSearchBases = (() => {
const bases = [];
@@ -150,7 +152,6 @@ const moduleSearchBases = (() => {
seen.add(base);
bases.push(base);
}
const cwd = process.cwd();
if (!seen.has(cwd)) {
bases.push(cwd);
}
@@ -1209,20 +1210,15 @@ function encodeByteImage(bytes, mimeType, detail) {
}
function parseImageDetail(detail) {
if (typeof detail === "undefined") {
if (detail == null) {
return undefined;
}
if (typeof detail !== "string" || !detail) {
throw new Error("codex.emitImage expected detail to be a non-empty string");
}
if (
detail !== "auto" &&
detail !== "low" &&
detail !== "high" &&
detail !== "original"
) {
if (detail !== "original") {
throw new Error(
'codex.emitImage expected detail to be one of "auto", "low", "high", or "original"',
'codex.emitImage only supports detail "original"; omit detail for default behavior',
);
}
return detail;
@@ -1539,12 +1535,12 @@ async function handleExec(message) {
priorBindings = builtSource.priorBindings;
let output = "";
context.codex = { tmpDir, tool, emitImage };
context.codex = { cwd, homeDir, tmpDir, tool, emitImage };
context.tmpDir = tmpDir;
await withCapturedConsole(context, async (logs) => {
const cellIdentifier = path.join(
process.cwd(),
cwd,
`.codex_js_repl_cell_${cellCounter++}.mjs`,
);
module = new SourceTextModule(source, {
+165 -32
View File
@@ -36,8 +36,8 @@ use crate::codex::Session;
use crate::codex::TurnContext;
use crate::exec::ExecExpiration;
use crate::exec_env::create_env;
use crate::features::Feature;
use crate::function_tool::FunctionCallError;
use crate::original_image_detail::normalize_output_image_detail;
use crate::sandboxing::CommandSpec;
use crate::sandboxing::SandboxManager;
use crate::sandboxing::SandboxPermissions;
@@ -637,6 +637,16 @@ impl JsReplManager {
summary.result_is_error = Some(!output.success());
summary
}
ResponseInputItem::ToolSearchOutput { tools, .. } => JsReplToolCallResponseSummary {
response_type: Some("tool_search_output".to_string()),
payload_kind: Some(JsReplToolCallPayloadKind::FunctionText),
payload_text_preview: Some(serde_json::Value::Array(tools.clone()).to_string()),
payload_text_length: Some(
serde_json::Value::Array(tools.clone()).to_string().len(),
),
payload_item_count: Some(tools.len()),
..Default::default()
},
}
}
@@ -678,8 +688,13 @@ impl JsReplManager {
let (stdin, pending_execs, exec_contexts, child, recent_stderr) = {
let mut kernel = self.kernel.lock().await;
if kernel.is_none() {
let dependency_env = session.dependency_env().await;
let state = self
.start_kernel(Arc::clone(&turn), Some(session.conversation_id))
.start_kernel(
Arc::clone(&turn),
&dependency_env,
Some(session.conversation_id),
)
.await
.map_err(FunctionCallError::RespondToModel)?;
*kernel = Some(state);
@@ -800,6 +815,7 @@ impl JsReplManager {
async fn start_kernel(
&self,
turn: Arc<TurnContext>,
dependency_env: &HashMap<String, String>,
thread_id: Option<ThreadId>,
) -> Result<KernelState, String> {
let node_path = resolve_compatible_node(self.node_path.as_deref()).await?;
@@ -810,6 +826,9 @@ impl JsReplManager {
.map_err(|err| err.to_string())?;
let mut env = create_env(&turn.shell_environment_policy, thread_id);
if !dependency_env.is_empty() {
env.extend(dependency_env.clone());
}
env.insert(
"CODEX_JS_TMP_DIR".to_string(),
self.tmp_dir.path().to_string_lossy().to_string(),
@@ -1351,26 +1370,30 @@ impl JsReplManager {
exec.turn.dynamic_tools.as_slice(),
);
let payload =
if let Some((server, tool)) = exec.session.parse_mcp_tool_name(&req.tool_name).await {
crate::tools::context::ToolPayload::Mcp {
server,
tool,
raw_arguments: req.arguments.clone(),
}
} else if is_freeform_tool(&router.specs(), &req.tool_name) {
crate::tools::context::ToolPayload::Custom {
input: req.arguments.clone(),
}
} else {
crate::tools::context::ToolPayload::Function {
arguments: req.arguments.clone(),
}
};
let payload = if let Some((server, tool)) = exec
.session
.parse_mcp_tool_name(&req.tool_name, &None)
.await
{
crate::tools::context::ToolPayload::Mcp {
server,
tool,
raw_arguments: req.arguments.clone(),
}
} else if is_freeform_tool(&router.specs(), &req.tool_name) {
crate::tools::context::ToolPayload::Custom {
input: req.arguments.clone(),
}
} else {
crate::tools::context::ToolPayload::Function {
arguments: req.arguments.clone(),
}
};
let tool_name = req.tool_name.clone();
let call = crate::tools::router::ToolCall {
tool_name: tool_name.clone(),
tool_namespace: None,
call_id: req.id.clone(),
payload,
};
@@ -1469,7 +1492,7 @@ fn emitted_image_content_item(
) -> FunctionCallOutputContentItem {
FunctionCallOutputContentItem::InputImage {
image_url,
detail: detail.or_else(|| default_output_image_detail_for_turn(turn)),
detail: normalize_output_image_detail(turn.features.get(), &turn.model_info, detail),
}
}
@@ -1484,12 +1507,6 @@ fn validate_emitted_image_url(image_url: &str) -> Result<(), String> {
}
}
fn default_output_image_detail_for_turn(turn: &TurnContext) -> Option<ImageDetail> {
(turn.config.features.enabled(Feature::ImageDetailOriginal)
&& turn.model_info.supports_image_detail_original)
.then_some(ImageDetail::Original)
}
fn build_exec_result_content_items(
output: String,
content_items: Vec<FunctionCallOutputContentItem>,
@@ -1995,7 +2012,7 @@ mod tests {
}
#[tokio::test]
async fn emitted_image_content_item_preserves_explicit_detail() {
async fn emitted_image_content_item_drops_unsupported_explicit_detail() {
let (_session, turn) = make_session_and_context().await;
let content_item = emitted_image_content_item(
&turn,
@@ -2006,23 +2023,53 @@ mod tests {
content_item,
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: Some(ImageDetail::Low),
detail: None,
}
);
}
#[tokio::test]
async fn emitted_image_content_item_uses_turn_original_detail_when_enabled() {
async fn emitted_image_content_item_does_not_force_original_when_enabled() {
let (_session, mut turn) = make_session_and_context().await;
Arc::make_mut(&mut turn.config)
.features
.enable(Feature::ImageDetailOriginal)
.expect("test config should allow feature update");
turn.features
.enable(Feature::ImageDetailOriginal)
.expect("test turn features should allow feature update");
turn.model_info.supports_image_detail_original = true;
let content_item =
emitted_image_content_item(&turn, "data:image/png;base64,AAA".to_string(), None);
assert_eq!(
content_item,
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: None,
}
);
}
#[tokio::test]
async fn emitted_image_content_item_allows_explicit_original_detail_when_enabled() {
let (_session, mut turn) = make_session_and_context().await;
Arc::make_mut(&mut turn.config)
.features
.enable(Feature::ImageDetailOriginal)
.expect("test config should allow feature update");
turn.features
.enable(Feature::ImageDetailOriginal)
.expect("test turn features should allow feature update");
turn.model_info.supports_image_detail_original = true;
let content_item = emitted_image_content_item(
&turn,
"data:image/png;base64,AAA".to_string(),
Some(ImageDetail::Original),
);
assert_eq!(
content_item,
FunctionCallOutputContentItem::InputImage {
@@ -2032,6 +2079,25 @@ mod tests {
);
}
#[tokio::test]
async fn emitted_image_content_item_drops_explicit_original_detail_when_disabled() {
let (_session, turn) = make_session_and_context().await;
let content_item = emitted_image_content_item(
&turn,
"data:image/png;base64,AAA".to_string(),
Some(ImageDetail::Original),
);
assert_eq!(
content_item,
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
detail: None,
}
);
}
#[test]
fn validate_emitted_image_url_accepts_case_insensitive_data_scheme() {
assert_eq!(
@@ -3075,7 +3141,63 @@ await codex.emitImage({ bytes: png, mimeType: "image/png", detail: "ultra" });
)
.await
.expect_err("invalid detail should fail");
assert!(err.to_string().contains("expected detail to be one of"));
assert!(
err.to_string()
.contains("only supports detail \"original\"")
);
assert!(session.get_pending_input().await.is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_emit_image_treats_null_detail_as_omitted() -> anyhow::Result<()> {
if !can_run_js_repl_runtime_tests().await {
return Ok(());
}
let (session, turn) = make_session_and_context().await;
if !turn
.model_info
.input_modalities
.contains(&InputModality::Image)
{
return Ok(());
}
let session = Arc::new(session);
let turn = Arc::new(turn);
*session.active_turn.lock().await = Some(crate::state::ActiveTurn::default());
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default()));
let manager = turn.js_repl.manager().await?;
let code = r#"
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==",
"base64"
);
await codex.emitImage({ bytes: png, mimeType: "image/png", detail: null });
"#;
let result = manager
.execute(
Arc::clone(&session),
turn,
tracker,
JsReplArgs {
code: code.to_string(),
timeout_ms: Some(15_000),
},
)
.await?;
assert_eq!(
result.content_items.as_slice(),
[FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(),
detail: None,
}]
.as_slice()
);
assert!(session.get_pending_input().await.is_empty());
Ok(())
@@ -3446,13 +3568,22 @@ await codex.emitImage(out);
}
let cwd_dir = tempdir()?;
let expected_home_dir = serde_json::to_string("/tmp/codex-home")?;
write_js_repl_test_module(
cwd_dir.path(),
"globals.js",
"console.log(codex.tmpDir === tmpDir);\nconsole.log(typeof codex.tool);\nconsole.log(\"local-file-console-ok\");\n",
&format!(
"const expectedHomeDir = {expected_home_dir};\nconsole.log(`tmp:${{codex.tmpDir === tmpDir}}`);\nconsole.log(`cwd:${{typeof codex.cwd}}:${{codex.cwd.length > 0}}`);\nconsole.log(`home:${{codex.homeDir === expectedHomeDir}}`);\nconsole.log(`tool:${{typeof codex.tool}}`);\nconsole.log(\"local-file-console-ok\");\n"
),
)?;
let (session, mut turn) = make_session_and_context().await;
session
.set_dependency_env(HashMap::from([(
"HOME".to_string(),
"/tmp/codex-home".to_string(),
)]))
.await;
turn.shell_environment_policy
.r#set
.remove("CODEX_JS_REPL_NODE_MODULE_DIRS");
@@ -3478,8 +3609,10 @@ await codex.emitImage(out);
},
)
.await?;
assert!(result.output.contains("true"));
assert!(result.output.contains("function"));
assert!(result.output.contains("tmp:true"));
assert!(result.output.contains("cwd:string:true"));
assert!(result.output.contains("home:true"));
assert!(result.output.contains("tool:function"));
assert!(result.output.contains("local-file-console-ok"));
Ok(())
}
+6
View File
@@ -122,6 +122,12 @@ impl ToolCallRuntime {
..Default::default()
},
},
ToolPayload::ToolSearch { .. } => ResponseInputItem::ToolSearchOutput {
call_id: call.call_id.clone(),
status: "completed".to_string(),
execution: "client".to_string(),
tools: Vec::new(),
},
ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput {
call_id: call.call_id.clone(),
output: codex_protocol::mcp::CallToolResult::from_error_text(Self::abort_message(
+95 -6
View File
@@ -39,6 +39,7 @@ pub trait ToolHandler: Send + Sync {
matches!(
(self.kind(), payload),
(ToolKind::Function, ToolPayload::Function { .. })
| (ToolKind::Function, ToolPayload::ToolSearch { .. })
| (ToolKind::Mcp, ToolPayload::Mcp { .. })
)
}
@@ -120,6 +121,14 @@ where
}
}
pub(crate) fn tool_handler_key(tool_name: &str, namespace: Option<&str>) -> String {
if let Some(namespace) = namespace {
format!("{namespace}:{tool_name}")
} else {
tool_name.to_string()
}
}
pub struct ToolRegistry {
handlers: HashMap<String, Arc<dyn AnyToolHandler>>,
}
@@ -129,8 +138,15 @@ impl ToolRegistry {
Self { handlers }
}
fn handler(&self, name: &str) -> Option<Arc<dyn AnyToolHandler>> {
self.handlers.get(name).map(Arc::clone)
fn handler(&self, name: &str, namespace: Option<&str>) -> Option<Arc<dyn AnyToolHandler>> {
self.handlers
.get(&tool_handler_key(name, namespace))
.map(Arc::clone)
}
#[cfg(test)]
pub(crate) fn has_handler(&self, name: &str, namespace: Option<&str>) -> bool {
self.handler(name, namespace).is_some()
}
// TODO(jif) for dynamic tools.
@@ -146,6 +162,7 @@ impl ToolRegistry {
invocation: ToolInvocation,
) -> Result<AnyToolResult, FunctionCallError> {
let tool_name = invocation.tool_name.clone();
let tool_namespace = invocation.tool_namespace.clone();
let call_id_owned = invocation.call_id.clone();
let otel = invocation.turn.session_telemetry.clone();
let payload_for_response = invocation.payload.clone();
@@ -191,11 +208,14 @@ impl ToolRegistry {
}
}
let handler = match self.handler(tool_name.as_ref()) {
let handler = match self.handler(tool_name.as_ref(), tool_namespace.as_deref()) {
Some(handler) => handler,
None => {
let message =
unsupported_tool_call_message(&invocation.payload, tool_name.as_ref());
let message = unsupported_tool_call_message(
&invocation.payload,
tool_name.as_ref(),
tool_namespace.as_deref(),
);
otel.tool_result_with_tags(
tool_name.as_ref(),
&call_id_owned,
@@ -372,7 +392,12 @@ impl ToolRegistryBuilder {
}
}
fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &str) -> String {
fn unsupported_tool_call_message(
payload: &ToolPayload,
tool_name: &str,
namespace: Option<&str>,
) -> String {
let tool_name = tool_handler_key(tool_name, namespace);
match payload {
ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"),
_ => format!("unsupported call: {tool_name}"),
@@ -396,6 +421,13 @@ impl From<&ToolPayload> for HookToolInput {
ToolPayload::Function { arguments } => HookToolInput::Function {
arguments: arguments.clone(),
},
ToolPayload::ToolSearch { arguments } => HookToolInput::Function {
arguments: serde_json::json!({
"query": arguments.query,
"limit": arguments.limit,
})
.to_string(),
},
ToolPayload::Custom { input } => HookToolInput::Custom {
input: input.clone(),
},
@@ -476,3 +508,60 @@ async fn dispatch_after_tool_use_hook(dispatch: AfterToolUseHookDispatch<'_>) {
})
.await;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::context::ToolInvocation;
use async_trait::async_trait;
use pretty_assertions::assert_eq;
struct TestHandler;
#[async_trait]
impl ToolHandler for TestHandler {
type Output = crate::tools::context::FunctionToolOutput;
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(
&self,
_invocation: ToolInvocation,
) -> Result<Self::Output, FunctionCallError> {
unreachable!("test handler should not be invoked")
}
}
#[test]
fn handler_looks_up_namespaced_aliases_explicitly() {
let plain_handler = Arc::new(TestHandler) as Arc<dyn AnyToolHandler>;
let namespaced_handler = Arc::new(TestHandler) as Arc<dyn AnyToolHandler>;
let namespace = "mcp__codex_apps__gmail";
let tool_name = "gmail_get_recent_emails";
let namespaced_name = tool_handler_key(tool_name, Some(namespace));
let registry = ToolRegistry::new(HashMap::from([
(tool_name.to_string(), Arc::clone(&plain_handler)),
(namespaced_name, Arc::clone(&namespaced_handler)),
]));
let plain = registry.handler(tool_name, None);
let namespaced = registry.handler(tool_name, Some(namespace));
let missing_namespaced = registry.handler(tool_name, Some("mcp__codex_apps__calendar"));
assert_eq!(plain.is_some(), true);
assert_eq!(namespaced.is_some(), true);
assert_eq!(missing_namespaced.is_none(), true);
assert!(
plain
.as_ref()
.is_some_and(|handler| Arc::ptr_eq(handler, &plain_handler))
);
assert!(
namespaced
.as_ref()
.is_some_and(|handler| Arc::ptr_eq(handler, &namespaced_handler))
);
}
}
+85 -2
View File
@@ -8,6 +8,7 @@ use crate::tools::context::FunctionToolOutput;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::ToolSearchOutput;
use crate::tools::registry::AnyToolResult;
use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::registry::ToolRegistry;
@@ -17,6 +18,7 @@ use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::models::SearchToolCallParams;
use codex_protocol::models::ShellToolCallParams;
use rmcp::model::Tool;
use std::collections::HashMap;
@@ -28,6 +30,7 @@ pub use crate::tools::context::ToolCallSource;
#[derive(Clone, Debug)]
pub struct ToolCall {
pub tool_name: String,
pub tool_namespace: Option<String>,
pub call_id: String,
pub payload: ToolPayload,
}
@@ -72,13 +75,15 @@ impl ToolRouter {
match item {
ResponseItem::FunctionCall {
name,
namespace,
arguments,
call_id,
..
} => {
if let Some((server, tool)) = session.parse_mcp_tool_name(&name).await {
if let Some((server, tool)) = session.parse_mcp_tool_name(&name, &namespace).await {
Ok(Some(ToolCall {
tool_name: name,
tool_namespace: namespace,
call_id,
payload: ToolPayload::Mcp {
server,
@@ -89,11 +94,32 @@ impl ToolRouter {
} else {
Ok(Some(ToolCall {
tool_name: name,
tool_namespace: namespace,
call_id,
payload: ToolPayload::Function { arguments },
}))
}
}
ResponseItem::ToolSearchCall {
call_id: Some(call_id),
execution,
arguments,
..
} if execution == "client" => {
let arguments: SearchToolCallParams =
serde_json::from_value(arguments).map_err(|err| {
FunctionCallError::RespondToModel(format!(
"failed to parse tool_search arguments: {err}"
))
})?;
Ok(Some(ToolCall {
tool_name: "tool_search".to_string(),
tool_namespace: None,
call_id,
payload: ToolPayload::ToolSearch { arguments },
}))
}
ResponseItem::ToolSearchCall { .. } => Ok(None),
ResponseItem::CustomToolCall {
name,
input,
@@ -101,6 +127,7 @@ impl ToolRouter {
..
} => Ok(Some(ToolCall {
tool_name: name,
tool_namespace: None,
call_id,
payload: ToolPayload::Custom { input },
})),
@@ -127,6 +154,7 @@ impl ToolRouter {
};
Ok(Some(ToolCall {
tool_name: "local_shell".to_string(),
tool_namespace: None,
call_id,
payload: ToolPayload::LocalShell { params },
}))
@@ -163,10 +191,12 @@ impl ToolRouter {
) -> Result<AnyToolResult, FunctionCallError> {
let ToolCall {
tool_name,
tool_namespace,
call_id,
payload,
} = call;
let payload_outputs_custom = matches!(payload, ToolPayload::Custom { .. });
let payload_outputs_tool_search = matches!(payload, ToolPayload::ToolSearch { .. });
let failure_call_id = call_id.clone();
if source == ToolCallSource::Direct
@@ -180,6 +210,7 @@ impl ToolRouter {
return Ok(Self::failure_result(
failure_call_id,
payload_outputs_custom,
payload_outputs_tool_search,
err,
));
}
@@ -190,6 +221,7 @@ impl ToolRouter {
tracker,
call_id,
tool_name,
tool_namespace,
payload,
};
@@ -199,6 +231,7 @@ impl ToolRouter {
Err(err) => Ok(Self::failure_result(
failure_call_id,
payload_outputs_custom,
payload_outputs_tool_search,
err,
)),
}
@@ -207,10 +240,22 @@ impl ToolRouter {
fn failure_result(
call_id: String,
payload_outputs_custom: bool,
payload_outputs_tool_search: bool,
err: FunctionCallError,
) -> AnyToolResult {
let message = err.to_string();
if payload_outputs_custom {
if payload_outputs_tool_search {
AnyToolResult {
call_id,
payload: ToolPayload::ToolSearch {
arguments: SearchToolCallParams {
query: String::new(),
limit: None,
},
},
result: Box::new(ToolSearchOutput { tools: Vec::new() }),
}
} else if payload_outputs_custom {
AnyToolResult {
call_id,
payload: ToolPayload::Custom {
@@ -237,6 +282,7 @@ mod tests {
use crate::tools::context::ToolPayload;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use super::ToolCall;
use super::ToolCallSource;
@@ -271,6 +317,7 @@ mod tests {
let call = ToolCall {
tool_name: "shell".to_string(),
tool_namespace: None,
call_id: "call-1".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
@@ -324,6 +371,7 @@ mod tests {
let call = ToolCall {
tool_name: "shell".to_string(),
tool_namespace: None,
call_id: "call-2".to_string(),
payload: ToolPayload::Function {
arguments: "{}".to_string(),
@@ -347,4 +395,39 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<()> {
let (session, _) = make_session_and_context().await;
let session = Arc::new(session);
let tool_name = "create_event".to_string();
let call = ToolRouter::build_tool_call(
&session,
ResponseItem::FunctionCall {
id: None,
name: tool_name.clone(),
namespace: Some("mcp__codex_apps__calendar".to_string()),
arguments: "{}".to_string(),
call_id: "call-namespace".to_string(),
},
)
.await?
.expect("function_call should produce a tool call");
assert_eq!(call.tool_name, tool_name);
assert_eq!(
call.tool_namespace,
Some("mcp__codex_apps__calendar".to_string())
);
assert_eq!(call.call_id, "call-namespace");
match call.payload {
ToolPayload::Function { arguments } => {
assert_eq!(arguments, "{}");
}
other => panic!("expected function payload, got {other:?}"),
}
Ok(())
}
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -141,12 +141,14 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool {
ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::ToolSearchCall { .. }
| ResponseItem::WebSearchCall { .. }
| ResponseItem::ImageGenerationCall { .. }
| ResponseItem::GhostSnapshot { .. }
| ResponseItem::Compaction { .. } => true,
ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::ToolSearchOutput { .. }
| ResponseItem::Other => false,
}
}
@@ -237,6 +239,7 @@ mod tests {
&ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
namespace: None,
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
}
@@ -2,28 +2,4 @@
Searches over apps tool metadata with BM25 and exposes matching tools for the next model call.
When `search_tool_bm25` is available, apps tools (`mcp__codex_apps__...`) are hidden until you search for them.
Follow this workflow:
1. Call `search_tool_bm25` with:
- `query` (required): focused terms that describe the capability you need.
- `limit` (optional): maximum number of tools to return (default `8`).
2. Use the returned `tools` list to decide which Apps tools are relevant.
3. Matching tools are added to available `tools` and available for the remainder of the current turn.
4. Repeated searches in the same turn are additive: new matches are unioned into `tools`.
Notes:
- Core tools remain available without searching.
- If you are unsure, start with `limit` between 5 and 10 to see a broader set of tools.
- `query` is matched against Apps tool metadata fields:
- `name`
- `tool_name`
- `server_name`
- `title`
- `description`
- `connector_name`
- `connector_id`
- input schema property keys (`input_keys`)
- If the needed app is already explicit in the prompt (for example `[$app-name](app://{connector_id})`) or already present in the current `tools` list, you can call that tool directly.
- Do not use `search_tool_bm25` for non-apps/local tasks (filesystem, repo search, or shell-only workflows) or anything not related to {{app_names}}.
Tools of the apps ({{app_names}}) are hidden until you search for them with this tool (`tool_search`).
+48 -5
View File
@@ -12,6 +12,7 @@ use wiremock::matchers::path_regex;
const CONNECTOR_ID: &str = "calendar";
const CONNECTOR_NAME: &str = "Calendar";
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 +32,12 @@ impl AppsTestServer {
connector_name: &str,
) -> Result<Self> {
mount_oauth_metadata(server).await;
mount_streamable_http_json_rpc(server, connector_name.to_string()).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 +56,24 @@ async fn mount_oauth_metadata(server: &MockServer) {
.await;
}
async fn mount_streamable_http_json_rpc(server: &MockServer, connector_name: String) {
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 +140,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 +157,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 +166,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);
+50 -2
View File
@@ -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()
@@ -774,6 +778,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",
@@ -1484,11 +1500,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") {
@@ -1538,7 +1556,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(
@@ -1546,6 +1581,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",
@@ -1564,6 +1600,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!(
@@ -1577,4 +1619,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}",
);
}
}
+2
View File
@@ -515,6 +515,7 @@ async fn resume_replays_image_tool_outputs_with_detail() {
item: RolloutItem::ResponseItem(ResponseItem::FunctionCall {
id: None,
name: "view_image".to_string(),
namespace: None,
arguments: "{\"path\":\"/tmp/example.webp\"}".to_string(),
call_id: function_call_id.to_string(),
}),
@@ -1878,6 +1879,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
prompt.input.push(ResponseItem::FunctionCall {
id: Some("function-id".into()),
name: "do_thing".into(),
namespace: None,
arguments: "{}".into(),
call_id: "function-call-id".into(),
});
+52 -16
View File
@@ -495,38 +495,74 @@ contentLength=0"
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_access_namespaced_mcp_tool_from_flat_tools_namespace() -> Result<()> {
async fn code_mode_exports_all_tools_metadata_for_builtin_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let code = r#"
import { tools } from "tools.js";
import { ALL_TOOLS } from "tools.js";
const { structuredContent, isError } = await tools["mcp__rmcp__echo"]({
message: "ping",
});
add_content(
`echo=${structuredContent?.echo ?? "missing"}\n` +
`env=${structuredContent?.env ?? "missing"}\n` +
`isError=${String(isError)}`
);
const tool = ALL_TOOLS.find(({ module, name }) => module === "tools.js" && name === "view_image");
add_content(JSON.stringify(tool));
"#;
let (_test, second_mock) =
run_code_mode_turn_with_rmcp(&server, "use exec to run the rmcp echo tool", code).await?;
run_code_mode_turn(&server, "use exec to inspect ALL_TOOLS", code, false).await?;
let req = second_mock.single_request();
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"exec rmcp echo call failed unexpectedly: {output}"
"exec ALL_TOOLS lookup failed unexpectedly: {output}"
);
let parsed: Value = serde_json::from_str(&output)?;
assert_eq!(
output,
"echo=ECHOING: ping
env=propagated-env
isError=false"
parsed,
serde_json::json!({
"module": "tools.js",
"name": "view_image",
"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\nCode mode declaration:\n```ts\nimport { view_image } from \"tools.js\";\ndeclare function view_image(args: {\n path: string;\n}): Promise<unknown>;\n```",
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_exports_all_tools_metadata_for_namespaced_mcp_tools() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let code = r#"
import { ALL_TOOLS } from "tools.js";
const tool = ALL_TOOLS.find(
({ module, name }) => module === "tools/mcp/rmcp.js" && name === "echo"
);
add_content(JSON.stringify(tool));
"#;
let (_test, second_mock) =
run_code_mode_turn_with_rmcp(&server, "use exec to inspect ALL_TOOLS", code).await?;
let req = second_mock.single_request();
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"exec ALL_TOOLS MCP lookup failed unexpectedly: {output}"
);
let parsed: Value = serde_json::from_str(&output)?;
assert_eq!(
parsed,
serde_json::json!({
"module": "tools/mcp/rmcp.js",
"name": "echo",
"description": "Echo back the provided message and include environment data.\n\nCode mode declaration:\n```ts\nimport { echo } from \"tools/mcp/rmcp.js\";\ndeclare function echo(args: {\n env_var?: string;\n message: string;\n}): Promise<{\n _meta?: unknown;\n content: Array<unknown>;\n isError?: boolean;\n structuredContent?: unknown;\n}>;\n```",
})
);
Ok(())
@@ -39,17 +39,11 @@ fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMod
fn developer_texts(input: &[Value]) -> Vec<String> {
input
.iter()
.filter_map(|item| {
let role = item.get("role")?.as_str()?;
if role != "developer" {
return None;
}
let text = item
.get("content")?
.as_array()?
.first()?
.get("text")?
.as_str()?;
.filter(|item| item.get("role").and_then(Value::as_str) == Some("developer"))
.filter_map(|item| item.get("content")?.as_array().cloned())
.flatten()
.filter_map(|content| {
let text = content.get("text")?.as_str()?;
Some(text.to_string())
})
.collect()
@@ -59,8 +53,8 @@ fn collab_xml(text: &str) -> String {
format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}")
}
fn count_exact(texts: &[String], target: &str) -> usize {
texts.iter().filter(|text| text.as_str() == target).count()
fn count_messages_containing(texts: &[String], target: &str) -> usize {
texts.iter().filter(|text| text.contains(target)).count()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -139,7 +133,7 @@ async fn user_input_includes_collaboration_instructions_after_override() -> Resu
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &collab_text), 1);
Ok(())
}
@@ -186,7 +180,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> {
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &collab_text), 1);
Ok(())
}
@@ -235,7 +229,7 @@ async fn override_then_next_turn_uses_updated_collaboration_instructions() -> Re
let input = req.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &collab_text), 1);
Ok(())
}
@@ -300,8 +294,8 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu
let dev_texts = developer_texts(&input);
let base_text = collab_xml(base_text);
let turn_text = collab_xml(turn_text);
assert_eq!(count_exact(&dev_texts, &base_text), 0);
assert_eq!(count_exact(&dev_texts, &turn_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &base_text), 0);
assert_eq!(count_messages_containing(&dev_texts, &turn_text), 1);
Ok(())
}
@@ -382,8 +376,8 @@ async fn collaboration_mode_update_emits_new_instruction_message() -> Result<()>
let dev_texts = developer_texts(&input);
let first_text = collab_xml(first_text);
let second_text = collab_xml(second_text);
assert_eq!(count_exact(&dev_texts, &first_text), 1);
assert_eq!(count_exact(&dev_texts, &second_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &first_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &second_text), 1);
Ok(())
}
@@ -462,7 +456,7 @@ async fn collaboration_mode_update_noop_does_not_append() -> Result<()> {
let input = req2.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &collab_text), 1);
Ok(())
}
@@ -549,8 +543,8 @@ async fn collaboration_mode_update_emits_new_instruction_message_when_mode_chang
let dev_texts = developer_texts(&input);
let default_text = collab_xml(default_text);
let plan_text = collab_xml(plan_text);
assert_eq!(count_exact(&dev_texts, &default_text), 1);
assert_eq!(count_exact(&dev_texts, &plan_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &default_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &plan_text), 1);
Ok(())
}
@@ -635,7 +629,7 @@ async fn collaboration_mode_update_noop_does_not_append_when_mode_is_unchanged()
let input = req2.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &collab_text), 1);
Ok(())
}
@@ -710,7 +704,7 @@ async fn resume_replays_collaboration_instructions() -> Result<()> {
let input = req2.single_request().input();
let dev_texts = developer_texts(&input);
let collab_text = collab_xml(collab_text);
assert_eq!(count_exact(&dev_texts, &collab_text), 1);
assert_eq!(count_messages_containing(&dev_texts, &collab_text), 1);
Ok(())
}
@@ -766,7 +760,7 @@ async fn empty_collaboration_instructions_are_ignored() -> Result<()> {
let dev_texts = developer_texts(&input);
assert_eq!(dev_texts.len(), 1);
let collab_text = collab_xml("");
assert_eq!(count_exact(&dev_texts, &collab_text), 0);
assert_eq!(count_messages_containing(&dev_texts, &collab_text), 0);
Ok(())
}
+4 -9
View File
@@ -2541,7 +2541,6 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_multi_summary_reinjec
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
// TODO(ccunningham): Update once manual remote /compact with no prior user turn becomes a no-op.
async fn snapshot_request_shape_remote_manual_compact_without_previous_user_messages() -> Result<()>
{
skip_if_no_network!(Ok(()));
@@ -2581,19 +2580,15 @@ async fn snapshot_request_shape_remote_manual_compact_without_previous_user_mess
assert_eq!(
compact_mock.requests().len(),
1,
"current behavior still issues remote compaction for manual /compact without prior user"
0,
"manual /compact without prior user should not issue a remote compaction request"
);
let compact_request = compact_mock.single_request();
let follow_up_request = responses_mock.single_request();
insta::assert_snapshot!(
"remote_manual_compact_without_prev_user_shapes",
format_labeled_requests_snapshot(
"Remote manual /compact with no prior user turn still issues a compact request; follow-up turn carries canonical context and new user message.",
&[
("Remote Compaction Request", &compact_request),
("Remote Post-Compaction History Layout", &follow_up_request),
]
"Remote manual /compact with no prior user turn skips the remote compact request; the follow-up turn carries canonical context and new user message.",
&[("Remote Post-Compaction History Layout", &follow_up_request)]
)
);
+28
View File
@@ -659,6 +659,34 @@ async fn js_repl_does_not_expose_process_global() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_exposes_codex_path_helpers() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let mock = run_js_repl_turn(
&server,
"check codex path helpers",
&[(
"call-1",
"console.log(`cwd:${typeof codex.cwd}:${codex.cwd.length > 0}`); console.log(`home:${codex.homeDir === null || typeof codex.homeDir === \"string\"}`);",
)],
)
.await?;
let req = mock.single_request();
let (output, success) = custom_tool_output_text_and_success(&req, "call-1");
assert_ne!(
success,
Some(false),
"js_repl call failed unexpectedly: {output}"
);
assert!(output.contains("cwd:string:true"));
assert!(output.contains("home:true"));
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn js_repl_blocks_sensitive_builtin_imports() -> Result<()> {
skip_if_no_network!(Ok(()));
+1
View File
@@ -121,6 +121,7 @@ mod shell_serialization;
mod shell_snapshot;
mod skill_approval;
mod skills;
mod spawn_agent_description;
mod sqlite_state;
mod stream_error_allows_next_turn;
mod stream_no_completed;
+1 -1
View File
@@ -865,7 +865,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
let developer_texts = request.message_input_texts("developer");
let personality_text = developer_texts
.iter()
.find(|text| text.contains("<personality_spec>"))
.find(|text| text.contains(remote_friendly_message))
.expect("expected personality update message in developer input");
assert!(
+6 -4
View File
@@ -277,7 +277,7 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
assert!(
request_tools
.iter()
.any(|name| name == "mcp__codex_apps__calendar_create_event"),
.any(|name| name == "mcp__codex_apps__google-calendar-create-event"),
"expected plugin app tools to become visible for this turn: {request_tools:?}"
);
let echo_description = tool_description(&request_body, "mcp__sample__echo")
@@ -286,9 +286,11 @@ async fn explicit_plugin_mentions_inject_plugin_guidance() -> Result<()> {
echo_description.contains("This tool is part of plugin `sample`."),
"expected plugin MCP provenance in tool description: {echo_description:?}"
);
let calendar_description =
tool_description(&request_body, "mcp__codex_apps__calendar_create_event")
.expect("plugin app tool description should be present");
let calendar_description = tool_description(
&request_body,
"mcp__codex_apps__google-calendar-create-event",
)
.expect("plugin app tool description should be present");
assert!(
calendar_description.contains("This tool is part of plugin `sample`."),
"expected plugin app provenance in tool description: {calendar_description:?}"
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,12 @@
---
source: core/tests/suite/compact.rs
assertion_line: 3343
expression: "format_labeled_requests_snapshot(\"Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.\",\n&[(\"Local Compaction Request\", &requests[0]),\n(\"Local Post-Compaction History Layout\", &requests[1]),])"
---
Scenario: Manual /compact with no prior user turn currently still issues a compaction request; follow-up turn carries canonical context and the new user message.
## Local Compaction Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user[2]:
[01] <AGENTS_MD>
[02] <ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/user:<SUMMARIZATION_PROMPT>
00:message/user:<SUMMARIZATION_PROMPT>
## Local Post-Compaction History Layout
00:message/user:<COMPACTION_SUMMARY>\nMANUAL_EMPTY_SUMMARY
@@ -1,6 +1,6 @@
---
source: core/tests/suite/compact.rs
assertion_line: 1791
assertion_line: 1799
expression: "format_labeled_requests_snapshot(\"Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.\",\n&[(\"Initial Request (Previous Model)\", &requests[0]),\n(\"Pre-sampling Compaction Request\", &requests[1]),\n(\"Post-Compaction Follow-up Request (Next Model)\", &requests[2]),])"
---
Scenario: Pre-sampling compaction on model switch to a smaller context window: current behavior compacts using prior-turn history only (incoming user message excluded), and the follow-up request carries compacted history plus the new user message.
@@ -10,18 +10,16 @@ Scenario: Pre-sampling compaction on model switch to a smaller context window: c
01:message/user[2]:
[01] <AGENTS_MD>
[02] <ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
03:message/user:before switch
02:message/user:before switch
## Pre-sampling Compaction Request
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user[2]:
[01] <AGENTS_MD>
[02] <ENVIRONMENT_CONTEXT:cwd=<CWD>>
02:message/developer:<PERMISSIONS_INSTRUCTIONS>
03:message/user:before switch
04:message/assistant:before switch
05:message/user:<SUMMARIZATION_PROMPT>
02:message/user:before switch
03:message/assistant:before switch
04:message/user:<SUMMARIZATION_PROMPT>
## Post-Compaction Follow-up Request (Next Model)
00:message/user:before switch

Some files were not shown because too many files have changed in this diff Show More