Merge eedb6e3f28 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-02-27 10:38:03 -08:00
committed by GitHub
106 changed files with 3773 additions and 431 deletions
+1
View File
@@ -1900,6 +1900,7 @@ version = "0.0.0"
dependencies = [
"anyhow",
"clap",
"codex-utils-absolute-path",
"multimap",
"pretty_assertions",
"serde",
@@ -1340,7 +1340,7 @@
"type": "string"
},
"output": {
"type": "string"
"$ref": "#/definitions/FunctionCallOutputPayload"
},
"type": {
"enum": [
@@ -4822,7 +4822,7 @@
"type": "string"
},
"output": {
"type": "string"
"$ref": "#/definitions/FunctionCallOutputPayload"
},
"type": {
"enum": [
@@ -10225,6 +10225,16 @@
},
"Model": {
"properties": {
"availabilityNux": {
"anyOf": [
{
"$ref": "#/definitions/v2/ModelAvailabilityNux"
},
{
"type": "null"
}
]
},
"defaultReasoningEffort": {
"$ref": "#/definitions/v2/ReasoningEffort"
},
@@ -10271,6 +10281,16 @@
"string",
"null"
]
},
"upgradeInfo": {
"anyOf": [
{
"$ref": "#/definitions/v2/ModelUpgradeInfo"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -10285,6 +10305,17 @@
],
"type": "object"
},
"ModelAvailabilityNux": {
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
],
"type": "object"
},
"ModelListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -10373,6 +10404,35 @@
"title": "ModelReroutedNotification",
"type": "object"
},
"ModelUpgradeInfo": {
"properties": {
"migrationMarkdown": {
"type": [
"string",
"null"
]
},
"model": {
"type": "string"
},
"modelLink": {
"type": [
"string",
"null"
]
},
"upgradeCopy": {
"type": [
"string",
"null"
]
}
},
"required": [
"model"
],
"type": "object"
},
"NetworkAccess": {
"enum": [
"restricted",
@@ -11382,7 +11442,7 @@
"type": "string"
},
"output": {
"type": "string"
"$ref": "#/definitions/v2/FunctionCallOutputPayload"
},
"type": {
"enum": [
@@ -22,6 +22,16 @@
},
"Model": {
"properties": {
"availabilityNux": {
"anyOf": [
{
"$ref": "#/definitions/ModelAvailabilityNux"
},
{
"type": "null"
}
]
},
"defaultReasoningEffort": {
"$ref": "#/definitions/ReasoningEffort"
},
@@ -68,6 +78,16 @@
"string",
"null"
]
},
"upgradeInfo": {
"anyOf": [
{
"$ref": "#/definitions/ModelUpgradeInfo"
},
{
"type": "null"
}
]
}
},
"required": [
@@ -82,6 +102,46 @@
],
"type": "object"
},
"ModelAvailabilityNux": {
"properties": {
"message": {
"type": "string"
}
},
"required": [
"message"
],
"type": "object"
},
"ModelUpgradeInfo": {
"properties": {
"migrationMarkdown": {
"type": [
"string",
"null"
]
},
"model": {
"type": "string"
},
"modelLink": {
"type": [
"string",
"null"
]
},
"upgradeCopy": {
"type": [
"string",
"null"
]
}
},
"required": [
"model"
],
"type": "object"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -565,7 +565,7 @@
"type": "string"
},
"output": {
"type": "string"
"$ref": "#/definitions/FunctionCallOutputPayload"
},
"type": {
"enum": [
@@ -615,7 +615,7 @@
"type": "string"
},
"output": {
"type": "string"
"$ref": "#/definitions/FunctionCallOutputPayload"
},
"type": {
"enum": [
@@ -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: string, } | { "type": "web_search_call", status?: string, action?: WebSearchAction, } | { "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, 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": "ghost_snapshot", ghost_commit: GhostCommit, } | { "type": "compaction", encrypted_content: string, } | { "type": "other" };
@@ -3,6 +3,8 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { InputModality } from "../InputModality";
import type { ReasoningEffort } from "../ReasoningEffort";
import type { ModelAvailabilityNux } from "./ModelAvailabilityNux";
import type { ModelUpgradeInfo } from "./ModelUpgradeInfo";
import type { ReasoningEffortOption } from "./ReasoningEffortOption";
export type Model = { id: string, model: string, upgrade: string | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array<ReasoningEffortOption>, defaultReasoningEffort: ReasoningEffort, inputModalities: Array<InputModality>, supportsPersonality: boolean, isDefault: boolean, };
export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array<ReasoningEffortOption>, defaultReasoningEffort: ReasoningEffort, inputModalities: Array<InputModality>, supportsPersonality: boolean, isDefault: boolean, };
@@ -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 ModelAvailabilityNux = { message: string, };
@@ -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 ModelUpgradeInfo = { model: string, upgradeCopy: string | null, modelLink: string | null, migrationMarkdown: string | null, };
@@ -107,10 +107,12 @@ export type { McpToolCallResult } from "./McpToolCallResult";
export type { McpToolCallStatus } from "./McpToolCallStatus";
export type { MergeStrategy } from "./MergeStrategy";
export type { Model } from "./Model";
export type { ModelAvailabilityNux } from "./ModelAvailabilityNux";
export type { ModelListParams } from "./ModelListParams";
export type { ModelListResponse } from "./ModelListResponse";
export type { ModelRerouteReason } from "./ModelRerouteReason";
export type { ModelReroutedNotification } from "./ModelReroutedNotification";
export type { ModelUpgradeInfo } from "./ModelUpgradeInfo";
export type { NetworkAccess } from "./NetworkAccess";
export type { NetworkApprovalContext } from "./NetworkApprovalContext";
export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol";
@@ -31,6 +31,7 @@ use codex_protocol::models::MessagePhase;
use codex_protocol::models::PermissionProfile as CorePermissionProfile;
use codex_protocol::models::ResponseItem;
use codex_protocol::openai_models::InputModality;
use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::openai_models::default_input_modalities;
use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand;
@@ -1389,6 +1390,21 @@ pub struct ModelListParams {
pub include_hidden: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelAvailabilityNux {
pub message: String,
}
impl From<CoreModelAvailabilityNux> for ModelAvailabilityNux {
fn from(value: CoreModelAvailabilityNux) -> Self {
Self {
message: value.message,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -1396,6 +1412,8 @@ pub struct Model {
pub id: String,
pub model: String,
pub upgrade: Option<String>,
pub upgrade_info: Option<ModelUpgradeInfo>,
pub availability_nux: Option<ModelAvailabilityNux>,
pub display_name: String,
pub description: String,
pub hidden: bool,
@@ -1409,6 +1427,16 @@ pub struct Model {
pub is_default: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ModelUpgradeInfo {
pub model: String,
pub upgrade_copy: Option<String>,
pub model_link: Option<String>,
pub migration_markdown: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+1 -1
View File
@@ -142,7 +142,7 @@ Example with notification opt-out:
- `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`.
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options and optional `upgrade` model ids.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `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`).
@@ -560,7 +560,12 @@ impl CodexMessageProcessor {
Ok((review_request, hint))
}
pub async fn process_request(&mut self, connection_id: ConnectionId, request: ClientRequest) {
pub async fn process_request(
&mut self,
connection_id: ConnectionId,
request: ClientRequest,
app_server_client_name: Option<String>,
) {
let to_connection_request_id = |request_id| ConnectionRequestId {
connection_id,
request_id,
@@ -647,8 +652,12 @@ impl CodexMessageProcessor {
.await;
}
ClientRequest::TurnStart { request_id, params } => {
self.turn_start(to_connection_request_id(request_id), params)
.await;
self.turn_start(
to_connection_request_id(request_id),
params,
app_server_client_name.clone(),
)
.await;
}
ClientRequest::TurnSteer { request_id, params } => {
self.turn_steer(to_connection_request_id(request_id), params)
@@ -767,12 +776,20 @@ impl CodexMessageProcessor {
.await;
}
ClientRequest::SendUserMessage { request_id, params } => {
self.send_user_message(to_connection_request_id(request_id), params)
.await;
self.send_user_message(
to_connection_request_id(request_id),
params,
app_server_client_name.clone(),
)
.await;
}
ClientRequest::SendUserTurn { request_id, params } => {
self.send_user_turn(to_connection_request_id(request_id), params)
.await;
self.send_user_turn(
to_connection_request_id(request_id),
params,
app_server_client_name.clone(),
)
.await;
}
ClientRequest::InterruptConversation { request_id, params } => {
self.interrupt_conversation(to_connection_request_id(request_id), params)
@@ -4152,6 +4169,7 @@ impl CodexMessageProcessor {
http_headers,
env_http_headers,
scopes.as_deref().unwrap_or_default(),
server.oauth_resource.as_deref(),
timeout_secs,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
@@ -5062,6 +5080,7 @@ impl CodexMessageProcessor {
&self,
request_id: ConnectionRequestId,
params: SendUserMessageParams,
app_server_client_name: Option<String>,
) {
let SendUserMessageParams {
conversation_id,
@@ -5080,6 +5099,12 @@ impl CodexMessageProcessor {
self.outgoing.send_error(request_id, error).await;
return;
};
if let Err(error) =
Self::set_app_server_client_name(conversation.as_ref(), app_server_client_name).await
{
self.outgoing.send_error(request_id, error).await;
return;
}
let mapped_items: Vec<CoreInputItem> = items
.into_iter()
@@ -5110,7 +5135,12 @@ impl CodexMessageProcessor {
.await;
}
async fn send_user_turn(&self, request_id: ConnectionRequestId, params: SendUserTurnParams) {
async fn send_user_turn(
&self,
request_id: ConnectionRequestId,
params: SendUserTurnParams,
app_server_client_name: Option<String>,
) {
let SendUserTurnParams {
conversation_id,
items,
@@ -5136,6 +5166,12 @@ impl CodexMessageProcessor {
self.outgoing.send_error(request_id, error).await;
return;
};
if let Err(error) =
Self::set_app_server_client_name(conversation.as_ref(), app_server_client_name).await
{
self.outgoing.send_error(request_id, error).await;
return;
}
let mapped_items: Vec<CoreInputItem> = items
.into_iter()
@@ -5249,6 +5285,7 @@ impl CodexMessageProcessor {
connectors::list_cached_accessible_connectors_from_mcp_tools(&config),
connectors::list_cached_all_connectors(&config)
);
let cached_all_connectors = all_connectors.clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
@@ -5275,6 +5312,19 @@ impl CodexMessageProcessor {
let app_list_deadline = tokio::time::Instant::now() + APP_LIST_LOAD_TIMEOUT;
let mut accessible_loaded = false;
let mut all_loaded = false;
let mut last_notified_apps = None;
if accessible_connectors.is_some() || all_connectors.is_some() {
let merged = connectors::with_app_enabled_state(
Self::merge_loaded_apps(
all_connectors.as_deref(),
accessible_connectors.as_deref(),
),
&config,
);
Self::send_app_list_updated_notification(&outgoing, merged.clone()).await;
last_notified_apps = Some(merged);
}
loop {
let result = match tokio::time::timeout_at(app_list_deadline, rx.recv()).await {
@@ -5331,14 +5381,30 @@ impl CodexMessageProcessor {
}
}
let showing_interim_force_refetch = force_refetch && !(accessible_loaded && all_loaded);
let all_connectors_for_update =
if showing_interim_force_refetch && cached_all_connectors.is_some() {
cached_all_connectors.as_deref()
} else {
all_connectors.as_deref()
};
let accessible_connectors_for_update =
if showing_interim_force_refetch && !accessible_loaded {
None
} else {
accessible_connectors.as_deref()
};
let merged = connectors::with_app_enabled_state(
Self::merge_loaded_apps(
all_connectors.as_deref(),
accessible_connectors.as_deref(),
all_connectors_for_update,
accessible_connectors_for_update,
),
&config,
);
Self::send_app_list_updated_notification(&outgoing, merged.clone()).await;
if last_notified_apps.as_ref() != Some(&merged) {
Self::send_app_list_updated_notification(&outgoing, merged.clone()).await;
last_notified_apps = Some(merged.clone());
}
if accessible_loaded && all_loaded {
match Self::paginate_apps(merged.as_slice(), start, limit) {
@@ -5607,7 +5673,12 @@ impl CodexMessageProcessor {
let _ = conversation.submit(Op::Interrupt).await;
}
async fn turn_start(&self, request_id: ConnectionRequestId, params: TurnStartParams) {
async fn turn_start(
&self,
request_id: ConnectionRequestId,
params: TurnStartParams,
app_server_client_name: Option<String>,
) {
if let Err(error) = Self::validate_v2_input_limit(&params.input) {
self.outgoing.send_error(request_id, error).await;
return;
@@ -5619,6 +5690,12 @@ impl CodexMessageProcessor {
return;
}
};
if let Err(error) =
Self::set_app_server_client_name(thread.as_ref(), app_server_client_name).await
{
self.outgoing.send_error(request_id, error).await;
return;
}
let collaboration_modes_config = CollaborationModesConfig {
default_mode_request_user_input: thread.enabled(Feature::DefaultModeRequestUserInput),
@@ -5700,6 +5777,20 @@ impl CodexMessageProcessor {
}
}
async fn set_app_server_client_name(
thread: &CodexThread,
app_server_client_name: Option<String>,
) -> Result<(), JSONRPCErrorError> {
thread
.set_app_server_client_name(app_server_client_name)
.await
.map_err(|err| JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to set app server client name: {err}"),
data: None,
})
}
async fn turn_steer(&self, request_id: ConnectionRequestId, params: TurnSteerParams) {
let (_, thread) = match self.load_thread(&params.thread_id).await {
Ok(v) => v,
+3 -1
View File
@@ -140,6 +140,7 @@ pub(crate) struct ConnectionSessionState {
pub(crate) initialized: bool,
pub(crate) experimental_api_enabled: bool,
pub(crate) opted_out_notification_methods: HashSet<String>,
pub(crate) app_server_client_name: Option<String>,
}
pub(crate) struct MessageProcessorArgs {
@@ -329,6 +330,7 @@ impl MessageProcessor {
if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
*suffix = Some(user_agent_suffix);
}
session.app_server_client_name = Some(name.clone());
let user_agent = get_codex_user_agent();
let response = InitializeResponse { user_agent };
@@ -430,7 +432,7 @@ impl MessageProcessor {
// inline the full `CodexMessageProcessor::process_request` future, which
// can otherwise push worker-thread stack usage over the edge.
self.codex_message_processor
.process_request(connection_id, other)
.process_request(connection_id, other, session.app_server_client_name.clone())
.boxed()
.await;
}
+9 -1
View File
@@ -1,6 +1,7 @@
use std::sync::Arc;
use codex_app_server_protocol::Model;
use codex_app_server_protocol::ModelUpgradeInfo;
use codex_app_server_protocol::ReasoningEffortOption;
use codex_core::ThreadManager;
use codex_core::models_manager::manager::RefreshStrategy;
@@ -24,7 +25,14 @@ fn model_from_preset(preset: ModelPreset) -> Model {
Model {
id: preset.id.to_string(),
model: preset.model.to_string(),
upgrade: preset.upgrade.map(|upgrade| upgrade.id),
upgrade: preset.upgrade.as_ref().map(|upgrade| upgrade.id.clone()),
upgrade_info: preset.upgrade.as_ref().map(|upgrade| ModelUpgradeInfo {
model: upgrade.id.clone(),
upgrade_copy: upgrade.upgrade_copy.clone(),
model_link: upgrade.model_link.clone(),
migration_markdown: upgrade.migration_markdown.clone(),
}),
availability_nux: preset.availability_nux.map(Into::into),
display_name: preset.display_name.to_string(),
description: preset.description.to_string(),
hidden: !preset.show_in_picker,
@@ -34,6 +34,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
+37 -2
View File
@@ -994,6 +994,41 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
let first_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(
first_update.data,
vec![
AppInfo {
id: "beta".to_string(),
name: "Beta App".to_string(),
description: Some("Beta v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/beta-app/beta".to_string()),
is_accessible: true,
is_enabled: true,
},
AppInfo {
id: "alpha".to_string(),
name: "Alpha".to_string(),
description: Some("Alpha v1".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()),
is_accessible: false,
is_enabled: true,
},
]
);
let second_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(
second_update.data,
vec![
AppInfo {
id: "alpha".to_string(),
@@ -1040,8 +1075,8 @@ async fn list_apps_force_refetch_patches_updates_from_cached_snapshots() -> Resu
is_accessible: false,
is_enabled: true,
}];
let second_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(second_update.data, expected_final);
let third_update = read_app_list_updated_notification(&mut mcp).await?;
assert_eq!(third_update.data, expected_final);
let refetch_response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
@@ -1,16 +1,24 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::to_response;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use core_test_support::fs_wait;
use pretty_assertions::assert_eq;
use serde_json::Value;
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -178,11 +186,103 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu
Ok(())
}
#[tokio::test]
async fn turn_start_notify_payload_includes_initialize_client_name() -> Result<()> {
let responses = vec![create_final_assistant_message_sse_response("Done")?];
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
let notify_script = codex_home.path().join("notify.py");
std::fs::write(
&notify_script,
r#"from pathlib import Path
import sys
payload_path = Path(__file__).with_name("notify.json")
tmp_path = payload_path.with_suffix(".json.tmp")
tmp_path.write_text(sys.argv[-1], encoding="utf-8")
tmp_path.replace(payload_path)
"#,
)?;
let notify_file = codex_home.path().join("notify.json");
let notify_script = notify_script
.to_str()
.expect("notify script path should be valid UTF-8");
create_config_toml_with_extra(
codex_home.path(),
&server.uri(),
"never",
&format!(
"notify = [\"python3\", {}]",
toml_basic_string(notify_script)
),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_client_info(ClientInfo {
name: "xcode".to_string(),
title: Some("Xcode".to_string()),
version: "1.0.0".to_string(),
}),
)
.await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let _: TurnStartResponse = to_response(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
fs_wait::wait_for_path_exists(&notify_file, Duration::from_secs(5)).await?;
let payload_raw = tokio::fs::read_to_string(&notify_file).await?;
let payload: Value = serde_json::from_str(&payload_raw)?;
assert_eq!(payload["client"], "xcode");
Ok(())
}
// Helper to create a config.toml pointing at the mock model server.
fn create_config_toml(
codex_home: &Path,
server_uri: &str,
approval_policy: &str,
) -> std::io::Result<()> {
create_config_toml_with_extra(codex_home, server_uri, approval_policy, "")
}
fn create_config_toml_with_extra(
codex_home: &Path,
server_uri: &str,
approval_policy: &str,
extra: &str,
) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
@@ -195,6 +295,8 @@ sandbox_mode = "read-only"
model_provider = "mock_provider"
{extra}
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
@@ -205,3 +307,7 @@ stream_max_retries = 0
),
)
}
fn toml_basic_string(value: &str) -> String {
format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
}
@@ -9,6 +9,7 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::Model;
use codex_app_server_protocol::ModelListParams;
use codex_app_server_protocol::ModelListResponse;
use codex_app_server_protocol::ModelUpgradeInfo;
use codex_app_server_protocol::ReasoningEffortOption;
use codex_app_server_protocol::RequestId;
use codex_protocol::openai_models::ModelPreset;
@@ -24,6 +25,13 @@ fn model_from_preset(preset: &ModelPreset) -> Model {
id: preset.id.clone(),
model: preset.model.clone(),
upgrade: preset.upgrade.as_ref().map(|upgrade| upgrade.id.clone()),
upgrade_info: preset.upgrade.as_ref().map(|upgrade| ModelUpgradeInfo {
model: upgrade.id.clone(),
upgrade_copy: upgrade.upgrade_copy.clone(),
model_link: upgrade.model_link.clone(),
migration_markdown: upgrade.migration_markdown.clone(),
}),
availability_nux: preset.availability_nux.clone().map(Into::into),
display_name: preset.display_name.clone(),
description: preset.description.clone(),
hidden: !preset.show_in_picker,
+1 -6
View File
@@ -298,12 +298,7 @@ fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
let existing_description_present = existing
.description
.as_deref()
.map(|value| !value.trim().is_empty())
.unwrap_or(false);
if !existing_description_present && incoming_description_present {
if incoming_description_present {
existing.description = description;
}
+3
View File
@@ -250,6 +250,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
};
servers.insert(name.clone(), new_entry);
@@ -272,6 +273,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
oauth_config.http_headers,
oauth_config.env_http_headers,
&Vec::new(),
None,
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)
@@ -356,6 +358,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
http_headers,
env_http_headers,
&scopes,
server.oauth_resource.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)
+127 -2
View File
@@ -28,17 +28,21 @@ use serde::Serialize;
use sha2::Sha256;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::time::Duration;
use std::time::Instant;
use thiserror::Error;
use tokio::fs;
use tokio::task::JoinHandle;
use tokio::time::sleep;
use tokio::time::timeout;
const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(15);
const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5;
const CLOUD_REQUIREMENTS_CACHE_FILENAME: &str = "cloud-requirements-cache.json";
const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
const CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
const CLOUD_REQUIREMENTS_CACHE_TTL: Duration = Duration::from_secs(30 * 60);
const CLOUD_REQUIREMENTS_CACHE_WRITE_HMAC_KEY: &[u8] =
b"codex-cloud-requirements-cache-v3-064f8542-75b4-494c-a294-97d3ce597271";
const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] =
@@ -46,6 +50,11 @@ const CLOUD_REQUIREMENTS_CACHE_READ_HMAC_KEYS: &[&[u8]] =
type HmacSha256 = Hmac<Sha256>;
fn refresher_task_slot() -> &'static Mutex<Option<JoinHandle<()>>> {
static REFRESHER_TASK: OnceLock<Mutex<Option<JoinHandle<()>>>> = OnceLock::new();
REFRESHER_TASK.get_or_init(|| Mutex::new(None))
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FetchCloudRequirementsStatus {
BackendClientInit,
@@ -188,6 +197,7 @@ impl RequirementsFetcher for BackendRequirementsFetcher {
}
}
#[derive(Clone)]
struct CloudRequirementsService {
auth_manager: Arc<AuthManager>,
fetcher: Arc<dyn RequirementsFetcher>,
@@ -325,6 +335,54 @@ impl CloudRequirementsService {
None
}
async fn refresh_cache_in_background(&self) {
loop {
sleep(CLOUD_REQUIREMENTS_CACHE_REFRESH_INTERVAL).await;
match timeout(self.timeout, self.refresh_cache()).await {
Ok(true) => {}
Ok(false) => break,
Err(_) => {
tracing::warn!(
"Timed out refreshing cloud requirements cache from remote; keeping existing cache"
);
}
}
}
}
async fn refresh_cache(&self) -> bool {
let Some(auth) = self.auth_manager.auth().await else {
return false;
};
if !auth.is_chatgpt_auth()
|| !matches!(
auth.account_plan_type(),
Some(PlanType::Business | PlanType::Enterprise)
)
{
return false;
}
let token_data = auth.get_token_data().ok();
let chatgpt_user_id = token_data
.as_ref()
.and_then(|token_data| token_data.id_token.chatgpt_user_id.as_deref());
let account_id = auth.get_account_id();
let account_id = account_id.as_deref();
if self
.fetch_with_retries(&auth, chatgpt_user_id, account_id)
.await
.is_none()
{
tracing::warn!(
path = %self.cache_path.display(),
"Failed to refresh cloud requirements cache from remote"
);
}
true
}
async fn load_cache(
&self,
chatgpt_user_id: Option<&str>,
@@ -452,7 +510,17 @@ pub fn cloud_requirements_loader(
codex_home,
CLOUD_REQUIREMENTS_TIMEOUT,
);
let refresh_service = service.clone();
let task = tokio::spawn(async move { service.fetch_with_timeout().await });
let refresh_task =
tokio::spawn(async move { refresh_service.refresh_cache_in_background().await });
let mut refresher_guard = refresher_task_slot().lock().unwrap_or_else(|err| {
tracing::warn!("cloud requirements refresher task slot was poisoned");
err.into_inner()
});
if let Some(existing_task) = refresher_guard.replace(refresh_task) {
existing_task.abort();
}
CloudRequirementsLoader::new(async move {
task.await
.inspect_err(|err| tracing::warn!(error = %err, "Cloud requirements task failed"))
@@ -1052,7 +1120,11 @@ mod tests {
let cache_file: CloudRequirementsCacheFile =
serde_json::from_str(&std::fs::read_to_string(path).expect("read cache"))
.expect("parse cache");
assert!(cache_file.signed_payload.expires_at > Utc::now());
assert!(
cache_file.signed_payload.expires_at
<= cache_file.signed_payload.cached_at + ChronoDuration::minutes(30)
);
assert!(cache_file.signed_payload.expires_at > cache_file.signed_payload.cached_at);
assert!(cache_file.signed_payload.cached_at <= Utc::now());
assert_eq!(
cache_file.signed_payload.chatgpt_user_id,
@@ -1130,4 +1202,57 @@ mod tests {
CLOUD_REQUIREMENTS_MAX_ATTEMPTS
);
}
#[tokio::test]
async fn refresh_from_remote_updates_cached_cloud_requirements() {
let codex_home = tempdir().expect("tempdir");
let fetcher = Arc::new(SequenceFetcher::new(vec![
Ok(Some("allowed_approval_policies = [\"never\"]".to_string())),
Ok(Some(
"allowed_approval_policies = [\"on-request\"]".to_string(),
)),
]));
let service = CloudRequirementsService::new(
auth_manager_with_plan("business"),
fetcher,
codex_home.path().to_path_buf(),
CLOUD_REQUIREMENTS_TIMEOUT,
);
assert_eq!(
service.fetch().await,
Some(ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
network: None,
})
);
service.refresh_cache().await;
let path = codex_home.path().join(CLOUD_REQUIREMENTS_CACHE_FILENAME);
let cache_file: CloudRequirementsCacheFile =
serde_json::from_str(&std::fs::read_to_string(path).expect("read cache"))
.expect("parse cache");
assert_eq!(
cache_file
.signed_payload
.contents
.as_deref()
.and_then(|contents| parse_cloud_requirements(contents).ok().flatten()),
Some(ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::OnRequest]),
allowed_sandbox_modes: None,
allowed_web_search_modes: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
network: None,
})
);
}
}
@@ -25,6 +25,8 @@ use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::tungstenite::Error as WsError;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing::trace;
use tungstenite::protocol::WebSocketConfig;
@@ -62,15 +64,23 @@ impl WsStream {
};
match command {
WsCommand::Send { message, tx_result } => {
debug!("realtime websocket sending message");
let result = inner.send(message).await;
let should_break = result.is_err();
if let Err(err) = &result {
error!("realtime websocket send failed: {err}");
}
let _ = tx_result.send(result);
if should_break {
break;
}
}
WsCommand::Close { tx_result } => {
info!("realtime websocket sending close");
let result = inner.close(None).await;
if let Err(err) = &result {
error!("realtime websocket close failed: {err}");
}
let _ = tx_result.send(result);
break;
}
@@ -82,7 +92,9 @@ impl WsStream {
};
match message {
Ok(Message::Ping(payload)) => {
trace!(payload_len = payload.len(), "realtime websocket received ping");
if let Err(err) = inner.send(Message::Pong(payload)).await {
error!("realtime websocket failed to send pong: {err}");
let _ = tx_message.send(Err(err));
break;
}
@@ -93,6 +105,24 @@ impl WsStream {
| Message::Close(_)
| Message::Frame(_))) => {
let is_close = matches!(message, Message::Close(_));
match &message {
Message::Text(_) => trace!("realtime websocket received text frame"),
Message::Binary(binary) => {
error!(
payload_len = binary.len(),
"realtime websocket received unexpected binary frame"
);
}
Message::Close(frame) => info!(
"realtime websocket received close frame: code={:?} reason={:?}",
frame.as_ref().map(|frame| frame.code),
frame.as_ref().map(|frame| frame.reason.as_str())
),
Message::Frame(_) => {
trace!("realtime websocket received raw frame");
}
Message::Ping(_) | Message::Pong(_) => {}
}
if tx_message.send(Ok(message)).is_err() {
break;
}
@@ -101,6 +131,7 @@ impl WsStream {
}
}
Err(err) => {
error!("realtime websocket receive failed: {err}");
let _ = tx_message.send(Err(err));
break;
}
@@ -108,6 +139,7 @@ impl WsStream {
}
}
}
info!("realtime websocket pump exiting");
});
(
@@ -298,7 +330,7 @@ impl RealtimeWebsocketWriter {
async fn send_json(&self, message: RealtimeOutboundMessage) -> Result<(), ApiError> {
let payload = serde_json::to_string(&message)
.map_err(|err| ApiError::Stream(format!("failed to encode realtime request: {err}")))?;
trace!("realtime websocket request: {payload}");
debug!(?message, "realtime websocket request");
if self.is_closed.load(Ordering::SeqCst) {
return Err(ApiError::Stream(
@@ -325,12 +357,14 @@ impl RealtimeWebsocketEvents {
Some(Ok(msg)) => msg,
Some(Err(err)) => {
self.is_closed.store(true, Ordering::SeqCst);
error!("realtime websocket read failed: {err}");
return Err(ApiError::Stream(format!(
"failed to read websocket message: {err}"
)));
}
None => {
self.is_closed.store(true, Ordering::SeqCst);
info!("realtime websocket event stream ended");
return Ok(None);
}
};
@@ -338,11 +372,18 @@ impl RealtimeWebsocketEvents {
match msg {
Message::Text(text) => {
if let Some(event) = parse_realtime_event(&text) {
debug!(?event, "realtime websocket parsed event");
return Ok(Some(event));
}
debug!("realtime websocket ignored unsupported text frame");
}
Message::Close(_) => {
Message::Close(frame) => {
self.is_closed.store(true, Ordering::SeqCst);
info!(
"realtime websocket closed: code={:?} reason={:?}",
frame.as_ref().map(|frame| frame.code),
frame.as_ref().map(|frame| frame.reason.as_str())
);
return Ok(None);
}
Message::Binary(_) => {
@@ -383,15 +424,24 @@ impl RealtimeWebsocketClient {
request.headers_mut().extend(headers);
info!("connecting realtime websocket: {ws_url}");
let (stream, _) =
let (stream, response) =
tokio_tungstenite::connect_async_with_config(request, Some(websocket_config()), false)
.await
.map_err(|err| {
ApiError::Stream(format!("failed to connect realtime websocket: {err}"))
})?;
info!(
ws_url = %ws_url,
status = %response.status(),
"realtime websocket connected"
);
let (stream, rx_message) = WsStream::new(stream);
let connection = RealtimeWebsocketConnection::new(stream, rx_message);
debug!(
conversation_id = config.session_id.as_deref().unwrap_or("<none>"),
"realtime websocket sending session.create"
);
connection
.send_session_create(config.prompt, config.session_id)
.await?;
@@ -82,6 +82,7 @@ async fn models_client_hits_models_endpoint() {
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
@@ -1147,6 +1147,7 @@ mod tests {
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["rm"]),
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
}
+12
View File
@@ -613,6 +613,10 @@
"additionalProperties": false,
"description": "Memories settings loaded from config.toml.",
"properties": {
"generate_memories": {
"description": "When `false`, newly created threads are stored with `memory_mode = \"disabled\"` in the state DB.",
"type": "boolean"
},
"max_raw_memories_for_global": {
"description": "Maximum number of recent raw memories retained for global consolidation.",
"format": "uint",
@@ -647,6 +651,10 @@
"phase_2_model": {
"description": "Model used for memory consolidation.",
"type": "string"
},
"use_memories": {
"description": "When `false`, skip injecting memory usage instructions into developer prompts.",
"type": "boolean"
}
},
"type": "object"
@@ -1146,6 +1154,10 @@
},
"type": "object"
},
"oauth_resource": {
"default": null,
"type": "string"
},
"required": {
"default": null,
"type": "boolean"
+18 -17
View File
@@ -187,23 +187,24 @@ Rules:
config_file: None,
}
),
(
"awaiter".to_string(),
AgentRoleConfig {
description: Some(r#"Use an `awaiter` agent EVERY TIME you must run a command that will take some very long time.
This includes, but not only:
* testing
* monitoring of a long running process
* explicit ask to wait for something
Rules:
- When an awaiter is running, you can work on something else. If you need to wait for its completion, use the largest possible timeout.
- Be patient with the `awaiter`.
- Do not use an awaiter for every compilation/test if it won't take time. Only use if for long running commands.
- Close the awaiter when you're done with it."#.to_string()),
config_file: Some("awaiter.toml".to_string().parse().unwrap_or_default()),
}
)
// Awaiter is temp removed
// (
// "awaiter".to_string(),
// AgentRoleConfig {
// description: Some(r#"Use an `awaiter` agent EVERY TIME you must run a command that will take some very long time.
// This includes, but not only:
// * testing
// * monitoring of a long running process
// * explicit ask to wait for something
//
// Rules:
// - When an awaiter is running, you can work on something else. If you need to wait for its completion, use the largest possible timeout.
// - Be patient with the `awaiter`.
// - Do not use an awaiter for every compilation/test if it won't take time. Only use if for long running commands.
// - Close the awaiter when you're done with it."#.to_string()),
// config_file: Some("awaiter.toml".to_string().parse().unwrap_or_default()),
// }
// )
])
});
&CONFIG
+61 -8
View File
@@ -84,19 +84,13 @@ fn reserialize_shell_outputs(items: &mut [ResponseItem]) {
shell_call_ids.insert(call_id.clone());
}
}
ResponseItem::CustomToolCallOutput { call_id, output } => {
if shell_call_ids.remove(call_id)
&& let Some(structured) = parse_structured_shell_output(output)
{
*output = structured
}
}
ResponseItem::FunctionCall { name, call_id, .. }
if is_shell_tool_name(name) || name == "apply_patch" =>
{
shell_call_ids.insert(call_id.clone());
}
ResponseItem::FunctionCallOutput { call_id, output } => {
ResponseItem::FunctionCallOutput { call_id, output }
| ResponseItem::CustomToolCallOutput { call_id, output } => {
if shell_call_ids.remove(call_id)
&& let Some(structured) = output
.text_content()
@@ -240,6 +234,7 @@ mod tests {
use codex_api::common::OpenAiVerbosity;
use codex_api::common::TextControls;
use codex_api::create_text_param_for_request;
use codex_protocol::models::FunctionCallOutputPayload;
use pretty_assertions::assert_eq;
use super::*;
@@ -343,4 +338,62 @@ mod tests {
let v = serde_json::to_value(&req).expect("json");
assert!(v.get("text").is_none());
}
#[test]
fn reserializes_shell_outputs_for_function_and_custom_tool_calls() {
let raw_output = r#"{"output":"hello","metadata":{"exit_code":0,"duration_seconds":0.5}}"#;
let expected_output = "Exit code: 0\nWall time: 0.5 seconds\nOutput:\nhello";
let mut items = vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
output: FunctionCallOutputPayload::from_text(raw_output.to_string()),
},
];
reserialize_shell_outputs(&mut items);
assert_eq!(
items,
vec![
ResponseItem::FunctionCall {
id: None,
name: "shell".to_string(),
arguments: "{}".to_string(),
call_id: "call-1".to_string(),
},
ResponseItem::FunctionCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "call-2".to_string(),
name: "apply_patch".to_string(),
input: "*** Begin Patch".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "call-2".to_string(),
output: FunctionCallOutputPayload::from_text(expected_output.to_string()),
},
]
);
}
}
+34 -3
View File
@@ -443,6 +443,7 @@ impl Codex {
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name,
app_server_client_name: None,
session_source,
dynamic_tools,
persist_extended_history,
@@ -530,6 +531,18 @@ impl Codex {
self.session.steer_input(input, expected_turn_id).await
}
pub(crate) async fn set_app_server_client_name(
&self,
app_server_client_name: Option<String>,
) -> ConstraintResult<()> {
self.session
.update_settings(SessionSettingsUpdate {
app_server_client_name,
..Default::default()
})
.await
}
pub(crate) async fn agent_status(&self) -> AgentStatus {
self.agent_status.borrow().clone()
}
@@ -599,6 +612,7 @@ pub(crate) struct TurnContext {
pub(crate) cwd: PathBuf,
pub(crate) current_date: Option<String>,
pub(crate) timezone: Option<String>,
pub(crate) app_server_client_name: Option<String>,
pub(crate) developer_instructions: Option<String>,
pub(crate) compact_prompt: Option<String>,
pub(crate) user_instructions: Option<String>,
@@ -685,6 +699,7 @@ impl TurnContext {
cwd: self.cwd.clone(),
current_date: self.current_date.clone(),
timezone: self.timezone.clone(),
app_server_client_name: self.app_server_client_name.clone(),
developer_instructions: self.developer_instructions.clone(),
compact_prompt: self.compact_prompt.clone(),
user_instructions: self.user_instructions.clone(),
@@ -812,6 +827,7 @@ pub(crate) struct SessionConfiguration {
original_config_do_not_use: Arc<Config>,
/// Optional service name tag for session metrics.
metrics_service_name: Option<String>,
app_server_client_name: Option<String>,
/// Source of the session (cli, vscode, exec, mcp, ...)
session_source: SessionSource,
dynamic_tools: Vec<DynamicToolSpec>,
@@ -859,6 +875,9 @@ impl SessionConfiguration {
if let Some(cwd) = updates.cwd.clone() {
next_configuration.cwd = cwd;
}
if let Some(app_server_client_name) = updates.app_server_client_name.clone() {
next_configuration.app_server_client_name = Some(app_server_client_name);
}
Ok(next_configuration)
}
}
@@ -873,6 +892,7 @@ pub(crate) struct SessionSettingsUpdate {
pub(crate) reasoning_summary: Option<ReasoningSummaryConfig>,
pub(crate) final_output_json_schema: Option<Option<Value>>,
pub(crate) personality: Option<Personality>,
pub(crate) app_server_client_name: Option<String>,
}
impl Session {
@@ -1049,6 +1069,7 @@ impl Session {
cwd,
current_date: Some(current_date),
timezone: Some(timezone),
app_server_client_name: session_configuration.app_server_client_name.clone(),
developer_instructions: session_configuration.developer_instructions.clone(),
compact_prompt: session_configuration.compact_prompt.clone(),
user_instructions: session_configuration.user_instructions.clone(),
@@ -3054,9 +3075,10 @@ impl Session {
developer_sections.push(developer_instructions.to_string());
}
// Add developer instructions for memories.
if let Some(memory_prompt) =
build_memory_tool_developer_instructions(&turn_context.config.codex_home).await
&& turn_context.features.enabled(Feature::MemoryTool)
if turn_context.features.enabled(Feature::MemoryTool)
&& turn_context.config.memories.use_memories
&& let Some(memory_prompt) =
build_memory_tool_developer_instructions(&turn_context.config.codex_home).await
{
developer_sections.push(memory_prompt);
}
@@ -3948,6 +3970,7 @@ mod handlers {
reasoning_summary: summary,
final_output_json_schema: Some(final_output_json_schema),
personality,
app_server_client_name: None,
},
)
}
@@ -4699,6 +4722,7 @@ async fn spawn_review_thread(
ghost_snapshot: parent_turn_context.ghost_snapshot.clone(),
current_date: parent_turn_context.current_date.clone(),
timezone: parent_turn_context.timezone.clone(),
app_server_client_name: parent_turn_context.app_server_client_name.clone(),
developer_instructions: None,
user_instructions: None,
compact_prompt: parent_turn_context.compact_prompt.clone(),
@@ -5084,6 +5108,7 @@ pub(crate) async fn run_turn(
.dispatch(HookPayload {
session_id: sess.conversation_id,
cwd: turn_context.cwd.clone(),
client: turn_context.app_server_client_name.clone(),
triggered_at: chrono::Utc::now(),
hook_event: HookEvent::AfterAgent {
event: HookEventAfterAgent {
@@ -7893,6 +7918,7 @@ mod tests {
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -7985,6 +8011,7 @@ mod tests {
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -8296,6 +8323,7 @@ mod tests {
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -8349,6 +8377,7 @@ mod tests {
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -8430,6 +8459,7 @@ mod tests {
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
persist_extended_history: false,
@@ -8589,6 +8619,7 @@ mod tests {
thread_name: None,
original_config_do_not_use: Arc::clone(&config),
metrics_service_name: None,
app_server_client_name: None,
session_source: SessionSource::Exec,
dynamic_tools,
persist_extended_history: false,
+10
View File
@@ -1,6 +1,7 @@
use crate::agent::AgentStatus;
use crate::codex::Codex;
use crate::codex::SteerInputError;
use crate::config::ConstraintResult;
use crate::error::Result as CodexResult;
use crate::features::Feature;
use crate::file_watcher::WatchRegistration;
@@ -67,6 +68,15 @@ impl CodexThread {
self.codex.steer_input(input, expected_turn_id).await
}
pub async fn set_app_server_client_name(
&self,
app_server_client_name: Option<String>,
) -> ConstraintResult<()> {
self.codex
.set_app_server_client_name(app_server_client_name)
.await
}
/// Use sparingly: this is intended to be removed soon.
pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> {
self.codex.submit_with_id(sub).await
+12
View File
@@ -195,6 +195,11 @@ mod document_helpers {
{
entry["scopes"] = array_from_iter(scopes.iter().cloned());
}
if let Some(resource) = &config.oauth_resource
&& !resource.is_empty()
{
entry["oauth_resource"] = value(resource.clone());
}
entry
}
@@ -1465,6 +1470,7 @@ gpt-5 = "gpt-5.1"
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
@@ -1489,6 +1495,7 @@ gpt-5 = "gpt-5.1"
enabled_tools: None,
disabled_tools: Some(vec!["forbidden".to_string()]),
scopes: None,
oauth_resource: Some("https://resource.example.com".to_string()),
},
);
@@ -1507,6 +1514,7 @@ bearer_token_env_var = \"TOKEN\"
enabled = false
startup_timeout_sec = 5.0
disabled_tools = [\"forbidden\"]
oauth_resource = \"https://resource.example.com\"
[mcp_servers.http.http_headers]
Z-Header = \"z\"
@@ -1556,6 +1564,7 @@ foo = { command = "cmd" }
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
@@ -1602,6 +1611,7 @@ foo = { command = "cmd" } # keep me
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
@@ -1647,6 +1657,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
@@ -1693,6 +1704,7 @@ foo = { command = "cmd" }
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
+66
View File
@@ -2433,6 +2433,7 @@ mod tests {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
}
}
@@ -2452,6 +2453,7 @@ mod tests {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
}
}
@@ -2488,6 +2490,8 @@ persistence = "none"
let memories = r#"
[memories]
generate_memories = false
use_memories = false
max_raw_memories_for_global = 512
max_unused_days = 21
max_rollout_age_days = 42
@@ -2500,6 +2504,8 @@ phase_2_model = "gpt-5"
toml::from_str::<ConfigToml>(memories).expect("TOML deserialization should succeed");
assert_eq!(
Some(MemoriesToml {
generate_memories: Some(false),
use_memories: Some(false),
max_raw_memories_for_global: Some(512),
max_unused_days: Some(21),
max_rollout_age_days: Some(42),
@@ -2520,6 +2526,8 @@ phase_2_model = "gpt-5"
assert_eq!(
config.memories,
MemoriesConfig {
generate_memories: false,
use_memories: false,
max_raw_memories_for_global: 512,
max_unused_days: 21,
max_rollout_age_days: 42,
@@ -3489,6 +3497,7 @@ profile = "project"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
@@ -3646,6 +3655,7 @@ bearer_token = "secret"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@@ -3717,6 +3727,7 @@ ZIG_VAR = "3"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@@ -3768,6 +3779,7 @@ ZIG_VAR = "3"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@@ -3817,6 +3829,7 @@ ZIG_VAR = "3"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@@ -3882,6 +3895,7 @@ startup_timeout_sec = 2.0
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
apply_blocking(
@@ -3959,6 +3973,7 @@ X-Auth = "DOCS_AUTH"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@@ -3989,6 +4004,7 @@ X-Auth = "DOCS_AUTH"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
apply_blocking(
@@ -4057,6 +4073,7 @@ url = "https://example.com/mcp"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
),
(
@@ -4077,6 +4094,7 @@ url = "https://example.com/mcp"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
),
]);
@@ -4160,6 +4178,7 @@ url = "https://example.com/mcp"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@@ -4205,6 +4224,7 @@ url = "https://example.com/mcp"
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@@ -4250,6 +4270,7 @@ url = "https://example.com/mcp"
enabled_tools: Some(vec!["allowed".to_string()]),
disabled_tools: Some(vec!["blocked".to_string()]),
scopes: None,
oauth_resource: None,
},
)]);
@@ -4278,6 +4299,51 @@ url = "https://example.com/mcp"
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_streamable_http_serializes_oauth_resource() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::StreamableHttp {
url: "https://example.com/mcp".to_string(),
bearer_token_env_var: None,
http_headers: None,
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: Some("https://resource.example.com".to_string()),
},
)]);
apply_blocking(
codex_home.path(),
None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert!(serialized.contains(r#"oauth_resource = "https://resource.example.com""#));
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
assert_eq!(
docs.oauth_resource.as_deref(),
Some("https://resource.example.com")
);
Ok(())
}
#[tokio::test]
async fn set_model_updates_defaults() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
+49
View File
@@ -99,6 +99,10 @@ pub struct McpServerConfig {
/// Optional OAuth scopes to request during MCP login.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scopes: Option<Vec<String>>,
/// Optional OAuth resource parameter to include during MCP login (RFC 8707).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oauth_resource: Option<String>,
}
// Raw MCP config shape used for deserialization and JSON Schema generation.
@@ -143,6 +147,8 @@ pub(crate) struct RawMcpServerConfig {
pub disabled_tools: Option<Vec<String>>,
#[serde(default)]
pub scopes: Option<Vec<String>>,
#[serde(default)]
pub oauth_resource: Option<String>,
}
impl<'de> Deserialize<'de> for McpServerConfig {
@@ -166,6 +172,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
let enabled_tools = raw.enabled_tools.clone();
let disabled_tools = raw.disabled_tools.clone();
let scopes = raw.scopes.clone();
let oauth_resource = raw.oauth_resource.clone();
fn throw_if_set<E, T>(transport: &str, field: &str, value: Option<&T>) -> Result<(), E>
where
@@ -189,6 +196,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
throw_if_set("stdio", "bearer_token", raw.bearer_token.as_ref())?;
throw_if_set("stdio", "http_headers", raw.http_headers.as_ref())?;
throw_if_set("stdio", "env_http_headers", raw.env_http_headers.as_ref())?;
throw_if_set("stdio", "oauth_resource", raw.oauth_resource.as_ref())?;
McpServerTransportConfig::Stdio {
command,
args: raw.args.clone().unwrap_or_default(),
@@ -222,6 +230,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
enabled_tools,
disabled_tools,
scopes,
oauth_resource,
})
}
}
@@ -362,6 +371,10 @@ pub struct FeedbackConfigToml {
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct MemoriesToml {
/// When `false`, newly created threads are stored with `memory_mode = "disabled"` in the state DB.
pub generate_memories: Option<bool>,
/// When `false`, skip injecting memory usage instructions into developer prompts.
pub use_memories: Option<bool>,
/// Maximum number of recent raw memories retained for global consolidation.
pub max_raw_memories_for_global: Option<usize>,
/// Maximum number of days since a memory was last used before it becomes ineligible for phase 2 selection.
@@ -381,6 +394,8 @@ pub struct MemoriesToml {
/// Effective memories settings after defaults are applied.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemoriesConfig {
pub generate_memories: bool,
pub use_memories: bool,
pub max_raw_memories_for_global: usize,
pub max_unused_days: i64,
pub max_rollout_age_days: i64,
@@ -393,6 +408,8 @@ pub struct MemoriesConfig {
impl Default for MemoriesConfig {
fn default() -> Self {
Self {
generate_memories: true,
use_memories: true,
max_raw_memories_for_global: DEFAULT_MEMORIES_MAX_RAW_MEMORIES_FOR_GLOBAL,
max_unused_days: DEFAULT_MEMORIES_MAX_UNUSED_DAYS,
max_rollout_age_days: DEFAULT_MEMORIES_MAX_ROLLOUT_AGE_DAYS,
@@ -408,6 +425,8 @@ impl From<MemoriesToml> for MemoriesConfig {
fn from(toml: MemoriesToml) -> Self {
let defaults = Self::default();
Self {
generate_memories: toml.generate_memories.unwrap_or(defaults.generate_memories),
use_memories: toml.use_memories.unwrap_or(defaults.use_memories),
max_raw_memories_for_global: toml
.max_raw_memories_for_global
.unwrap_or(defaults.max_raw_memories_for_global)
@@ -1093,6 +1112,22 @@ mod tests {
);
}
#[test]
fn deserialize_streamable_http_server_config_with_oauth_resource() {
let cfg: McpServerConfig = toml::from_str(
r#"
url = "https://example.com/mcp"
oauth_resource = "https://api.example.com"
"#,
)
.expect("should deserialize http config with oauth_resource");
assert_eq!(
cfg.oauth_resource,
Some("https://api.example.com".to_string())
);
}
#[test]
fn deserialize_server_config_with_tool_filters() {
let cfg: McpServerConfig = toml::from_str(
@@ -1147,6 +1182,20 @@ mod tests {
"#,
)
.expect_err("should reject env_http_headers for stdio transport");
let err = toml::from_str::<McpServerConfig>(
r#"
command = "echo"
oauth_resource = "https://api.example.com"
"#,
)
.expect_err("should reject oauth_resource for stdio transport");
assert!(
err.to_string()
.contains("oauth_resource is not supported for stdio"),
"unexpected error: {err}"
);
}
#[test]
+6
View File
@@ -1390,6 +1390,7 @@ prefix_rules = [
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["rm"]),
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
}
@@ -1415,6 +1416,7 @@ prefix_rules = [
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}],
}
@@ -1426,6 +1428,7 @@ prefix_rules = [
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["hg", "status"]),
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}],
}
@@ -1509,6 +1512,7 @@ prefix_rules = []
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
}
@@ -1547,6 +1551,7 @@ prefix_rules = []
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
}
@@ -1561,6 +1566,7 @@ prefix_rules = []
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["git".to_string(), "push".to_string()],
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}],
}
+29 -20
View File
@@ -344,32 +344,21 @@ impl ContextManager {
let policy_with_serialization_budget = policy * 1.2;
match item {
ResponseItem::FunctionCallOutput { call_id, output } => {
let body = match &output.body {
FunctionCallOutputBody::Text(content) => FunctionCallOutputBody::Text(
truncate_text(content, policy_with_serialization_budget),
),
FunctionCallOutputBody::ContentItems(items) => {
FunctionCallOutputBody::ContentItems(
truncate_function_output_items_with_policy(
items,
policy_with_serialization_budget,
),
)
}
};
ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
body,
success: output.success,
},
output: truncate_function_output_payload(
output,
policy_with_serialization_budget,
),
}
}
ResponseItem::CustomToolCallOutput { call_id, output } => {
let truncated = truncate_text(output, policy_with_serialization_budget);
ResponseItem::CustomToolCallOutput {
call_id: call_id.clone(),
output: truncated,
output: truncate_function_output_payload(
output,
policy_with_serialization_budget,
),
}
}
ResponseItem::Message { .. }
@@ -385,6 +374,25 @@ impl ContextManager {
}
}
fn truncate_function_output_payload(
output: &FunctionCallOutputPayload,
policy: TruncationPolicy,
) -> FunctionCallOutputPayload {
let body = match &output.body {
FunctionCallOutputBody::Text(content) => {
FunctionCallOutputBody::Text(truncate_text(content, policy))
}
FunctionCallOutputBody::ContentItems(items) => FunctionCallOutputBody::ContentItems(
truncate_function_output_items_with_policy(items, policy),
),
};
FunctionCallOutputPayload {
body,
success: output.success,
}
}
/// API messages include every non-system item (user/assistant messages, reasoning,
/// tool calls, tool outputs, shell calls, and web-search calls).
fn is_api_message(message: &ResponseItem) -> bool {
@@ -508,7 +516,8 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
}
}
}
ResponseItem::FunctionCallOutput { output, .. } => {
ResponseItem::FunctionCallOutput { output, .. }
| ResponseItem::CustomToolCallOutput { output, .. } => {
if let FunctionCallOutputBody::ContentItems(items) = &output.body {
for content_item in items {
if let FunctionCallOutputContentItem::InputImage { image_url } = content_item {
@@ -67,7 +67,7 @@ fn user_input_text_msg(text: &str) -> ResponseItem {
fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem {
ResponseItem::CustomToolCallOutput {
call_id: call_id.to_string(),
output: output.to_string(),
output: FunctionCallOutputPayload::from_text(output.to_string()),
}
}
@@ -279,6 +279,24 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
},
]),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "tool-1".to_string(),
name: "js_repl".to_string(),
input: "view_image".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "tool-1".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputText {
text: "js repl result".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "https://example.com/js-repl-result.png".to_string(),
},
]),
},
];
let history = create_history_with_items(items);
let text_only_modalities = vec![InputModality::Text];
@@ -321,6 +339,25 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
},
]),
},
ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "tool-1".to_string(),
name: "js_repl".to_string(),
input: "view_image".to_string(),
},
ResponseItem::CustomToolCallOutput {
call_id: "tool-1".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputText {
text: "js repl result".to_string(),
},
FunctionCallOutputContentItem::InputText {
text: "image content omitted because you do not support image input"
.to_string(),
},
]),
},
];
assert_eq!(stripped, expected);
@@ -671,7 +708,7 @@ fn remove_first_item_handles_custom_tool_pair() {
},
ResponseItem::CustomToolCallOutput {
call_id: "tool-1".to_string(),
output: "ok".to_string(),
output: FunctionCallOutputPayload::from_text("ok".to_string()),
},
];
let mut h = create_history_with_items(items);
@@ -750,7 +787,7 @@ fn record_items_truncates_custom_tool_call_output_content() {
let long_output = line.repeat(2_500);
let item = ResponseItem::CustomToolCallOutput {
call_id: "tool-200".to_string(),
output: long_output.clone(),
output: FunctionCallOutputPayload::from_text(long_output.clone()),
};
history.record_items([&item], policy);
@@ -758,7 +795,8 @@ fn record_items_truncates_custom_tool_call_output_content() {
assert_eq!(history.items.len(), 1);
match &history.items[0] {
ResponseItem::CustomToolCallOutput { output, .. } => {
assert_ne!(output, &long_output);
let output = output.text_content().unwrap_or_default();
assert_ne!(output, long_output);
assert!(
output.contains("tokens truncated"),
"expected token-based truncation marker, got {output}"
@@ -949,7 +987,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() {
},
ResponseItem::CustomToolCallOutput {
call_id: "tool-x".to_string(),
output: "aborted".to_string(),
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
},
]
);
@@ -1016,7 +1054,7 @@ fn normalize_removes_orphan_function_call_output() {
fn normalize_removes_orphan_custom_tool_call_output() {
let items = vec![ResponseItem::CustomToolCallOutput {
call_id: "orphan-2".to_string(),
output: "ok".to_string(),
output: FunctionCallOutputPayload::from_text("ok".to_string()),
}];
let mut h = create_history_with_items(items);
@@ -1089,7 +1127,7 @@ fn normalize_mixed_inserts_and_removals() {
},
ResponseItem::CustomToolCallOutput {
call_id: "t1".to_string(),
output: "aborted".to_string(),
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
},
ResponseItem::LocalShellCall {
id: None,
@@ -1191,7 +1229,7 @@ fn normalize_removes_orphan_function_call_output_panics_in_debug() {
fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() {
let items = vec![ResponseItem::CustomToolCallOutput {
call_id: "orphan-2".to_string(),
output: "ok".to_string(),
output: FunctionCallOutputPayload::from_text("ok".to_string()),
}];
let mut h = create_history_with_items(items);
h.normalize_history(&default_input_modalities());
@@ -1294,6 +1332,28 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() {
assert!(estimated < raw_len);
}
#[test]
fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() {
let payload = "C".repeat(50_000);
let image_url = format!("data:image/png;base64,{payload}");
let item = ResponseItem::CustomToolCallOutput {
call_id: "call-js-repl".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputText {
text: "Screenshot captured".to_string(),
},
FunctionCallOutputContentItem::InputImage { image_url },
]),
};
let raw_len = serde_json::to_string(&item).unwrap().len() as i64;
let estimated = estimate_response_item_model_visible_bytes(&item);
let expected = raw_len - payload.len() as i64 + IMAGE_BYTES_ESTIMATE;
assert_eq!(estimated, expected);
assert!(estimated < raw_len);
}
#[test]
fn non_base64_image_urls_are_unchanged() {
let message_item = ResponseItem::Message {
+5 -11
View File
@@ -1,7 +1,6 @@
use std::collections::HashSet;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseItem;
@@ -35,10 +34,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
idx,
ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text("aborted".to_string()),
..Default::default()
},
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
},
));
}
@@ -59,7 +55,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
idx,
ResponseItem::CustomToolCallOutput {
call_id: call_id.clone(),
output: "aborted".to_string(),
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
},
));
}
@@ -82,10 +78,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec<ResponseItem>) {
idx,
ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text("aborted".to_string()),
..Default::default()
},
output: FunctionCallOutputPayload::from_text("aborted".to_string()),
},
));
}
@@ -245,7 +238,8 @@ pub(crate) fn strip_images_when_unsupported(
}
*content = normalized_content;
}
ResponseItem::FunctionCallOutput { output, .. } => {
ResponseItem::FunctionCallOutput { output, .. }
| ResponseItem::CustomToolCallOutput { output, .. } => {
if let Some(content_items) = output.content_items_mut() {
let mut normalized_content_items = Vec::with_capacity(content_items.len());
for content_item in content_items.iter() {
+77 -11
View File
@@ -472,17 +472,7 @@ pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy,
return Ok(policy);
};
let mut combined_rules = policy.rules().clone();
for (program, rules) in requirements_policy.as_ref().rules().iter_all() {
for rule in rules {
combined_rules.insert(program.clone(), rule.clone());
}
}
let mut combined_network_rules = policy.network_rules().to_vec();
combined_network_rules.extend(requirements_policy.as_ref().network_rules().iter().cloned());
Ok(Policy::from_parts(combined_rules, combined_network_rules))
Ok(policy.merge_overlay(requirements_policy.as_ref()))
}
/// If a command is not matched by any execpolicy rule, derive a [`Decision`].
@@ -827,6 +817,7 @@ mod tests {
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::tempdir;
use toml::Value as TomlValue;
@@ -846,6 +837,22 @@ mod tests {
.expect("ConfigLayerStack")
}
fn host_absolute_path(segments: &[&str]) -> String {
let mut path = if cfg!(windows) {
PathBuf::from(r"C:\")
} else {
PathBuf::from("/")
};
for segment in segments {
path.push(segment);
}
path.to_string_lossy().into_owned()
}
fn starlark_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
#[tokio::test]
async fn returns_empty_policy_when_no_policy_files_exist() {
let temp_dir = tempdir().expect("create temp dir");
@@ -949,6 +956,7 @@ mod tests {
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
},
@@ -991,6 +999,59 @@ mod tests {
Ok(())
}
#[tokio::test]
async fn preserves_host_executables_when_requirements_overlay_is_present() -> anyhow::Result<()>
{
let temp_dir = tempdir()?;
let policy_dir = temp_dir.path().join(RULES_DIR_NAME);
fs::create_dir_all(&policy_dir)?;
let git_path = host_absolute_path(&["usr", "bin", "git"]);
let git_path_literal = starlark_string(&git_path);
fs::write(
policy_dir.join("host.rules"),
format!(
r#"
host_executable(name = "git", paths = ["{git_path_literal}"])
"#
),
)?;
let mut requirements_exec_policy = Policy::empty();
requirements_exec_policy.add_network_rule(
"blocked.example.com",
codex_execpolicy::NetworkRuleProtocol::Https,
Decision::Forbidden,
None,
)?;
let requirements = ConfigRequirements {
exec_policy: Some(codex_config::Sourced::new(
codex_config::RequirementsExecPolicy::new(requirements_exec_policy),
codex_config::RequirementSource::Unknown,
)),
..ConfigRequirements::default()
};
let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?;
let layer = ConfigLayerEntry::new(
ConfigLayerSource::Project { dot_codex_folder },
TomlValue::Table(Default::default()),
);
let config_stack =
ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?;
let policy = load_exec_policy(&config_stack).await?;
assert_eq!(
policy
.host_executables()
.get("git")
.expect("missing git host executable")
.as_ref(),
[AbsolutePathBuf::try_from(git_path)?]
);
Ok(())
}
#[tokio::test]
async fn ignores_policies_outside_policy_dir() {
let temp_dir = tempdir().expect("create temp dir");
@@ -1106,6 +1167,7 @@ mod tests {
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["rm".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}],
},
@@ -1117,6 +1179,7 @@ mod tests {
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["ls".to_string()],
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}],
},
@@ -1983,6 +2046,7 @@ prefix_rule(
let matched_rules_prompt = vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["cargo".to_string()],
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}];
assert_eq!(
@@ -1996,6 +2060,7 @@ prefix_rule(
let matched_rules_allow = vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["cargo".to_string()],
decision: Decision::Allow,
resolved_program: None,
justification: None,
}];
assert_eq!(
@@ -2009,6 +2074,7 @@ prefix_rule(
let matched_rules_forbidden = vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["cargo".to_string()],
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
}];
assert_eq!(
+1
View File
@@ -139,6 +139,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
}
}
@@ -241,6 +241,7 @@ pub(crate) async fn maybe_install_mcp_dependencies(
oauth_config.http_headers,
oauth_config.env_http_headers,
&[],
server_config.oauth_resource.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)
@@ -387,6 +388,7 @@ fn mcp_dependency_to_server_config(
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
});
}
@@ -411,6 +413,7 @@ fn mcp_dependency_to_server_config(
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
});
}
@@ -468,6 +471,7 @@ mod tests {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@@ -516,6 +520,7 @@ mod tests {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
@@ -2115,6 +2115,7 @@ mod tests {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
auth_status: McpAuthStatus::Unsupported,
};
@@ -2162,6 +2163,7 @@ mod tests {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
auth_status: McpAuthStatus::Unsupported,
};
@@ -69,6 +69,7 @@ pub(crate) fn model_info_from_slug(slug: &str) -> ModelInfo {
visibility: ModelVisibility::None,
supported_in_api: true,
priority: 99,
availability_nux: None,
upgrade: None,
base_instructions: BASE_INSTRUCTIONS.to_string(),
model_messages: local_personality_messages_for_slug(slug),
@@ -31,6 +31,7 @@ use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing::warn;
const AUDIO_IN_QUEUE_CAPACITY: usize = 256;
@@ -184,6 +185,7 @@ pub(crate) async fn handle_start(
let requested_session_id = params
.session_id
.or_else(|| Some(sess.conversation_id.to_string()));
info!("starting realtime conversation");
let events_rx = match sess
.conversation
.start(api_provider, None, prompt, requested_session_id.clone())
@@ -191,11 +193,14 @@ pub(crate) async fn handle_start(
{
Ok(events_rx) => events_rx,
Err(err) => {
error!("failed to start realtime conversation: {err}");
send_conversation_error(sess, sub_id, err.to_string(), CodexErrorInfo::Other).await;
return Ok(());
}
};
info!("realtime conversation started");
sess.send_event_raw(Event {
id: sub_id.clone(),
msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent {
@@ -211,6 +216,7 @@ pub(crate) async fn handle_start(
msg,
};
while let Ok(event) = events_rx.recv().await {
debug!(conversation_id = %sess_clone.conversation_id, "received realtime conversation event");
let maybe_routed_text = match &event {
RealtimeEvent::ConversationItemAdded(item) => {
realtime_text_from_conversation_item(item)
@@ -231,6 +237,7 @@ pub(crate) async fn handle_start(
.await;
}
if let Some(()) = sess_clone.conversation.running_state().await {
info!("realtime conversation transport closed");
sess_clone
.send_event_raw(ev(EventMsg::RealtimeConversationClosed(
RealtimeConversationClosedEvent {
@@ -250,6 +257,7 @@ pub(crate) async fn handle_audio(
params: ConversationAudioParams,
) {
if let Err(err) = sess.conversation.audio_in(params.frame).await {
error!("failed to append realtime audio: {err}");
send_conversation_error(sess, sub_id, err.to_string(), CodexErrorInfo::BadRequest).await;
}
}
@@ -284,6 +292,7 @@ pub(crate) async fn handle_text(
debug!(text = %params.text, "[realtime-text] appending realtime conversation text input");
if let Err(err) = sess.conversation.text_in(params.text).await {
error!("failed to append realtime text: {err}");
send_conversation_error(sess, sub_id, err.to_string(), CodexErrorInfo::BadRequest).await;
}
}
+8
View File
@@ -460,6 +460,7 @@ impl RolloutRecorder {
state_db_ctx.clone(),
state_builder,
config.model_provider_id.clone(),
config.memories.generate_memories,
));
Ok(Self {
@@ -711,6 +712,7 @@ async fn rollout_writer(
state_db_ctx: Option<StateDbHandle>,
mut state_builder: Option<ThreadMetadataBuilder>,
default_provider: String,
generate_memories: bool,
) -> std::io::Result<()> {
let mut writer = file.map(|file| JsonlWriter { file });
let mut buffered_items = Vec::<RolloutItem>::new();
@@ -731,6 +733,7 @@ async fn rollout_writer(
state_db_ctx.as_deref(),
&mut state_builder,
default_provider.as_str(),
generate_memories,
)
.await?;
}
@@ -784,6 +787,7 @@ async fn rollout_writer(
state_db_ctx.as_deref(),
&mut state_builder,
default_provider.as_str(),
generate_memories,
)
.await?;
}
@@ -831,6 +835,7 @@ async fn rollout_writer(
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn write_session_meta(
mut writer: Option<&mut JsonlWriter>,
session_meta: SessionMeta,
@@ -839,6 +844,7 @@ async fn write_session_meta(
state_db_ctx: Option<&StateRuntime>,
state_builder: &mut Option<ThreadMetadataBuilder>,
default_provider: &str,
generate_memories: bool,
) -> std::io::Result<()> {
let git_info = collect_git_info(cwd).await;
let session_meta_line = SessionMetaLine {
@@ -860,6 +866,7 @@ async fn write_session_meta(
state_builder.as_ref(),
std::slice::from_ref(&rollout_item),
None,
(!generate_memories).then_some("disabled"),
)
.await;
Ok(())
@@ -888,6 +895,7 @@ async fn write_and_reconcile_items(
state_builder.as_ref(),
items,
"rollout_writer",
None,
)
.await;
Ok(())
+8 -1
View File
@@ -345,6 +345,7 @@ pub async fn reconcile_rollout(
builder: Option<&ThreadMetadataBuilder>,
items: &[RolloutItem],
archived_only: Option<bool>,
new_thread_memory_mode: Option<&str>,
) {
let Some(ctx) = context else {
return;
@@ -357,6 +358,7 @@ pub async fn reconcile_rollout(
builder,
items,
"reconcile_rollout",
new_thread_memory_mode,
)
.await;
return;
@@ -467,6 +469,7 @@ pub async fn read_repair_rollout_path(
None,
&[],
archived_only,
None,
)
.await;
}
@@ -479,6 +482,7 @@ pub async fn apply_rollout_items(
builder: Option<&ThreadMetadataBuilder>,
items: &[RolloutItem],
stage: &str,
new_thread_memory_mode: Option<&str>,
) {
let Some(ctx) = context else {
return;
@@ -499,7 +503,10 @@ pub async fn apply_rollout_items(
};
builder.rollout_path = rollout_path.to_path_buf();
builder.cwd = normalize_cwd_for_state_db(&builder.cwd);
if let Err(err) = ctx.apply_rollout_items(&builder, items, None).await {
if let Err(err) = ctx
.apply_rollout_items(&builder, items, None, new_thread_memory_mode)
.await
{
warn!(
"state db apply_rollout_items failed during {stage} for {}: {err}",
rollout_path.display()
+1
View File
@@ -249,6 +249,7 @@ pub fn terminal_info() -> TerminalInfo {
/// type is split on whitespace to extract a program name plus optional version (for example,
/// `ghostty 1.2.3`), while the client term name becomes the `TERM` capability string.
/// - Otherwise, `TERM_PROGRAM` (plus `TERM_PROGRAM_VERSION`) drives the detected terminal name.
/// This means `TERM_PROGRAM` can mask later probes (for example `WT_SESSION`).
/// - Next, terminal-specific variables (WEZTERM, iTerm2, Apple Terminal, kitty, etc.) are checked.
/// - Finally, `TERM` is used as the capability fallback with `TerminalName::Unknown`.
///
+20 -8
View File
@@ -95,15 +95,12 @@ impl ToolOutput {
match self {
ToolOutput::Function { body, success } => {
// `custom_tool_call` is the Responses API item type for freeform
// tools (`ToolSpec::Freeform`, e.g. freeform `apply_patch`).
// Those payloads must round-trip as `custom_tool_call_output`
// with plain string output.
// tools (`ToolSpec::Freeform`, e.g. freeform `apply_patch` or
// `js_repl`).
if matches!(payload, ToolPayload::Custom { .. }) {
// Freeform/custom tools (`custom_tool_call`) use the custom
// output wire shape and remain string-only.
return ResponseInputItem::CustomToolCallOutput {
call_id: call_id.to_string(),
output: body.to_text().unwrap_or_default(),
output: FunctionCallOutputPayload { body, success },
};
}
@@ -183,7 +180,9 @@ mod tests {
match response {
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
assert_eq!(call_id, "call-42");
assert_eq!(output, "patched");
assert_eq!(output.text_content(), Some("patched"));
assert!(output.content_items().is_none());
assert_eq!(output.success, Some(true));
}
other => panic!("expected CustomToolCallOutput, got {other:?}"),
}
@@ -234,8 +233,21 @@ mod tests {
match response {
ResponseInputItem::CustomToolCallOutput { call_id, output } => {
let expected = vec![
FunctionCallOutputContentItem::InputText {
text: "line 1".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,AAA".to_string(),
},
FunctionCallOutputContentItem::InputText {
text: "line 2".to_string(),
},
];
assert_eq!(call_id, "call-99");
assert_eq!(output, "line 1\nline 2");
assert_eq!(output.content_items(), Some(expected.as_slice()));
assert_eq!(output.body.to_text().as_deref(), Some("line 1\nline 2"));
assert_eq!(output.success, Some(true));
}
other => panic!("expected CustomToolCallOutput, got {other:?}"),
}
+12 -4
View File
@@ -155,9 +155,13 @@ impl ToolHandler for JsReplHandler {
};
let content = result.output;
let items = vec![FunctionCallOutputContentItem::InputText {
text: content.clone(),
}];
let mut items = Vec::with_capacity(result.content_items.len() + 1);
if !content.is_empty() {
items.push(FunctionCallOutputContentItem::InputText {
text: content.clone(),
});
}
items.extend(result.content_items);
emit_js_repl_exec_end(
session.as_ref(),
@@ -170,7 +174,11 @@ impl ToolHandler for JsReplHandler {
.await;
Ok(ToolOutput::Function {
body: FunctionCallOutputBody::ContentItems(items),
body: if items.is_empty() {
FunctionCallOutputBody::Text(content)
} else {
FunctionCallOutputBody::ContentItems(items)
},
success: Some(true),
})
}
@@ -1228,7 +1228,7 @@ mod tests {
"spawn_agent",
function_payload(json!({
"message": "await this command",
"agent_type": "awaiter"
"agent_type": "explorer"
})),
);
let output = MultiAgentHandler
+170 -71
View File
@@ -104,6 +104,7 @@ pub struct JsReplArgs {
#[derive(Clone, Debug)]
pub struct JsExecResult {
pub output: String,
pub content_items: Vec<FunctionCallOutputContentItem>,
}
struct KernelState {
@@ -125,6 +126,7 @@ struct ExecContext {
#[derive(Default)]
struct ExecToolCalls {
in_flight: usize,
content_items: Vec<FunctionCallOutputContentItem>,
notify: Arc<Notify>,
cancel: CancellationToken,
}
@@ -136,6 +138,7 @@ enum JsReplToolCallPayloadKind {
FunctionText,
FunctionContentItems,
CustomText,
CustomContentItems,
McpResult,
McpErrorResult,
Error,
@@ -369,6 +372,21 @@ impl JsReplManager {
Some(state.cancel.clone())
}
async fn record_exec_tool_call_content_items(
exec_tool_calls: &Arc<Mutex<HashMap<String, ExecToolCalls>>>,
exec_id: &str,
content_items: Vec<FunctionCallOutputContentItem>,
) {
if content_items.is_empty() {
return;
}
let mut calls = exec_tool_calls.lock().await;
if let Some(state) = calls.get_mut(exec_id) {
state.content_items.extend(content_items);
}
}
async fn finish_exec_tool_call(
exec_tool_calls: &Arc<Mutex<HashMap<String, ExecToolCalls>>>,
exec_id: &str,
@@ -592,11 +610,18 @@ impl JsReplManager {
output,
)
}
ResponseInputItem::CustomToolCallOutput { output, .. } => Self::summarize_text_payload(
Some("custom_tool_call_output"),
JsReplToolCallPayloadKind::CustomText,
output,
),
ResponseInputItem::CustomToolCallOutput { output, .. } => {
let payload_kind = if output.content_items().is_some() {
JsReplToolCallPayloadKind::CustomContentItems
} else {
JsReplToolCallPayloadKind::CustomText
};
Self::summarize_function_output_payload(
"custom_tool_call_output",
payload_kind,
output,
)
}
ResponseInputItem::McpToolCallOutput { result, .. } => match result {
Ok(result) => {
let output = FunctionCallOutputPayload::from(result);
@@ -769,7 +794,13 @@ impl JsReplManager {
};
match response {
ExecResultMessage::Ok { output } => Ok(JsExecResult { output }),
ExecResultMessage::Ok { content_items } => {
let (output, content_items) = split_exec_result_content_items(content_items);
Ok(JsExecResult {
output,
content_items,
})
}
ExecResultMessage::Err { message } => Err(FunctionCallError::RespondToModel(message)),
}
}
@@ -1073,10 +1104,22 @@ impl JsReplManager {
error,
} => {
JsReplManager::wait_for_exec_tool_calls_map(&exec_tool_calls, &id).await;
let content_items = {
let calls = exec_tool_calls.lock().await;
calls
.get(&id)
.map(|state| state.content_items.clone())
.unwrap_or_default()
};
let mut pending = pending_execs.lock().await;
if let Some(tx) = pending.remove(&id) {
let payload = if ok {
ExecResultMessage::Ok { output }
ExecResultMessage::Ok {
content_items: build_exec_result_content_items(
output,
content_items,
),
}
} else {
ExecResultMessage::Err {
message: error
@@ -1133,7 +1176,11 @@ impl JsReplManager {
response: None,
error: Some("js_repl execution reset".to_string()),
},
result = JsReplManager::run_tool_request(ctx, req) => result,
result = JsReplManager::run_tool_request(
ctx,
req,
Arc::clone(&exec_tool_calls_for_task),
) => result,
}
}
None => RunToolResult {
@@ -1227,7 +1274,11 @@ impl JsReplManager {
}
}
async fn run_tool_request(exec: ExecContext, req: RunToolRequest) -> RunToolResult {
async fn run_tool_request(
exec: ExecContext,
req: RunToolRequest,
exec_tool_calls: Arc<Mutex<HashMap<String, ExecToolCalls>>>,
) -> RunToolResult {
if is_js_repl_internal_tool(&req.tool_name) {
let error = "js_repl cannot invoke itself".to_string();
let summary = Self::summarize_tool_call_error(&error);
@@ -1300,39 +1351,13 @@ impl JsReplManager {
.await
{
Ok(response) => {
if let ResponseInputItem::FunctionCallOutput { output, .. } = &response
&& let Some(items) = output.content_items()
{
let mut has_image = false;
let mut content = Vec::with_capacity(items.len());
for item in items {
match item {
FunctionCallOutputContentItem::InputText { text } => {
content.push(ContentItem::InputText { text: text.clone() });
}
FunctionCallOutputContentItem::InputImage { image_url } => {
has_image = true;
content.push(ContentItem::InputImage {
image_url: image_url.clone(),
});
}
}
}
if has_image
&& session
.inject_response_items(vec![ResponseInputItem::Message {
role: "user".to_string(),
content,
}])
.await
.is_err()
{
warn!(
tool_name = %tool_name,
"js_repl tool call returned image content but there was no active turn to attach it to"
);
}
if let Some(items) = response_content_items(&response) {
Self::record_exec_tool_call_content_items(
&exec_tool_calls,
&req.exec_id,
items,
)
.await;
}
let summary = Self::summarize_tool_call_response(&response);
@@ -1407,6 +1432,50 @@ impl JsReplManager {
}
}
fn response_content_items(
response: &ResponseInputItem,
) -> Option<Vec<FunctionCallOutputContentItem>> {
match response {
ResponseInputItem::FunctionCallOutput { output, .. }
| ResponseInputItem::CustomToolCallOutput { output, .. } => output
.content_items()
.map(<[FunctionCallOutputContentItem]>::to_vec),
ResponseInputItem::McpToolCallOutput { result, .. } => match result {
Ok(result) => FunctionCallOutputPayload::from(result)
.content_items()
.map(<[FunctionCallOutputContentItem]>::to_vec),
Err(_) => None,
},
ResponseInputItem::Message { .. } => None,
}
}
fn build_exec_result_content_items(
output: String,
content_items: Vec<FunctionCallOutputContentItem>,
) -> Vec<FunctionCallOutputContentItem> {
let mut all_content_items = Vec::with_capacity(content_items.len() + 1);
all_content_items.push(FunctionCallOutputContentItem::InputText { text: output });
all_content_items.extend(content_items);
all_content_items
}
fn split_exec_result_content_items(
mut content_items: Vec<FunctionCallOutputContentItem>,
) -> (String, Vec<FunctionCallOutputContentItem>) {
match content_items.first() {
Some(FunctionCallOutputContentItem::InputText { .. }) => {
let FunctionCallOutputContentItem::InputText { text } = content_items.remove(0) else {
unreachable!("first content item should be input_text");
};
(text, content_items)
}
Some(FunctionCallOutputContentItem::InputImage { .. }) | None => {
(String::new(), content_items)
}
}
}
fn is_freeform_tool(specs: &[ToolSpec], name: &str) -> bool {
specs
.iter()
@@ -1462,8 +1531,12 @@ struct RunToolResult {
#[derive(Debug)]
enum ExecResultMessage {
Ok { output: String },
Err { message: String },
Ok {
content_items: Vec<FunctionCallOutputContentItem>,
},
Err {
message: String,
},
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
@@ -1601,7 +1674,6 @@ mod tests {
use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputContentItem;
use codex_protocol::models::FunctionCallOutputPayload;
use codex_protocol::models::ResponseInputItem;
@@ -1850,6 +1922,35 @@ mod tests {
);
}
#[test]
fn summarize_tool_call_response_for_multimodal_custom_output() {
let response = ResponseInputItem::CustomToolCallOutput {
call_id: "call-1".to_string(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,abcd".to_string(),
},
]),
};
let actual = JsReplManager::summarize_tool_call_response(&response);
assert_eq!(
actual,
JsReplToolCallResponseSummary {
response_type: Some("custom_tool_call_output".to_string()),
payload_kind: Some(JsReplToolCallPayloadKind::CustomContentItems),
payload_text_preview: None,
payload_text_length: None,
payload_item_count: Some(1),
text_item_count: Some(0),
image_item_count: Some(1),
structured_content_present: None,
result_is_error: None,
}
);
}
#[test]
fn summarize_tool_call_error_marks_error_payload() {
let actual = JsReplManager::summarize_tool_call_error("tool failed");
@@ -2310,20 +2411,22 @@ console.log(out.output?.body?.text ?? "");
)
.await?;
assert!(result.output.contains("function_call_output"));
let pending_input = session.get_pending_input().await;
let [ResponseInputItem::Message { role, content }] = pending_input.as_slice() else {
panic!(
"view_image should inject exactly one pending input message, got {pending_input:?}"
);
};
assert_eq!(role, "user");
let [ContentItem::InputImage { image_url }] = content.as_slice() else {
panic!(
"view_image should inject exactly one input_image content item, got {content:?}"
);
assert_eq!(
result.content_items.as_slice(),
[FunctionCallOutputContentItem::InputImage {
image_url:
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="
.to_string(),
}]
.as_slice()
);
let [FunctionCallOutputContentItem::InputImage { image_url }] =
result.content_items.as_slice()
else {
panic!("view_image should return exactly one input_image content item");
};
assert!(image_url.starts_with("data:image/png;base64,"));
assert!(session.get_pending_input().await.is_empty());
Ok(())
}
@@ -2404,22 +2507,18 @@ console.log(out.type);
response_watcher_result?;
let result = result?;
assert!(result.output.contains("function_call_output"));
let pending_input = session.get_pending_input().await;
assert_eq!(
pending_input,
vec![ResponseInputItem::Message {
role: "user".to_string(),
content: vec![
ContentItem::InputText {
text: "inline image note".to_string(),
},
ContentItem::InputImage {
image_url: image_url.to_string(),
},
],
}]
result.content_items,
vec![
FunctionCallOutputContentItem::InputText {
text: "inline image note".to_string(),
},
FunctionCallOutputContentItem::InputImage {
image_url: image_url.to_string(),
},
]
);
assert!(session.get_pending_input().await.is_empty());
Ok(())
}
+4 -1
View File
@@ -117,7 +117,10 @@ impl ToolCallRuntime {
match &call.payload {
ToolPayload::Custom { .. } => ResponseInputItem::CustomToolCallOutput {
call_id: call.call_id.clone(),
output: Self::abort_message(call, secs),
output: FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(Self::abort_message(call, secs)),
..Default::default()
},
},
ToolPayload::Mcp { .. } => ResponseInputItem::McpToolCallOutput {
call_id: call.call_id.clone(),
+1
View File
@@ -379,6 +379,7 @@ async fn dispatch_after_tool_use_hook(
.dispatch(HookPayload {
session_id: session.conversation_id,
cwd: turn.cwd.clone(),
client: turn.app_server_client_name.clone(),
triggered_at: chrono::Utc::now(),
hook_event: HookEvent::AfterToolUse {
event: HookEventAfterToolUse {
+4 -1
View File
@@ -197,7 +197,10 @@ impl ToolRouter {
if payload_outputs_custom {
ResponseInputItem::CustomToolCallOutput {
call_id,
output: message,
output: codex_protocol::models::FunctionCallOutputPayload {
body: FunctionCallOutputBody::Text(message),
success: Some(false),
},
}
} else {
ResponseInputItem::FunctionCallOutput {
@@ -16,6 +16,8 @@ use crate::tools::sandboxing::SandboxablePreference;
use crate::tools::sandboxing::ToolCtx;
use crate::tools::sandboxing::ToolError;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
use codex_execpolicy::MatchOptions;
use codex_execpolicy::Policy;
use codex_execpolicy::RuleMatch;
use codex_protocol::config_types::WindowsSandboxLevel;
@@ -493,28 +495,16 @@ impl EscalationPolicy for CoreShellActionProvider {
.await;
}
let command = join_program_and_argv(program, argv);
let (commands, used_complex_parsing) =
if let Some(commands) = parse_shell_lc_plain_commands(&command) {
(commands, false)
} else if let Some(single_command) = parse_shell_lc_single_command_prefix(&command) {
(vec![single_command], true)
} else {
(vec![command.clone()], false)
};
let fallback = |cmd: &[String]| {
crate::exec_policy::render_decision_for_unmatched_command(
self.approval_policy,
&self.sandbox_policy,
cmd,
self.sandbox_permissions,
used_complex_parsing,
)
};
let evaluation = {
let policy = self.policy.read().await;
policy.check_multiple(commands.iter(), &fallback)
evaluate_intercepted_exec_policy(
&policy,
program,
argv,
self.approval_policy,
&self.sandbox_policy,
self.sandbox_permissions,
)
};
// When true, means the Evaluation was due to *.rules, not the
// fallback function.
@@ -552,6 +542,43 @@ impl EscalationPolicy for CoreShellActionProvider {
}
}
fn evaluate_intercepted_exec_policy(
policy: &Policy,
program: &AbsolutePathBuf,
argv: &[String],
approval_policy: AskForApproval,
sandbox_policy: &SandboxPolicy,
sandbox_permissions: SandboxPermissions,
) -> Evaluation {
let command = join_program_and_argv(program, argv);
let (commands, used_complex_parsing) =
if let Some(commands) = parse_shell_lc_plain_commands(&command) {
(commands, false)
} else if let Some(single_command) = parse_shell_lc_single_command_prefix(&command) {
(vec![single_command], true)
} else {
(vec![command], false)
};
let fallback = |cmd: &[String]| {
crate::exec_policy::render_decision_for_unmatched_command(
approval_policy,
sandbox_policy,
cmd,
sandbox_permissions,
used_complex_parsing,
)
};
policy.check_multiple_with_options(
commands.iter(),
&fallback,
&MatchOptions {
resolve_host_executables: true,
},
)
}
struct CoreShellCommandExecutor {
command: Vec<String>,
cwd: PathBuf,
@@ -2,6 +2,7 @@ use super::CoreShellActionProvider;
#[cfg(target_os = "macos")]
use super::CoreShellCommandExecutor;
use super::ParsedShellCommand;
use super::evaluate_intercepted_exec_policy;
use super::extract_shell_script;
use super::join_program_and_argv;
use super::map_exec_result;
@@ -12,14 +13,16 @@ use crate::config::Permissions;
#[cfg(target_os = "macos")]
use crate::config::types::ShellEnvironmentPolicy;
use crate::exec::SandboxType;
#[cfg(target_os = "macos")]
use crate::protocol::AskForApproval;
use crate::protocol::ReadOnlyAccess;
use crate::protocol::SandboxPolicy;
#[cfg(target_os = "macos")]
use crate::sandboxing::SandboxPermissions;
#[cfg(target_os = "macos")]
use crate::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
use codex_execpolicy::PolicyParser;
use codex_execpolicy::RuleMatch;
#[cfg(target_os = "macos")]
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::FileSystemPermissions;
@@ -38,6 +41,22 @@ use pretty_assertions::assert_eq;
use std::collections::HashMap;
use std::time::Duration;
fn host_absolute_path(segments: &[&str]) -> String {
let mut path = if cfg!(windows) {
PathBuf::from(r"C:\")
} else {
PathBuf::from("/")
};
for segment in segments {
path.push(segment);
}
path.to_string_lossy().into_owned()
}
fn starlark_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
#[test]
fn extract_shell_script_preserves_login_flag() {
assert_eq!(
@@ -203,6 +222,84 @@ fn shell_request_escalation_execution_is_explicit() {
);
}
#[test]
fn intercepted_exec_policy_uses_host_executable_mappings() {
let git_path = host_absolute_path(&["usr", "bin", "git"]);
let git_path_literal = starlark_string(&git_path);
let policy_src = format!(
r#"
prefix_rule(pattern = ["git", "status"], decision = "prompt")
host_executable(name = "git", paths = ["{git_path_literal}"])
"#
);
let mut parser = PolicyParser::new();
parser.parse("test.rules", &policy_src).unwrap();
let policy = parser.build();
let program = AbsolutePathBuf::try_from(git_path).unwrap();
let evaluation = evaluate_intercepted_exec_policy(
&policy,
&program,
&["git".to_string(), "status".to_string()],
AskForApproval::OnRequest,
&SandboxPolicy::new_read_only_policy(),
SandboxPermissions::UseDefault,
);
assert_eq!(
evaluation,
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec!["git".to_string(), "status".to_string()],
decision: Decision::Prompt,
resolved_program: Some(program),
justification: None,
}],
}
);
assert!(CoreShellActionProvider::decision_driven_by_policy(
&evaluation.matched_rules,
evaluation.decision
));
}
#[test]
fn intercepted_exec_policy_rejects_disallowed_host_executable_mapping() {
let allowed_git = host_absolute_path(&["usr", "bin", "git"]);
let other_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]);
let allowed_git_literal = starlark_string(&allowed_git);
let policy_src = format!(
r#"
prefix_rule(pattern = ["git", "status"], decision = "prompt")
host_executable(name = "git", paths = ["{allowed_git_literal}"])
"#
);
let mut parser = PolicyParser::new();
parser.parse("test.rules", &policy_src).unwrap();
let policy = parser.build();
let program = AbsolutePathBuf::try_from(other_git.clone()).unwrap();
let evaluation = evaluate_intercepted_exec_policy(
&policy,
&program,
&["git".to_string(), "status".to_string()],
AskForApproval::OnRequest,
&SandboxPolicy::new_read_only_policy(),
SandboxPermissions::UseDefault,
);
assert!(matches!(
evaluation.matched_rules.as_slice(),
[RuleMatch::HeuristicsRuleMatch { command, .. }]
if command == &vec![other_git, "status".to_string()]
));
assert!(!CoreShellActionProvider::decision_driven_by_policy(
&evaluation.matched_rules,
evaluation.decision
));
}
#[cfg(target_os = "macos")]
#[tokio::test]
async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions() {
+82 -1
View File
@@ -264,7 +264,7 @@ impl ResponsesRequest {
.cloned()
.unwrap_or(Value::Null);
match output {
Value::String(text) => Some((Some(text), None)),
Value::String(_) | Value::Array(_) => Some((output_value_to_text(&output), None)),
Value::Object(obj) => Some((
obj.get("content")
.and_then(Value::as_str)
@@ -296,6 +296,87 @@ impl ResponsesRequest {
}
}
pub(crate) fn output_value_to_text(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Array(items) => match items.as_slice() {
[item] if item.get("type").and_then(Value::as_str) == Some("input_text") => {
item.get("text").and_then(Value::as_str).map(str::to_string)
}
[_] | [] | [_, _, ..] => None,
},
Value::Object(_) | Value::Number(_) | Value::Bool(_) | Value::Null => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use wiremock::http::HeaderMap;
use wiremock::http::Method;
fn request_with_input(input: Value) -> ResponsesRequest {
ResponsesRequest(wiremock::Request {
url: "http://localhost/v1/responses"
.parse()
.expect("valid request url"),
method: Method::POST,
headers: HeaderMap::new(),
body: serde_json::to_vec(&serde_json::json!({ "input": input }))
.expect("serialize request body"),
})
}
#[test]
fn call_output_content_and_success_returns_only_single_text_content_item() {
let single_text = request_with_input(serde_json::json!([
{
"type": "function_call_output",
"call_id": "call-1",
"output": [{ "type": "input_text", "text": "hello" }]
},
{
"type": "custom_tool_call_output",
"call_id": "call-2",
"output": [{ "type": "input_text", "text": "world" }]
}
]));
assert_eq!(
single_text.function_call_output_content_and_success("call-1"),
Some((Some("hello".to_string()), None))
);
assert_eq!(
single_text.custom_tool_call_output_content_and_success("call-2"),
Some((Some("world".to_string()), None))
);
let mixed_content = request_with_input(serde_json::json!([
{
"type": "function_call_output",
"call_id": "call-3",
"output": [
{ "type": "input_text", "text": "hello" },
{ "type": "input_image", "image_url": "data:image/png;base64,abc" }
]
},
{
"type": "custom_tool_call_output",
"call_id": "call-4",
"output": [{ "type": "input_image", "image_url": "data:image/png;base64,abc" }]
}
]));
assert_eq!(
mixed_content.function_call_output_content_and_success("call-3"),
Some((None, None))
);
assert_eq!(
mixed_content.custom_tool_call_output_content_and_success("call-4"),
Some((None, None))
);
}
}
#[derive(Debug, Clone)]
pub struct WebSocketRequest {
body: Value,
+43 -5
View File
@@ -23,6 +23,7 @@ use wiremock::MockServer;
use crate::load_default_config_for_test;
use crate::responses::WebSocketTestServer;
use crate::responses::output_value_to_text;
use crate::responses::start_mock_server;
use crate::streaming_sse::StreamingSseServer;
use crate::wait_for_event;
@@ -394,11 +395,7 @@ impl TestCodexHarness {
pub async fn custom_tool_call_output(&self, call_id: &str) -> String {
let bodies = self.request_bodies().await;
custom_tool_call_output(&bodies, call_id)
.get("output")
.and_then(Value::as_str)
.expect("output string")
.to_string()
custom_tool_call_output_text(&bodies, call_id)
}
pub async fn apply_patch_output(
@@ -433,6 +430,14 @@ fn custom_tool_call_output<'a>(bodies: &'a [Value], call_id: &str) -> &'a Value
panic!("custom_tool_call_output {call_id} not found");
}
fn custom_tool_call_output_text(bodies: &[Value], call_id: &str) -> String {
let output = custom_tool_call_output(bodies, call_id)
.get("output")
.unwrap_or_else(|| panic!("custom_tool_call_output {call_id} missing output"));
output_value_to_text(output)
.unwrap_or_else(|| panic!("custom_tool_call_output {call_id} missing text output"))
}
fn function_call_output<'a>(bodies: &'a [Value], call_id: &str) -> &'a Value {
for body in bodies {
if let Some(items) = body.get("input").and_then(Value::as_array) {
@@ -456,3 +461,36 @@ pub fn test_codex() -> TestCodexBuilder {
home: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn custom_tool_call_output_text_returns_output_text() {
let bodies = vec![json!({
"input": [{
"type": "custom_tool_call_output",
"call_id": "call-1",
"output": "hello"
}]
})];
assert_eq!(custom_tool_call_output_text(&bodies, "call-1"), "hello");
}
#[test]
#[should_panic(expected = "custom_tool_call_output call-2 missing output")]
fn custom_tool_call_output_text_panics_when_output_is_missing() {
let bodies = vec![json!({
"input": [{
"type": "custom_tool_call_output",
"call_id": "call-2"
}]
})];
let _ = custom_tool_call_output_text(&bodies, "call-2");
}
}
+143 -1
View File
@@ -35,6 +35,10 @@ use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
use core_test_support::apps_test_server::AppsTestServer;
@@ -344,6 +348,144 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
assert!(pos_environment < pos_new_user);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn resume_replays_legacy_js_repl_image_rollout_shapes() {
skip_if_no_network!();
// Early js_repl builds persisted image tool results as two separate rollout items:
// a string-valued custom_tool_call_output plus a standalone user input_image message.
// Current image tests cover today's shapes; this keeps resume compatibility for that
// legacy rollout representation.
let legacy_custom_tool_call = ResponseItem::CustomToolCall {
id: None,
status: None,
call_id: "legacy-js-call".to_string(),
name: "js_repl".to_string(),
input: "console.log('legacy image flow')".to_string(),
};
let legacy_image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==";
let rollout = vec![
RolloutLine {
timestamp: "2024-01-01T00:00:00.000Z".to_string(),
item: RolloutItem::SessionMeta(SessionMetaLine {
meta: SessionMeta {
id: ThreadId::default(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
cwd: ".".into(),
originator: "test_originator".to_string(),
cli_version: "test_version".to_string(),
model_provider: Some("test-provider".to_string()),
..Default::default()
},
git: None,
}),
},
RolloutLine {
timestamp: "2024-01-01T00:00:01.000Z".to_string(),
item: RolloutItem::ResponseItem(legacy_custom_tool_call),
},
RolloutLine {
timestamp: "2024-01-01T00:00:02.000Z".to_string(),
item: RolloutItem::ResponseItem(ResponseItem::CustomToolCallOutput {
call_id: "legacy-js-call".to_string(),
output: FunctionCallOutputPayload::from_text("legacy js_repl stdout".to_string()),
}),
},
RolloutLine {
timestamp: "2024-01-01T00:00:03.000Z".to_string(),
item: RolloutItem::ResponseItem(ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputImage {
image_url: legacy_image_url.to_string(),
}],
end_turn: None,
phase: None,
}),
},
];
let tmpdir = TempDir::new().unwrap();
let session_path = tmpdir
.path()
.join("resume-legacy-js-repl-image-rollout.jsonl");
let mut f = std::fs::File::create(&session_path).unwrap();
for line in rollout {
writeln!(f, "{}", serde_json::to_string(&line).unwrap()).unwrap();
}
let server = MockServer::start().await;
let resp_mock = mount_sse_once(
&server,
sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
)
.await;
let codex_home = Arc::new(TempDir::new().unwrap());
let mut builder = test_codex().with_model("gpt-5.1");
let test = builder
.resume(&server, codex_home, session_path.clone())
.await
.expect("resume conversation");
test.submit_turn("after resume").await.unwrap();
let input = resp_mock.single_request().input();
let legacy_output_index = input
.iter()
.position(|item| {
item.get("type").and_then(|value| value.as_str()) == Some("custom_tool_call_output")
&& item.get("call_id").and_then(|value| value.as_str()) == Some("legacy-js-call")
})
.expect("legacy custom tool output should be replayed");
assert_eq!(
input[legacy_output_index]
.get("output")
.and_then(|value| value.as_str()),
Some("legacy js_repl stdout")
);
let legacy_image_index = input
.iter()
.position(|item| {
item.get("type").and_then(|value| value.as_str()) == Some("message")
&& item.get("role").and_then(|value| value.as_str()) == Some("user")
&& item
.get("content")
.and_then(|value| value.as_array())
.is_some_and(|content| {
content.iter().any(|entry| {
entry.get("type").and_then(|value| value.as_str())
== Some("input_image")
&& entry.get("image_url").and_then(|value| value.as_str())
== Some(legacy_image_url)
})
})
})
.expect("legacy injected image message should be replayed");
let new_user_index = input
.iter()
.position(|item| {
item.get("type").and_then(|value| value.as_str()) == Some("message")
&& item.get("role").and_then(|value| value.as_str()) == Some("user")
&& item
.get("content")
.and_then(|value| value.as_array())
.is_some_and(|content| {
content.iter().any(|entry| {
entry.get("type").and_then(|value| value.as_str()) == Some("input_text")
&& entry.get("text").and_then(|value| value.as_str())
== Some("after resume")
})
})
})
.expect("new user message should be present");
assert!(legacy_output_index < new_user_index);
assert!(legacy_image_index < new_user_index);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn includes_conversation_id_and_model_headers_in_request() {
skip_if_no_network!();
@@ -1565,7 +1707,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
});
prompt.input.push(ResponseItem::CustomToolCallOutput {
call_id: "custom-tool-call-id".into(),
output: "ok".into(),
output: FunctionCallOutputPayload::from_text("ok".into()),
});
let mut stream = client_session
@@ -237,6 +237,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result<
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
@@ -395,6 +396,7 @@ async fn model_switch_to_smaller_model_updates_token_context_window() -> Result<
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
@@ -339,6 +339,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo {
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
+2
View File
@@ -602,6 +602,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
@@ -710,6 +711,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() -
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
@@ -297,6 +297,7 @@ async fn remote_models_remote_model_uses_unified_exec() -> Result<()> {
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
@@ -534,6 +535,7 @@ async fn remote_models_apply_remote_base_instructions() -> Result<()> {
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
@@ -995,6 +997,7 @@ fn test_remote_model_with_policy(
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy,
supports_parallel_tool_calls: false,
+7
View File
@@ -104,6 +104,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
@@ -246,6 +247,7 @@ async fn stdio_image_responses_round_trip() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
@@ -401,6 +403,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
@@ -463,6 +466,7 @@ async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Re
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
@@ -581,6 +585,7 @@ async fn stdio_server_propagates_whitelisted_env_vars() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
@@ -740,6 +745,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
@@ -959,6 +965,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
+1
View File
@@ -133,6 +133,7 @@ fn rmcp_server_config(command: String) -> McpServerConfig {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
}
}
+3
View File
@@ -370,6 +370,7 @@ async fn mcp_tool_call_output_exceeds_limit_truncated_for_model() -> Result<()>
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
@@ -464,6 +465,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
@@ -729,6 +731,7 @@ async fn mcp_tool_call_output_not_truncated_with_custom_limit() -> Result<()> {
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
);
config
@@ -23,10 +23,6 @@ use responses::start_mock_server;
use std::time::Duration;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "flaky on ubuntu-24.04-arm - aarch64-unknown-linux-gnu"]
// The notify script gets far enough to create (and therefore surface) the file,
// but hasn’t flushed the JSON yet. Reading an empty file produces EOF while parsing
// a value at line 1 column 0. May be caused by a slow runner.
async fn summarize_context_three_requests_and_instructions() -> anyhow::Result<()> {
skip_if_no_network!(Ok(()));
@@ -43,7 +39,10 @@ async fn summarize_context_three_requests_and_instructions() -> anyhow::Result<(
&notify_script,
r#"#!/bin/bash
set -e
echo -n "${@: -1}" > $(dirname "${0}")/notify.txt"#,
payload_path="$(dirname "${0}")/notify.txt"
tmp_path="${payload_path}.tmp"
echo -n "${@: -1}" > "${tmp_path}"
mv "${tmp_path}" "${payload_path}""#,
)?;
std::fs::set_permissions(&notify_script, std::fs::Permissions::from_mode(0o755))?;
+17 -30
View File
@@ -358,40 +358,26 @@ console.log(out.output?.body?.text ?? "");
.await;
let req = mock.single_request();
let (js_repl_output, js_repl_success) = req
.custom_tool_call_output_content_and_success(call_id)
.expect("custom tool output present");
let js_repl_output = js_repl_output.expect("custom tool output text present");
assert_ne!(
js_repl_success,
Some(false),
"js_repl call failed unexpectedly: {js_repl_output}"
let body = req.body_json();
assert_eq!(
image_messages(&body).len(),
0,
"js_repl view_image should not inject a pending input image message"
);
let body = req.body_json();
let image_messages = image_messages(&body);
assert_eq!(
image_messages.len(),
1,
"js_repl view_image should inject exactly one pending input image message"
);
let image_message = image_messages
.into_iter()
.next()
.expect("pending input image message not included in request");
let image_url = image_message
.get("content")
let custom_output = req.custom_tool_call_output(call_id);
let output_items = custom_output
.get("output")
.and_then(Value::as_array)
.and_then(|content| {
content.iter().find_map(|span| {
if span.get("type").and_then(Value::as_str) == Some("input_image") {
span.get("image_url").and_then(Value::as_str)
} else {
None
}
})
.expect("custom_tool_call_output should be a content item array");
let image_url = output_items
.iter()
.find_map(|item| {
(item.get("type").and_then(Value::as_str) == Some("input_image"))
.then(|| item.get("image_url").and_then(Value::as_str))
.flatten()
})
.expect("image_url present");
.expect("image_url present in js_repl custom tool output");
assert!(
image_url.starts_with("data:image/png;base64,"),
"expected png data URL, got {image_url}"
@@ -678,6 +664,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an
default_reasoning_summary: ReasoningSummary::Auto,
support_verbosity: false,
default_verbosity: None,
availability_nux: None,
apply_patch_tool_type: None,
truncation_policy: TruncationPolicyConfig::bytes(10_000),
supports_parallel_tool_calls: false,
+9 -3
View File
@@ -90,20 +90,26 @@ directory (it returns the restored thread summary).
Fetch the catalog of models available in the current Codex build with `model/list`. The request accepts optional pagination inputs:
- `pageSize` – number of models to return (defaults to a server-selected value)
- `limit` – number of models to return (defaults to a server-selected value)
- `cursor` – opaque string from the previous response’s `nextCursor`
Each response yields:
- `items` – ordered list of models. A model includes:
- `data` – ordered list of models. A model includes:
- `id`, `model`, `displayName`, `description`
- `supportedReasoningEfforts` – array of objects with:
- `reasoningEffort` – one of `minimal|low|medium|high`
- `reasoningEffort` – one of `none|minimal|low|medium|high|xhigh`
- `description` – human-friendly label for the effort
- `defaultReasoningEffort` – suggested effort for the UI
- `inputModalities` – accepted input types for the model
- `supportsPersonality` – whether the model supports personality-specific instructions
- `isDefault` – whether the model is recommended for most users
- `upgrade` – optional recommended upgrade model id
- `upgradeInfo` – optional upgrade metadata object with:
- `model` – recommended upgrade model id
- `upgradeCopy` – optional display copy for the upgrade recommendation
- `modelLink` – optional link for the upgrade recommendation
- `migrationMarkdown` – optional markdown shown when presenting the upgrade
- `nextCursor` – pass into the next request to continue paging (optional)
## Collaboration modes (experimental)
+1
View File
@@ -19,6 +19,7 @@ workspace = true
[dependencies]
anyhow = { workspace = true }
clap = { workspace = true, features = ["derive"] }
codex-utils-absolute-path = { workspace = true }
multimap = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
+34 -2
View File
@@ -2,8 +2,8 @@
## Overview
- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, justification?, match?, not_match?)`.
- This release covers the prefix-rule subset of the execpolicy language; a richer language will follow.
- Policy engine and CLI built around `prefix_rule(pattern=[...], decision?, justification?, match?, not_match?)` plus `host_executable(name=..., paths=[...])`.
- This release covers the prefix-rule subset of the execpolicy language plus host executable metadata; a richer language will follow.
- Tokens are matched in order; any `pattern` element may be a list to denote alternatives. `decision` defaults to `allow`; valid values: `allow`, `prompt`, `forbidden`.
- `justification` is an optional human-readable rationale for why a rule exists. It can be provided for any `decision` and may be surfaced in different contexts (for example, in approval prompts or rejection messages). When `decision = "forbidden"` is used, include a recommended alternative in the `justification`, when appropriate (e.g., ``"Use `jj` instead of `git`."``).
- `match` / `not_match` supply example invocations that are validated at load time (think of them as unit tests); examples can be token arrays or strings (strings are tokenized with `shlex`).
@@ -24,6 +24,27 @@ prefix_rule(
)
```
- Host executable metadata can optionally constrain which absolute paths may
resolve through basename rules:
```starlark
host_executable(
name = "git",
paths = [
"/Users/example/.openai/bin/git",
"/opt/homebrew/bin/git",
"/usr/bin/git",
],
)
```
- Matching semantics:
- execpolicy always tries exact first-token matches first.
- With host-executable resolution disabled, `/usr/bin/git status` only matches a rule whose first token is `/usr/bin/git`.
- With host-executable resolution enabled, if no exact rule matches, execpolicy may fall back from `/usr/bin/git` to basename rules for `git`.
- If `host_executable(name="git", ...)` exists, basename fallback is only allowed for listed absolute paths.
- If no `host_executable()` entry exists for a basename, basename fallback is allowed.
## CLI
- From the Codex CLI, run `codex execpolicy check` subcommand with one or more policy files (for example `src/default.rules`) to check a command:
@@ -32,6 +53,15 @@ prefix_rule(
codex execpolicy check --rules path/to/policy.rules git status
```
- To opt into basename fallback for absolute program paths, pass `--resolve-host-executables`:
```bash
codex execpolicy check \
--rules path/to/policy.rules \
--resolve-host-executables \
/usr/bin/git status
```
- Pass multiple `--rules` flags to merge rules, evaluated in the order provided, and use `--pretty` for formatted JSON.
- You can also run the standalone dev binary directly during development:
@@ -52,6 +82,7 @@ cargo run -p codex-execpolicy -- check --rules path/to/policy.rules git status
"prefixRuleMatch": {
"matchedPrefix": ["<token>", "..."],
"decision": "allow|prompt|forbidden",
"resolvedProgram": "/absolute/path/to/program",
"justification": "..."
}
}
@@ -62,6 +93,7 @@ cargo run -p codex-execpolicy -- check --rules path/to/policy.rules git status
- When no rules match, `matchedRules` is an empty array and `decision` is omitted.
- `matchedRules` lists every rule whose prefix matched the command; `matchedPrefix` is the exact prefix that matched.
- `resolvedProgram` is omitted unless an absolute executable path matched via basename fallback.
- The effective `decision` is the strictest severity across all matches (`forbidden` > `prompt` > `allow`).
Note: `execpolicy` commands are still in preview. The API may have breaking changes in the future.
+13 -1
View File
@@ -7,6 +7,7 @@ use clap::Parser;
use serde::Serialize;
use crate::Decision;
use crate::MatchOptions;
use crate::Policy;
use crate::PolicyParser;
use crate::RuleMatch;
@@ -22,6 +23,11 @@ pub struct ExecPolicyCheckCommand {
#[arg(long)]
pub pretty: bool,
/// Resolve absolute program paths against basename rules, gated by any
/// `host_executable()` definitions in the loaded policy files.
#[arg(long)]
pub resolve_host_executables: bool,
/// Command tokens to check against the policy.
#[arg(
value_name = "COMMAND",
@@ -36,7 +42,13 @@ impl ExecPolicyCheckCommand {
/// Load the policies for this command, evaluate the command, and render JSON output.
pub fn run(&self) -> Result<()> {
let policy = load_policies(&self.rules)?;
let matched_rules = policy.matches_for_command(&self.command, None);
let matched_rules = policy.matches_for_command_with_options(
&self.command,
None,
&MatchOptions {
resolve_host_executables: self.resolve_host_executables,
},
);
let json = format_matches_json(&matched_rules, self.pretty)?;
println!("{json}");
+1
View File
@@ -18,6 +18,7 @@ pub use error::TextRange;
pub use execpolicycheck::ExecPolicyCheckCommand;
pub use parser::PolicyParser;
pub use policy::Evaluation;
pub use policy::MatchOptions;
pub use policy::Policy;
pub use rule::NetworkRuleProtocol;
pub use rule::Rule;
+75 -1
View File
@@ -1,3 +1,4 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use multimap::MultiMap;
use shlex;
use starlark::any::ProvidesStaticType;
@@ -13,6 +14,8 @@ use starlark::values::list::UnpackList;
use starlark::values::none::NoneType;
use std::cell::RefCell;
use std::cell::RefMut;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use crate::decision::Decision;
@@ -74,6 +77,7 @@ impl PolicyParser {
struct PolicyBuilder {
rules_by_program: MultiMap<String, RuleRef>,
network_rules: Vec<NetworkRule>,
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
}
impl PolicyBuilder {
@@ -81,6 +85,7 @@ impl PolicyBuilder {
Self {
rules_by_program: MultiMap::new(),
network_rules: Vec::new(),
host_executables_by_name: HashMap::new(),
}
}
@@ -93,8 +98,16 @@ impl PolicyBuilder {
self.network_rules.push(rule);
}
fn add_host_executable(&mut self, name: String, paths: Vec<AbsolutePathBuf>) {
self.host_executables_by_name.insert(name, paths.into());
}
fn build(self) -> crate::policy::Policy {
crate::policy::Policy::from_parts(self.rules_by_program, self.network_rules)
crate::policy::Policy::from_parts(
self.rules_by_program,
self.network_rules,
self.host_executables_by_name,
)
}
}
@@ -150,6 +163,36 @@ fn parse_examples<'v>(examples: UnpackList<Value<'v>>) -> Result<Vec<Vec<String>
examples.items.into_iter().map(parse_example).collect()
}
fn parse_literal_absolute_path(raw: &str) -> Result<AbsolutePathBuf> {
if !Path::new(raw).is_absolute() {
return Err(Error::InvalidRule(format!(
"host_executable paths must be absolute (got {raw})"
)));
}
AbsolutePathBuf::try_from(raw.to_string())
.map_err(|error| Error::InvalidRule(format!("invalid absolute path `{raw}`: {error}")))
}
fn validate_host_executable_name(name: &str) -> Result<()> {
if name.is_empty() {
return Err(Error::InvalidRule(
"host_executable name cannot be empty".to_string(),
));
}
let path = Path::new(name);
if path.components().count() != 1
|| path.file_name().and_then(|value| value.to_str()) != Some(name)
{
return Err(Error::InvalidRule(format!(
"host_executable name must be a bare executable name (got {name})"
)));
}
Ok(())
}
fn parse_network_rule_decision(raw: &str) -> Result<Decision> {
match raw {
"deny" => Ok(Decision::Forbidden),
@@ -308,4 +351,35 @@ fn policy_builtins(builder: &mut GlobalsBuilder) {
});
Ok(NoneType)
}
fn host_executable<'v>(
name: &'v str,
paths: UnpackList<Value<'v>>,
eval: &mut Evaluator<'v, '_, '_>,
) -> anyhow::Result<NoneType> {
validate_host_executable_name(name)?;
let mut parsed_paths = Vec::new();
for value in paths.items {
let raw = value.unpack_str().ok_or_else(|| {
Error::InvalidRule(format!(
"host_executable paths must be strings (got {})",
value.get_type()
))
})?;
let path = parse_literal_absolute_path(raw)?;
if path.as_path().file_name().and_then(|value| value.to_str()) != Some(name) {
return Err(Error::InvalidRule(format!(
"host_executable path `{raw}` must have basename `{name}`"
))
.into());
}
if !parsed_paths.iter().any(|existing| existing == &path) {
parsed_paths.push(path);
}
}
policy_builder(eval).add_host_executable(name.to_string(), parsed_paths);
Ok(NoneType)
}
}
+146 -11
View File
@@ -9,31 +9,42 @@ use crate::rule::PrefixRule;
use crate::rule::RuleMatch;
use crate::rule::RuleRef;
use crate::rule::normalize_network_rule_host;
use codex_utils_absolute_path::AbsolutePathBuf;
use multimap::MultiMap;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
type HeuristicsFallback<'a> = Option<&'a dyn Fn(&[String]) -> Decision>;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MatchOptions {
pub resolve_host_executables: bool,
}
#[derive(Clone, Debug)]
pub struct Policy {
rules_by_program: MultiMap<String, RuleRef>,
network_rules: Vec<NetworkRule>,
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
}
impl Policy {
pub fn new(rules_by_program: MultiMap<String, RuleRef>) -> Self {
Self::from_parts(rules_by_program, Vec::new())
Self::from_parts(rules_by_program, Vec::new(), HashMap::new())
}
pub fn from_parts(
rules_by_program: MultiMap<String, RuleRef>,
network_rules: Vec<NetworkRule>,
host_executables_by_name: HashMap<String, Arc<[AbsolutePathBuf]>>,
) -> Self {
Self {
rules_by_program,
network_rules,
host_executables_by_name,
}
}
@@ -49,6 +60,10 @@ impl Policy {
&self.network_rules
}
pub fn host_executables(&self) -> &HashMap<String, Arc<[AbsolutePathBuf]>> {
&self.host_executables_by_name
}
pub fn get_allowed_prefixes(&self) -> Vec<Vec<String>> {
let mut prefixes = Vec::new();
@@ -119,6 +134,36 @@ impl Policy {
Ok(())
}
pub fn set_host_executable_paths(&mut self, name: String, paths: Vec<AbsolutePathBuf>) {
self.host_executables_by_name.insert(name, paths.into());
}
pub fn merge_overlay(&self, overlay: &Policy) -> Policy {
let mut combined_rules = self.rules_by_program.clone();
for (program, rules) in overlay.rules_by_program.iter_all() {
for rule in rules {
combined_rules.insert(program.clone(), rule.clone());
}
}
let mut combined_network_rules = self.network_rules.clone();
combined_network_rules.extend(overlay.network_rules.iter().cloned());
let mut host_executables_by_name = self.host_executables_by_name.clone();
host_executables_by_name.extend(
overlay
.host_executables_by_name
.iter()
.map(|(name, paths)| (name.clone(), paths.clone())),
);
Policy::from_parts(
combined_rules,
combined_network_rules,
host_executables_by_name,
)
}
pub fn compiled_network_domains(&self) -> (Vec<String>, Vec<String>) {
let mut allowed = Vec::new();
let mut denied = Vec::new();
@@ -144,7 +189,25 @@ impl Policy {
where
F: Fn(&[String]) -> Decision,
{
let matched_rules = self.matches_for_command(cmd, Some(heuristics_fallback));
let matched_rules = self.matches_for_command_with_options(
cmd,
Some(heuristics_fallback),
&MatchOptions::default(),
);
Evaluation::from_matches(matched_rules)
}
pub fn check_with_options<F>(
&self,
cmd: &[String],
heuristics_fallback: &F,
options: &MatchOptions,
) -> Evaluation
where
F: Fn(&[String]) -> Decision,
{
let matched_rules =
self.matches_for_command_with_options(cmd, Some(heuristics_fallback), options);
Evaluation::from_matches(matched_rules)
}
@@ -154,6 +217,20 @@ impl Policy {
commands: Commands,
heuristics_fallback: &F,
) -> Evaluation
where
Commands: IntoIterator,
Commands::Item: AsRef<[String]>,
F: Fn(&[String]) -> Decision,
{
self.check_multiple_with_options(commands, heuristics_fallback, &MatchOptions::default())
}
pub fn check_multiple_with_options<Commands, F>(
&self,
commands: Commands,
heuristics_fallback: &F,
options: &MatchOptions,
) -> Evaluation
where
Commands: IntoIterator,
Commands::Item: AsRef<[String]>,
@@ -162,7 +239,11 @@ impl Policy {
let matched_rules: Vec<RuleMatch> = commands
.into_iter()
.flat_map(|command| {
self.matches_for_command(command.as_ref(), Some(heuristics_fallback))
self.matches_for_command_with_options(
command.as_ref(),
Some(heuristics_fallback),
options,
)
})
.collect();
@@ -181,14 +262,25 @@ impl Policy {
cmd: &[String],
heuristics_fallback: HeuristicsFallback<'_>,
) -> Vec<RuleMatch> {
let matched_rules: Vec<RuleMatch> = match cmd.first() {
Some(first) => self
.rules_by_program
.get_vec(first)
.map(|rules| rules.iter().filter_map(|rule| rule.matches(cmd)).collect())
.unwrap_or_default(),
None => Vec::new(),
};
self.matches_for_command_with_options(cmd, heuristics_fallback, &MatchOptions::default())
}
pub fn matches_for_command_with_options(
&self,
cmd: &[String],
heuristics_fallback: HeuristicsFallback<'_>,
options: &MatchOptions,
) -> Vec<RuleMatch> {
let matched_rules = self
.match_exact_rules(cmd)
.filter(|matched_rules| !matched_rules.is_empty())
.or_else(|| {
options
.resolve_host_executables
.then(|| self.match_host_executable_rules(cmd))
.filter(|matched_rules| !matched_rules.is_empty())
})
.unwrap_or_default();
if matched_rules.is_empty()
&& let Some(heuristics_fallback) = heuristics_fallback
@@ -201,6 +293,49 @@ impl Policy {
matched_rules
}
}
fn match_exact_rules(&self, cmd: &[String]) -> Option<Vec<RuleMatch>> {
let first = cmd.first()?;
Some(
self.rules_by_program
.get_vec(first)
.map(|rules| rules.iter().filter_map(|rule| rule.matches(cmd)).collect())
.unwrap_or_default(),
)
}
fn match_host_executable_rules(&self, cmd: &[String]) -> Vec<RuleMatch> {
let Some(first) = cmd.first() else {
return Vec::new();
};
if !Path::new(first).is_absolute() {
return Vec::new();
}
let Ok(program) = AbsolutePathBuf::try_from(first.clone()) else {
return Vec::new();
};
let Some(basename) = program.as_path().file_name().and_then(|name| name.to_str()) else {
return Vec::new();
};
let Some(rules) = self.rules_by_program.get_vec(basename) else {
return Vec::new();
};
if let Some(paths) = self.host_executables_by_name.get(basename)
&& !paths.iter().any(|path| path == &program)
{
return Vec::new();
}
let basename_command = std::iter::once(basename.to_string())
.chain(cmd.iter().skip(1).cloned())
.collect::<Vec<_>>();
rules
.iter()
.filter_map(|rule| rule.matches(&basename_command))
.map(|rule_match| rule_match.with_resolved_program(&program))
.collect()
}
}
fn upsert_domain(entries: &mut Vec<String>, host: &str) {
+21
View File
@@ -1,6 +1,7 @@
use crate::decision::Decision;
use crate::error::Error;
use crate::error::Result;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use serde::Serialize;
use shlex::try_join;
@@ -63,6 +64,8 @@ pub enum RuleMatch {
#[serde(rename = "matchedPrefix")]
matched_prefix: Vec<String>,
decision: Decision,
#[serde(rename = "resolvedProgram", skip_serializing_if = "Option::is_none")]
resolved_program: Option<AbsolutePathBuf>,
/// Optional rationale for why this rule exists.
///
/// This can be supplied for any decision and may be surfaced in different contexts
@@ -83,6 +86,23 @@ impl RuleMatch {
Self::HeuristicsRuleMatch { decision, .. } => *decision,
}
}
pub fn with_resolved_program(self, resolved_program: &AbsolutePathBuf) -> Self {
match self {
Self::PrefixRuleMatch {
matched_prefix,
decision,
justification,
..
} => Self::PrefixRuleMatch {
matched_prefix,
decision,
resolved_program: Some(resolved_program.clone()),
justification,
},
other => other,
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -210,6 +230,7 @@ impl Rule for PrefixRule {
.map(|matched_prefix| RuleMatch::PrefixRuleMatch {
matched_prefix,
decision: self.decision,
resolved_program: None,
justification: self.justification.clone(),
})
}
+316
View File
@@ -1,5 +1,6 @@
use std::any::Any;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::Context;
@@ -7,6 +8,7 @@ use anyhow::Result;
use codex_execpolicy::Decision;
use codex_execpolicy::Error;
use codex_execpolicy::Evaluation;
use codex_execpolicy::MatchOptions;
use codex_execpolicy::NetworkRuleProtocol;
use codex_execpolicy::Policy;
use codex_execpolicy::PolicyParser;
@@ -16,6 +18,7 @@ use codex_execpolicy::blocking_append_allow_prefix_rule;
use codex_execpolicy::rule::PatternToken;
use codex_execpolicy::rule::PrefixPattern;
use codex_execpolicy::rule::PrefixRule;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
@@ -31,6 +34,27 @@ fn prompt_all(_: &[String]) -> Decision {
Decision::Prompt
}
fn absolute_path(path: &str) -> AbsolutePathBuf {
AbsolutePathBuf::try_from(path.to_string())
.unwrap_or_else(|error| panic!("expected absolute path `{path}`: {error}"))
}
fn host_absolute_path(segments: &[&str]) -> String {
let mut path = if cfg!(windows) {
PathBuf::from(r"C:\")
} else {
PathBuf::from("/")
};
for segment in segments {
path.push(segment);
}
path.to_string_lossy().into_owned()
}
fn starlark_string(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum RuleSnapshot {
Prefix(PrefixRule),
@@ -125,6 +149,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
decision: Decision::Allow,
resolved_program: None,
justification: None,
}],
},
@@ -156,6 +181,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["rm"]),
decision: Decision::Forbidden,
resolved_program: None,
justification: Some("destructive command".to_string()),
}],
},
@@ -184,6 +210,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["ls"]),
decision: Decision::Allow,
resolved_program: None,
justification: Some("safe and commonly used".to_string()),
}],
},
@@ -236,6 +263,7 @@ fn add_prefix_rule_extends_policy() -> Result<()> {
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["ls", "-l"]),
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}],
},
@@ -305,6 +333,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
resolved_program: None,
justification: None,
}],
},
@@ -319,11 +348,13 @@ prefix_rule(
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
resolved_program: None,
justification: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "commit"]),
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
},
],
@@ -381,6 +412,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["bash", "-c"]),
decision: Decision::Allow,
resolved_program: None,
justification: None,
}],
},
@@ -394,6 +426,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["sh", "-l"]),
decision: Decision::Allow,
resolved_program: None,
justification: None,
}],
},
@@ -440,6 +473,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["npm", "i", "--legacy-peer-deps"]),
decision: Decision::Allow,
resolved_program: None,
justification: None,
}],
},
@@ -456,6 +490,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["npm", "install", "--no-save"]),
decision: Decision::Allow,
resolved_program: None,
justification: None,
}],
},
@@ -486,6 +521,7 @@ prefix_rule(
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
decision: Decision::Allow,
resolved_program: None,
justification: None,
}],
},
@@ -533,11 +569,13 @@ prefix_rule(
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
resolved_program: None,
justification: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "commit"]),
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
},
],
@@ -576,16 +614,19 @@ prefix_rule(
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
resolved_program: None,
justification: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
resolved_program: None,
justification: None,
},
RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "commit"]),
decision: Decision::Forbidden,
resolved_program: None,
justification: None,
},
],
@@ -612,3 +653,278 @@ fn heuristics_match_is_returned_when_no_policy_matches() {
evaluation
);
}
#[test]
fn parses_host_executable_paths() -> Result<()> {
let homebrew_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]);
let usr_git = host_absolute_path(&["usr", "bin", "git"]);
let homebrew_git_literal = starlark_string(&homebrew_git);
let usr_git_literal = starlark_string(&usr_git);
let policy_src = format!(
r#"
host_executable(
name = "git",
paths = [
"{homebrew_git_literal}",
"{usr_git_literal}",
"{usr_git_literal}",
],
)
"#
);
let mut parser = PolicyParser::new();
parser.parse("test.rules", &policy_src)?;
let policy = parser.build();
assert_eq!(
policy
.host_executables()
.get("git")
.expect("missing git host executable")
.as_ref(),
[absolute_path(&homebrew_git), absolute_path(&usr_git)]
);
Ok(())
}
#[test]
fn host_executable_rejects_non_absolute_path() {
let policy_src = r#"
host_executable(name = "git", paths = ["git"])
"#;
let mut parser = PolicyParser::new();
let err = parser
.parse("test.rules", policy_src)
.expect_err("expected parse error");
assert!(
err.to_string()
.contains("host_executable paths must be absolute")
);
}
#[test]
fn host_executable_rejects_name_with_path_separator() {
let git_path = host_absolute_path(&["usr", "bin", "git"]);
let git_path_literal = starlark_string(&git_path);
let policy_src =
format!(r#"host_executable(name = "{git_path_literal}", paths = ["{git_path_literal}"])"#);
let mut parser = PolicyParser::new();
let err = parser
.parse("test.rules", &policy_src)
.expect_err("expected parse error");
assert!(
err.to_string()
.contains("host_executable name must be a bare executable name")
);
}
#[test]
fn host_executable_rejects_path_with_wrong_basename() {
let rg_path = host_absolute_path(&["usr", "bin", "rg"]);
let rg_path_literal = starlark_string(&rg_path);
let policy_src = format!(r#"host_executable(name = "git", paths = ["{rg_path_literal}"])"#);
let mut parser = PolicyParser::new();
let err = parser
.parse("test.rules", &policy_src)
.expect_err("expected parse error");
assert!(err.to_string().contains("must have basename `git`"));
}
#[test]
fn host_executable_last_definition_wins() -> Result<()> {
let usr_git = host_absolute_path(&["usr", "bin", "git"]);
let homebrew_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]);
let usr_git_literal = starlark_string(&usr_git);
let homebrew_git_literal = starlark_string(&homebrew_git);
let mut parser = PolicyParser::new();
parser.parse(
"shared.rules",
&format!(r#"host_executable(name = "git", paths = ["{usr_git_literal}"])"#),
)?;
parser.parse(
"user.rules",
&format!(r#"host_executable(name = "git", paths = ["{homebrew_git_literal}"])"#),
)?;
let policy = parser.build();
assert_eq!(
policy
.host_executables()
.get("git")
.expect("missing git host executable")
.as_ref(),
[absolute_path(&homebrew_git)]
);
Ok(())
}
#[test]
fn host_executable_resolution_uses_basename_rule_when_allowed() -> Result<()> {
let git_path = host_absolute_path(&["usr", "bin", "git"]);
let git_path_literal = starlark_string(&git_path);
let policy_src = format!(
r#"
prefix_rule(pattern = ["git", "status"], decision = "prompt")
host_executable(name = "git", paths = ["{git_path_literal}"])
"#
);
let mut parser = PolicyParser::new();
parser.parse("test.rules", &policy_src)?;
let policy = parser.build();
let evaluation = policy.check_with_options(
&[git_path.clone(), "status".to_string()],
&allow_all,
&MatchOptions {
resolve_host_executables: true,
},
);
assert_eq!(
evaluation,
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git", "status"]),
decision: Decision::Prompt,
resolved_program: Some(absolute_path(&git_path)),
justification: None,
}],
}
);
Ok(())
}
#[test]
fn host_executable_resolution_respects_explicit_empty_allowlist() -> Result<()> {
let policy_src = r#"
prefix_rule(pattern = ["git"], decision = "prompt")
host_executable(name = "git", paths = [])
"#;
let mut parser = PolicyParser::new();
parser.parse("test.rules", policy_src)?;
let policy = parser.build();
let git_path = host_absolute_path(&["usr", "bin", "git"]);
let evaluation = policy.check_with_options(
&[git_path.clone(), "status".to_string()],
&allow_all,
&MatchOptions {
resolve_host_executables: true,
},
);
assert_eq!(
evaluation,
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
command: vec![git_path, "status".to_string()],
decision: Decision::Allow,
}],
}
);
Ok(())
}
#[test]
fn host_executable_resolution_ignores_path_not_in_allowlist() -> Result<()> {
let allowed_git = host_absolute_path(&["usr", "bin", "git"]);
let other_git = host_absolute_path(&["opt", "homebrew", "bin", "git"]);
let allowed_git_literal = starlark_string(&allowed_git);
let policy_src = format!(
r#"
prefix_rule(pattern = ["git"], decision = "prompt")
host_executable(name = "git", paths = ["{allowed_git_literal}"])
"#
);
let mut parser = PolicyParser::new();
parser.parse("test.rules", &policy_src)?;
let policy = parser.build();
let evaluation = policy.check_with_options(
&[other_git.clone(), "status".to_string()],
&allow_all,
&MatchOptions {
resolve_host_executables: true,
},
);
assert_eq!(
evaluation,
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::HeuristicsRuleMatch {
command: vec![other_git, "status".to_string()],
decision: Decision::Allow,
}],
}
);
Ok(())
}
#[test]
fn host_executable_resolution_falls_back_without_mapping() -> Result<()> {
let policy_src = r#"
prefix_rule(pattern = ["git"], decision = "prompt")
"#;
let mut parser = PolicyParser::new();
parser.parse("test.rules", policy_src)?;
let policy = parser.build();
let git_path = host_absolute_path(&["usr", "bin", "git"]);
let evaluation = policy.check_with_options(
&[git_path.clone(), "status".to_string()],
&allow_all,
&MatchOptions {
resolve_host_executables: true,
},
);
assert_eq!(
evaluation,
Evaluation {
decision: Decision::Prompt,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: tokens(&["git"]),
decision: Decision::Prompt,
resolved_program: Some(absolute_path(&git_path)),
justification: None,
}],
}
);
Ok(())
}
#[test]
fn host_executable_resolution_does_not_override_exact_match() -> Result<()> {
let git_path = host_absolute_path(&["usr", "bin", "git"]);
let git_path_literal = starlark_string(&git_path);
let policy_src = format!(
r#"
prefix_rule(pattern = ["{git_path_literal}"], decision = "allow")
prefix_rule(pattern = ["git"], decision = "prompt")
host_executable(name = "git", paths = ["{git_path_literal}"])
"#
);
let mut parser = PolicyParser::new();
parser.parse("test.rules", &policy_src)?;
let policy = parser.build();
let evaluation = policy.check_with_options(
&[git_path.clone(), "status".to_string()],
&allow_all,
&MatchOptions {
resolve_host_executables: true,
},
);
assert_eq!(
evaluation,
Evaluation {
decision: Decision::Allow,
matched_rules: vec![RuleMatch::PrefixRuleMatch {
matched_prefix: vec![git_path],
decision: Decision::Allow,
resolved_program: None,
justification: None,
}],
}
);
Ok(())
}
+2
View File
@@ -104,6 +104,7 @@ mod tests {
HookPayload {
session_id: ThreadId::new(),
cwd: PathBuf::from(CWD),
client: None,
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
@@ -172,6 +173,7 @@ mod tests {
HookPayload {
session_id: ThreadId::new(),
cwd: PathBuf::from(CWD),
client: None,
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
+4
View File
@@ -65,6 +65,8 @@ impl Hook {
pub struct HookPayload {
pub session_id: ThreadId,
pub cwd: PathBuf,
#[serde(skip_serializing_if = "Option::is_none")]
pub client: Option<String>,
#[serde(serialize_with = "serialize_triggered_at")]
pub triggered_at: DateTime<Utc>,
pub hook_event: HookEvent,
@@ -181,6 +183,7 @@ mod tests {
let payload = HookPayload {
session_id,
cwd: PathBuf::from("tmp"),
client: None,
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
@@ -218,6 +221,7 @@ mod tests {
let payload = HookPayload {
session_id,
cwd: PathBuf::from("tmp"),
client: None,
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
+27 -15
View File
@@ -1,4 +1,3 @@
use std::path::Path;
use std::process::Stdio;
use std::sync::Arc;
@@ -19,6 +18,8 @@ enum UserNotification {
thread_id: String,
turn_id: String,
cwd: String,
#[serde(skip_serializing_if = "Option::is_none")]
client: Option<String>,
/// Messages that the user sent to the agent to initiate the turn.
input_messages: Vec<String>,
@@ -28,13 +29,14 @@ enum UserNotification {
},
}
pub fn legacy_notify_json(hook_event: &HookEvent, cwd: &Path) -> Result<String, serde_json::Error> {
match hook_event {
pub fn legacy_notify_json(payload: &HookPayload) -> Result<String, serde_json::Error> {
match &payload.hook_event {
HookEvent::AfterAgent { event } => {
serde_json::to_string(&UserNotification::AgentTurnComplete {
thread_id: event.thread_id.to_string(),
turn_id: event.turn_id.clone(),
cwd: cwd.display().to_string(),
cwd: payload.cwd.display().to_string(),
client: payload.client.clone(),
input_messages: event.input_messages.clone(),
last_assistant_message: event.last_assistant_message.clone(),
})
@@ -56,7 +58,7 @@ pub fn notify_hook(argv: Vec<String>) -> Hook {
Some(command) => command,
None => return HookResult::Success,
};
if let Ok(notify_payload) = legacy_notify_json(&payload.hook_event, &payload.cwd) {
if let Ok(notify_payload) = legacy_notify_json(payload) {
command.arg(notify_payload);
}
@@ -91,6 +93,7 @@ mod tests {
"thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
"turn-id": "12345",
"cwd": "/Users/example/project",
"client": "codex-tui",
"input-messages": ["Rename `foo` to `bar` and update the callsites."],
"last-assistant-message": "Rename complete and verified `cargo build` succeeds.",
})
@@ -102,6 +105,7 @@ mod tests {
thread_id: "b5f6c1c2-1111-2222-3333-444455556666".to_string(),
turn_id: "12345".to_string(),
cwd: "/Users/example/project".to_string(),
client: Some("codex-tui".to_string()),
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
@@ -115,19 +119,27 @@ mod tests {
#[test]
fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> {
let hook_event = HookEvent::AfterAgent {
event: crate::HookEventAfterAgent {
thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666")
.expect("valid thread id"),
turn_id: "12345".to_string(),
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
let payload = HookPayload {
session_id: ThreadId::new(),
cwd: std::path::Path::new("/Users/example/project").to_path_buf(),
client: Some("codex-tui".to_string()),
triggered_at: chrono::Utc::now(),
hook_event: HookEvent::AfterAgent {
event: crate::HookEventAfterAgent {
thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666")
.expect("valid thread id"),
turn_id: "12345".to_string(),
input_messages: vec![
"Rename `foo` to `bar` and update the callsites.".to_string(),
],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
},
},
};
let serialized = legacy_notify_json(&hook_event, Path::new("/Users/example/project"))?;
let serialized = legacy_notify_json(&payload)?;
let actual: Value = serde_json::from_str(&serialized)?;
assert_eq!(actual, expected_notification_json());
+25 -2
View File
@@ -161,7 +161,7 @@ pub enum ResponseInputItem {
},
CustomToolCallOutput {
call_id: String,
output: String,
output: FunctionCallOutputPayload,
},
}
@@ -261,9 +261,12 @@ pub enum ResponseItem {
name: String,
input: String,
},
// `custom_tool_call_output.output` uses the same wire encoding as
// `function_call_output.output` so freeform tools can return either plain
// text or structured content items.
CustomToolCallOutput {
call_id: String,
output: String,
output: FunctionCallOutputPayload,
},
// Emitted by the Responses API when the agent triggers a web search.
// Example payload (from SSE `response.output_item.done`):
@@ -1538,6 +1541,26 @@ mod tests {
Ok(())
}
#[test]
fn serializes_custom_tool_image_outputs_as_array() -> Result<()> {
let item = ResponseInputItem::CustomToolCallOutput {
call_id: "call1".into(),
output: FunctionCallOutputPayload::from_content_items(vec![
FunctionCallOutputContentItem::InputImage {
image_url: "data:image/png;base64,BASE64".into(),
},
]),
};
let json = serde_json::to_string(&item)?;
let v: serde_json::Value = serde_json::from_str(&json)?;
let output = v.get("output").expect("output field");
assert!(output.is_array(), "expected array output");
Ok(())
}
#[test]
fn preserves_existing_image_data_urls() -> Result<()> {
let call_tool_result = CallToolResult {
+63
View File
@@ -99,6 +99,11 @@ pub struct ModelUpgrade {
pub migration_markdown: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq, Eq)]
pub struct ModelAvailabilityNux {
pub message: String,
}
/// Metadata describing a Codex-supported model.
#[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)]
pub struct ModelPreset {
@@ -123,6 +128,8 @@ pub struct ModelPreset {
pub upgrade: Option<ModelUpgrade>,
/// Whether this preset should appear in the picker UI.
pub show_in_picker: bool,
/// Availability NUX shown when this preset becomes accessible to the user.
pub availability_nux: Option<ModelAvailabilityNux>,
/// whether this model is supported in the api
pub supported_in_api: bool,
/// Input modalities accepted when composing user turns for this preset.
@@ -225,6 +232,7 @@ pub struct ModelInfo {
pub visibility: ModelVisibility,
pub supported_in_api: bool,
pub priority: i32,
pub availability_nux: Option<ModelAvailabilityNux>,
pub upgrade: Option<ModelInfoUpgrade>,
pub base_instructions: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -410,6 +418,7 @@ impl From<ModelInfo> for ModelPreset {
migration_markdown: Some(upgrade.migration_markdown.clone()),
}),
show_in_picker: info.visibility == ModelVisibility::List,
availability_nux: info.availability_nux,
supported_in_api: info.supported_in_api,
input_modalities: info.input_modalities,
}
@@ -495,6 +504,7 @@ mod tests {
visibility: ModelVisibility::List,
supported_in_api: true,
priority: 1,
availability_nux: None,
upgrade: None,
base_instructions: "base".to_string(),
model_messages: spec,
@@ -668,4 +678,57 @@ mod tests {
);
assert_eq!(personality_variables.get_personality_message(None), None);
}
#[test]
fn model_info_defaults_availability_nux_to_none_when_omitted() {
let model: ModelInfo = serde_json::from_value(serde_json::json!({
"slug": "test-model",
"display_name": "Test Model",
"description": null,
"supported_reasoning_levels": [],
"shell_type": "shell_command",
"visibility": "list",
"supported_in_api": true,
"priority": 1,
"upgrade": null,
"base_instructions": "base",
"model_messages": null,
"supports_reasoning_summaries": false,
"default_reasoning_summary": "auto",
"support_verbosity": false,
"default_verbosity": null,
"apply_patch_tool_type": null,
"truncation_policy": {
"mode": "bytes",
"limit": 10000
},
"supports_parallel_tool_calls": false,
"context_window": null,
"auto_compact_token_limit": null,
"effective_context_window_percent": 95,
"experimental_supported_tools": [],
"input_modalities": ["text", "image"],
"prefer_websockets": false
}))
.expect("deserialize model info");
assert_eq!(model.availability_nux, None);
}
#[test]
fn model_preset_preserves_availability_nux() {
let preset = ModelPreset::from(ModelInfo {
availability_nux: Some(ModelAvailabilityNux {
message: "Try Spark.".to_string(),
}),
..test_model(None)
});
assert_eq!(
preset.availability_nux,
Some(ModelAvailabilityNux {
message: "Try Spark.".to_string(),
})
);
}
}
@@ -47,6 +47,7 @@ pub async fn perform_oauth_login(
http_headers: Option<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
callback_port: Option<u16>,
callback_url: Option<&str>,
) -> Result<()> {
@@ -60,6 +61,7 @@ pub async fn perform_oauth_login(
store_mode,
headers,
scopes,
oauth_resource,
true,
callback_port,
callback_url,
@@ -78,6 +80,7 @@ pub async fn perform_oauth_login_return_url(
http_headers: Option<HashMap<String, String>>,
env_http_headers: Option<HashMap<String, String>>,
scopes: &[String],
oauth_resource: Option<&str>,
timeout_secs: Option<i64>,
callback_port: Option<u16>,
callback_url: Option<&str>,
@@ -92,6 +95,7 @@ pub async fn perform_oauth_login_return_url(
store_mode,
headers,
scopes,
oauth_resource,
false,
callback_port,
callback_url,
@@ -303,6 +307,7 @@ impl OauthLoginFlow {
store_mode: OAuthCredentialsStoreMode,
headers: OauthHeaders,
scopes: &[String],
oauth_resource: Option<&str>,
launch_browser: bool,
callback_port: Option<u16>,
callback_url: Option<&str>,
@@ -340,7 +345,11 @@ impl OauthLoginFlow {
oauth_state
.start_authorization(&scope_refs, &redirect_uri, Some("Codex"))
.await?;
let auth_url = oauth_state.get_authorization_url().await?;
let auth_url = append_query_param(
&oauth_state.get_authorization_url().await?,
"resource",
oauth_resource,
);
let timeout_secs = timeout_secs.unwrap_or(DEFAULT_OAUTH_TIMEOUT_SECS).max(1);
let timeout = Duration::from_secs(timeout_secs as u64);
@@ -431,9 +440,29 @@ impl OauthLoginFlow {
}
}
fn append_query_param(url: &str, key: &str, value: Option<&str>) -> String {
let Some(value) = value else {
return url.to_string();
};
let value = value.trim();
if value.is_empty() {
return url.to_string();
}
if let Ok(mut parsed) = Url::parse(url) {
parsed.query_pairs_mut().append_pair(key, value);
return parsed.to_string();
}
let encoded = urlencoding::encode(value);
let separator = if url.contains('?') { "&" } else { "?" };
format!("{url}{separator}{key}={encoded}")
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::CallbackOutcome;
use super::append_query_param;
use super::callback_path_from_redirect_uri;
use super::parse_oauth_callback;
@@ -461,4 +490,36 @@ mod tests {
.expect("redirect URI should parse");
assert_eq!(path, "/oauth/callback");
}
#[test]
fn append_query_param_adds_resource_to_absolute_url() {
let url = append_query_param(
"https://example.com/authorize?scope=read",
"resource",
Some("https://api.example.com"),
);
assert_eq!(
url,
"https://example.com/authorize?scope=read&resource=https%3A%2F%2Fapi.example.com"
);
}
#[test]
fn append_query_param_ignores_empty_values() {
let url = append_query_param(
"https://example.com/authorize?scope=read",
"resource",
Some(" "),
);
assert_eq!(url, "https://example.com/authorize?scope=read");
}
#[test]
fn append_query_param_handles_unparseable_url() {
let url = append_query_param("not a url", "resource", Some("api/resource"));
assert_eq!(url, "not a url?resource=api%2Fresource");
}
}
+73
View File
@@ -97,6 +97,7 @@ WHERE thread_id = ?
/// Query behavior:
/// - starts from `threads` filtered to active threads and allowed sources
/// (`push_thread_filters`)
/// - excludes threads with `memory_mode != 'enabled'`
/// - excludes the current thread id
/// - keeps only threads in the age window:
/// `updated_at >= now - max_age_days` and `updated_at <= now - min_rollout_idle_hours`
@@ -174,6 +175,7 @@ LEFT JOIN jobs
SortKey::UpdatedAt,
None,
);
builder.push(" AND threads.memory_mode = 'enabled'");
builder
.push(" AND id != ")
.push_bind(current_thread_id.as_str());
@@ -1591,6 +1593,77 @@ mod tests {
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn claim_stage1_jobs_skips_threads_with_disabled_memory_mode() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("initialize runtime");
let now = Utc::now();
let eligible_at = now - Duration::hours(13);
let current_thread_id =
ThreadId::from_string(&Uuid::new_v4().to_string()).expect("current thread id");
let disabled_thread_id =
ThreadId::from_string(&Uuid::new_v4().to_string()).expect("disabled thread id");
let enabled_thread_id =
ThreadId::from_string(&Uuid::new_v4().to_string()).expect("enabled thread id");
let mut current =
test_thread_metadata(&codex_home, current_thread_id, codex_home.join("current"));
current.created_at = now;
current.updated_at = now;
runtime
.upsert_thread(&current)
.await
.expect("upsert current thread");
let mut disabled =
test_thread_metadata(&codex_home, disabled_thread_id, codex_home.join("disabled"));
disabled.created_at = eligible_at;
disabled.updated_at = eligible_at;
runtime
.upsert_thread(&disabled)
.await
.expect("upsert disabled thread");
sqlx::query("UPDATE threads SET memory_mode = 'disabled' WHERE id = ?")
.bind(disabled_thread_id.to_string())
.execute(runtime.pool.as_ref())
.await
.expect("disable thread memory mode");
let mut enabled =
test_thread_metadata(&codex_home, enabled_thread_id, codex_home.join("enabled"));
enabled.created_at = eligible_at;
enabled.updated_at = eligible_at;
runtime
.upsert_thread(&enabled)
.await
.expect("upsert enabled thread");
let allowed_sources = vec!["cli".to_string()];
let claims = runtime
.claim_stage1_jobs_for_startup(
current_thread_id,
Stage1StartupClaimParams {
scan_limit: 10,
max_claimed: 10,
max_age_days: 30,
min_rollout_idle_hours: 12,
allowed_sources: allowed_sources.as_slice(),
lease_seconds: 3600,
},
)
.await
.expect("claim stage1 startup jobs");
assert_eq!(claims.len(), 1);
assert_eq!(claims[0].thread.id, enabled_thread_id);
let _ = tokio::fs::remove_dir_all(codex_home).await;
}
#[tokio::test]
async fn claim_stage1_jobs_enforces_global_running_cap() {
let codex_home = unique_temp_dir();
+70 -6
View File
@@ -195,6 +195,15 @@ FROM threads
/// Insert or replace thread metadata directly.
pub async fn upsert_thread(&self, metadata: &crate::ThreadMetadata) -> anyhow::Result<()> {
self.upsert_thread_with_creation_memory_mode(metadata, None)
.await
}
async fn upsert_thread_with_creation_memory_mode(
&self,
metadata: &crate::ThreadMetadata,
creation_memory_mode: Option<&str>,
) -> anyhow::Result<()> {
sqlx::query(
r#"
INSERT INTO threads (
@@ -217,8 +226,9 @@ INSERT INTO threads (
archived_at,
git_sha,
git_branch,
git_origin_url
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
git_origin_url,
memory_mode
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
rollout_path = excluded.rollout_path,
created_at = excluded.created_at,
@@ -261,6 +271,7 @@ ON CONFLICT(id) DO UPDATE SET
.bind(metadata.git_sha.as_deref())
.bind(metadata.git_branch.as_deref())
.bind(metadata.git_origin_url.as_deref())
.bind(creation_memory_mode.unwrap_or("enabled"))
.execute(self.pool.as_ref())
.await?;
Ok(())
@@ -316,13 +327,14 @@ ON CONFLICT(thread_id, position) DO NOTHING
builder: &ThreadMetadataBuilder,
items: &[RolloutItem],
otel: Option<&OtelManager>,
new_thread_memory_mode: Option<&str>,
) -> anyhow::Result<()> {
if items.is_empty() {
return Ok(());
}
let mut metadata = self
.get_thread(builder.id)
.await?
let existing_metadata = self.get_thread(builder.id).await?;
let mut metadata = existing_metadata
.clone()
.unwrap_or_else(|| builder.build(&self.default_provider));
metadata.rollout_path = builder.rollout_path.clone();
for item in items {
@@ -333,7 +345,13 @@ ON CONFLICT(thread_id, position) DO NOTHING
}
// Keep the thread upsert before dynamic tools to satisfy the foreign key constraint:
// thread_dynamic_tools.thread_id -> threads.id.
if let Err(err) = self.upsert_thread(&metadata).await {
let upsert_result = if existing_metadata.is_none() {
self.upsert_thread_with_creation_memory_mode(&metadata, new_thread_memory_mode)
.await
} else {
self.upsert_thread(&metadata).await
};
if let Err(err) = upsert_result {
if let Some(otel) = otel {
otel.counter(DB_ERROR_METRIC, 1, &[("stage", "apply_rollout_items")]);
}
@@ -494,3 +512,49 @@ pub(super) fn push_thread_order_and_limit(
builder.push(" LIMIT ");
builder.push_bind(limit as i64);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::runtime::test_support::test_thread_metadata;
use crate::runtime::test_support::unique_temp_dir;
use pretty_assertions::assert_eq;
#[tokio::test]
async fn upsert_thread_keeps_creation_memory_mode_for_existing_rows() {
let codex_home = unique_temp_dir();
let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("state db should initialize");
let thread_id =
ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id");
let mut metadata = test_thread_metadata(&codex_home, thread_id, codex_home.clone());
runtime
.upsert_thread_with_creation_memory_mode(&metadata, Some("disabled"))
.await
.expect("initial insert should succeed");
let memory_mode: String =
sqlx::query_scalar("SELECT memory_mode FROM threads WHERE id = ?")
.bind(thread_id.to_string())
.fetch_one(runtime.pool.as_ref())
.await
.expect("memory mode should be readable");
assert_eq!(memory_mode, "disabled");
metadata.title = "updated title".to_string();
runtime
.upsert_thread(&metadata)
.await
.expect("upsert should succeed");
let memory_mode: String =
sqlx::query_scalar("SELECT memory_mode FROM threads WHERE id = ?")
.bind(thread_id.to_string())
.fetch_one(runtime.pool.as_ref())
.await
.expect("memory mode should remain readable");
assert_eq!(memory_mode, "disabled");
}
}
+54
View File
@@ -1,6 +1,7 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(target_os = "windows")]
use crate::app_event::WindowsSandboxEnableMode;
use crate::app_event_sender::AppEventSender;
@@ -2020,6 +2021,9 @@ impl App {
AppEvent::UpdatePersonality(personality) => {
self.on_update_personality(personality);
}
AppEvent::OpenRealtimeAudioDeviceSelection { kind } => {
self.chat_widget.open_realtime_audio_device_selection(kind);
}
AppEvent::OpenReasoningPopup { model } => {
self.chat_widget.open_reasoning_popup(model);
}
@@ -2445,6 +2449,56 @@ impl App {
}
}
}
AppEvent::PersistRealtimeAudioDeviceSelection { kind, name } => {
let builder = match kind {
RealtimeAudioDeviceKind::Microphone => {
ConfigEditsBuilder::new(&self.config.codex_home)
.set_realtime_microphone(name.as_deref())
}
RealtimeAudioDeviceKind::Speaker => {
ConfigEditsBuilder::new(&self.config.codex_home)
.set_realtime_speaker(name.as_deref())
}
};
match builder.apply().await {
Ok(()) => {
match kind {
RealtimeAudioDeviceKind::Microphone => {
self.config.realtime_audio.microphone = name.clone();
}
RealtimeAudioDeviceKind::Speaker => {
self.config.realtime_audio.speaker = name.clone();
}
}
self.chat_widget
.set_realtime_audio_device(kind, name.clone());
if self.chat_widget.realtime_conversation_is_live() {
self.chat_widget.open_realtime_audio_restart_prompt(kind);
} else {
let selection = name.unwrap_or_else(|| "System default".to_string());
self.chat_widget.add_info_message(
format!("Realtime {} set to {selection}", kind.noun()),
None,
);
}
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist realtime audio selection"
);
self.chat_widget.add_error_message(format!(
"Failed to save realtime {}: {err}",
kind.noun()
));
}
}
}
AppEvent::RestartRealtimeAudioDevice { kind } => {
self.chat_widget.restart_realtime_audio_device(kind);
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
self.runtime_approval_policy_override = Some(policy);
if let Err(err) = self.config.permissions.approval_policy.set(policy) {
+42
View File
@@ -29,6 +29,28 @@ use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RealtimeAudioDeviceKind {
Microphone,
Speaker,
}
impl RealtimeAudioDeviceKind {
pub(crate) fn title(self) -> &'static str {
match self {
Self::Microphone => "Microphone",
Self::Speaker => "Speaker",
}
}
pub(crate) fn noun(self) -> &'static str {
match self {
Self::Microphone => "microphone",
Self::Speaker => "speaker",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) enum WindowsSandboxEnableMode {
@@ -166,6 +188,26 @@ pub(crate) enum AppEvent {
personality: Personality,
},
/// Open the device picker for a realtime microphone or speaker.
OpenRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind,
},
/// Persist the selected realtime microphone or speaker to top-level config.
#[cfg_attr(
any(target_os = "linux", not(feature = "voice-input")),
allow(dead_code)
)]
PersistRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind,
name: Option<String>,
},
/// Restart the selected realtime microphone or speaker locally.
RestartRealtimeAudioDevice {
kind: RealtimeAudioDeviceKind,
},
/// Open the reasoning selection popup after picking a model.
OpenReasoningPopup {
model: ModelPreset,
+99 -45
View File
@@ -3,53 +3,79 @@ use cpal::traits::DeviceTrait;
use cpal::traits::HostTrait;
use tracing::warn;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AudioDeviceKind {
Input,
Output,
}
use crate::app_event::RealtimeAudioDeviceKind;
impl AudioDeviceKind {
fn noun(self) -> &'static str {
match self {
Self::Input => "input",
Self::Output => "output",
}
}
fn configured_name(self, config: &Config) -> Option<&str> {
match self {
Self::Input => config.realtime_audio.microphone.as_deref(),
Self::Output => config.realtime_audio.speaker.as_deref(),
const PREFERRED_INPUT_SAMPLE_RATE: u32 = 24_000;
const PREFERRED_INPUT_CHANNELS: u16 = 1;
pub(crate) fn list_realtime_audio_device_names(
kind: RealtimeAudioDeviceKind,
) -> Result<Vec<String>, String> {
let host = cpal::default_host();
let mut device_names = Vec::new();
for device in devices(&host, kind)? {
let Ok(name) = device.name() else {
continue;
};
if !device_names.contains(&name) {
device_names.push(name);
}
}
Ok(device_names)
}
pub(crate) fn select_configured_input_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
select_device_and_config(AudioDeviceKind::Input, config)
select_device_and_config(RealtimeAudioDeviceKind::Microphone, config)
}
pub(crate) fn select_configured_output_device_and_config(
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
select_device_and_config(AudioDeviceKind::Output, config)
select_device_and_config(RealtimeAudioDeviceKind::Speaker, config)
}
pub(crate) fn preferred_input_config(
device: &cpal::Device,
) -> Result<cpal::SupportedStreamConfig, String> {
let supported_configs = device
.supported_input_configs()
.map_err(|err| format!("failed to enumerate input audio configs: {err}"))?;
supported_configs
.filter_map(|range| {
let sample_format_rank = match range.sample_format() {
cpal::SampleFormat::I16 => 0u8,
cpal::SampleFormat::U16 => 1u8,
cpal::SampleFormat::F32 => 2u8,
_ => return None,
};
let sample_rate = preferred_input_sample_rate(&range);
let sample_rate_penalty = sample_rate.0.abs_diff(PREFERRED_INPUT_SAMPLE_RATE);
let channel_penalty = range.channels().abs_diff(PREFERRED_INPUT_CHANNELS);
Some((
(sample_rate_penalty, channel_penalty, sample_format_rank),
range.with_sample_rate(sample_rate),
))
})
.min_by_key(|(score, _)| *score)
.map(|(_, config)| config)
.or_else(|| device.default_input_config().ok())
.ok_or_else(|| "failed to get default input config".to_string())
}
fn select_device_and_config(
kind: AudioDeviceKind,
kind: RealtimeAudioDeviceKind,
config: &Config,
) -> Result<(cpal::Device, cpal::SupportedStreamConfig), String> {
let host = cpal::default_host();
let configured_name = kind.configured_name(config);
let configured_name = configured_name(kind, config);
let selected = configured_name
.and_then(|name| find_device_by_name(&host, kind, name))
.or_else(|| {
let default_device = default_device(&host, kind);
if let Some(name) = configured_name
&& default_device.is_some()
{
if let Some(name) = configured_name && default_device.is_some() {
warn!(
"configured {} audio device `{name}` was unavailable; falling back to system default",
kind.noun()
@@ -59,13 +85,23 @@ fn select_device_and_config(
})
.ok_or_else(|| missing_device_error(kind, configured_name))?;
let stream_config = default_config(&selected, kind)?;
let stream_config = match kind {
RealtimeAudioDeviceKind::Microphone => preferred_input_config(&selected)?,
RealtimeAudioDeviceKind::Speaker => default_config(&selected, kind)?,
};
Ok((selected, stream_config))
}
fn configured_name(kind: RealtimeAudioDeviceKind, config: &Config) -> Option<&str> {
match kind {
RealtimeAudioDeviceKind::Microphone => config.realtime_audio.microphone.as_deref(),
RealtimeAudioDeviceKind::Speaker => config.realtime_audio.speaker.as_deref(),
}
}
fn find_device_by_name(
host: &cpal::Host,
kind: AudioDeviceKind,
kind: RealtimeAudioDeviceKind,
name: &str,
) -> Option<cpal::Device> {
let devices = devices(host, kind).ok()?;
@@ -74,49 +110,67 @@ fn find_device_by_name(
.find(|device| device.name().ok().as_deref() == Some(name))
}
fn devices(host: &cpal::Host, kind: AudioDeviceKind) -> Result<Vec<cpal::Device>, String> {
fn devices(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Result<Vec<cpal::Device>, String> {
match kind {
AudioDeviceKind::Input => host
RealtimeAudioDeviceKind::Microphone => host
.input_devices()
.map(|devices| devices.collect())
.map_err(|err| format!("failed to enumerate input audio devices: {err}")),
AudioDeviceKind::Output => host
RealtimeAudioDeviceKind::Speaker => host
.output_devices()
.map(|devices| devices.collect())
.map_err(|err| format!("failed to enumerate output audio devices: {err}")),
}
}
fn default_device(host: &cpal::Host, kind: AudioDeviceKind) -> Option<cpal::Device> {
fn default_device(host: &cpal::Host, kind: RealtimeAudioDeviceKind) -> Option<cpal::Device> {
match kind {
AudioDeviceKind::Input => host.default_input_device(),
AudioDeviceKind::Output => host.default_output_device(),
RealtimeAudioDeviceKind::Microphone => host.default_input_device(),
RealtimeAudioDeviceKind::Speaker => host.default_output_device(),
}
}
fn default_config(
device: &cpal::Device,
kind: AudioDeviceKind,
kind: RealtimeAudioDeviceKind,
) -> Result<cpal::SupportedStreamConfig, String> {
match kind {
AudioDeviceKind::Input => device
RealtimeAudioDeviceKind::Microphone => device
.default_input_config()
.map_err(|err| format!("failed to get default input config: {err}")),
AudioDeviceKind::Output => device
RealtimeAudioDeviceKind::Speaker => device
.default_output_config()
.map_err(|err| format!("failed to get default output config: {err}")),
}
}
fn missing_device_error(kind: AudioDeviceKind, configured_name: Option<&str>) -> String {
match (kind, configured_name) {
(AudioDeviceKind::Input, Some(name)) => format!(
"configured input audio device `{name}` was unavailable and no default input audio device was found"
),
(AudioDeviceKind::Output, Some(name)) => format!(
"configured output audio device `{name}` was unavailable and no default output audio device was found"
),
(AudioDeviceKind::Input, None) => "no input audio device available".to_string(),
(AudioDeviceKind::Output, None) => "no output audio device available".to_string(),
fn preferred_input_sample_rate(range: &cpal::SupportedStreamConfigRange) -> cpal::SampleRate {
let min = range.min_sample_rate().0;
let max = range.max_sample_rate().0;
if (min..=max).contains(&PREFERRED_INPUT_SAMPLE_RATE) {
cpal::SampleRate(PREFERRED_INPUT_SAMPLE_RATE)
} else if PREFERRED_INPUT_SAMPLE_RATE < min {
cpal::SampleRate(min)
} else {
cpal::SampleRate(max)
}
}
fn missing_device_error(kind: RealtimeAudioDeviceKind, configured_name: Option<&str>) -> String {
match (kind, configured_name) {
(RealtimeAudioDeviceKind::Microphone, Some(name)) => {
format!(
"configured microphone `{name}` was unavailable and no default input audio device was found"
)
}
(RealtimeAudioDeviceKind::Speaker, Some(name)) => {
format!(
"configured speaker `{name}` was unavailable and no default output audio device was found"
)
}
(RealtimeAudioDeviceKind::Microphone, None) => {
"no input audio device available".to_string()
}
(RealtimeAudioDeviceKind::Speaker, None) => "no output audio device available".to_string(),
}
}
+13 -1
View File
@@ -395,6 +395,7 @@ pub(crate) struct ChatComposer {
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
windows_degraded_sandbox_active: bool,
status_line_value: Option<Line<'static>>,
status_line_enabled: bool,
@@ -500,6 +501,7 @@ impl ChatComposer {
connectors_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
status_line_value: None,
status_line_enabled: false,
@@ -577,10 +579,13 @@ impl ChatComposer {
self.realtime_conversation_enabled = enabled;
}
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
self.audio_device_selection_enabled = enabled;
}
/// Compatibility shim for tests that still toggle the removed steer mode flag.
#[cfg(test)]
pub fn set_steer_enabled(&mut self, _enabled: bool) {}
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
self.voice_state.transcription_enabled = enabled;
if !enabled {
@@ -2264,6 +2269,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
@@ -2480,6 +2486,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
{
@@ -2515,6 +2522,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)?;
@@ -3334,6 +3342,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
)
.is_some();
@@ -3396,6 +3405,7 @@ impl ChatComposer {
self.connectors_enabled,
self.personality_command_enabled,
self.realtime_conversation_enabled,
self.audio_device_selection_enabled,
self.windows_degraded_sandbox_active,
) {
return true;
@@ -3450,6 +3460,7 @@ impl ChatComposer {
let connectors_enabled = self.connectors_enabled;
let personality_command_enabled = self.personality_command_enabled;
let realtime_conversation_enabled = self.realtime_conversation_enabled;
let audio_device_selection_enabled = self.audio_device_selection_enabled;
let mut command_popup = CommandPopup::new(
self.custom_prompts.clone(),
CommandPopupFlags {
@@ -3457,6 +3468,7 @@ impl ChatComposer {
connectors_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
windows_degraded_sandbox_active: self.windows_degraded_sandbox_active,
},
);
@@ -40,6 +40,7 @@ pub(crate) struct CommandPopupFlags {
pub(crate) connectors_enabled: bool,
pub(crate) personality_command_enabled: bool,
pub(crate) realtime_conversation_enabled: bool,
pub(crate) audio_device_selection_enabled: bool,
pub(crate) windows_degraded_sandbox_active: bool,
}
@@ -51,6 +52,7 @@ impl CommandPopup {
flags.connectors_enabled,
flags.personality_command_enabled,
flags.realtime_conversation_enabled,
flags.audio_device_selection_enabled,
flags.windows_degraded_sandbox_active,
)
.into_iter()
@@ -498,6 +500,7 @@ mod tests {
connectors_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -518,6 +521,7 @@ mod tests {
connectors_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -538,6 +542,7 @@ mod tests {
connectors_enabled: false,
personality_command_enabled: false,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -566,6 +571,7 @@ mod tests {
connectors_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: false,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
@@ -577,6 +583,36 @@ mod tests {
}
}
#[test]
fn settings_command_hidden_when_audio_device_selection_is_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: false,
connectors_enabled: false,
personality_command_enabled: true,
realtime_conversation_enabled: true,
audio_device_selection_enabled: false,
windows_degraded_sandbox_active: false,
},
);
popup.on_composer_text_change("/aud".to_string());
let cmds: Vec<&str> = popup
.filtered_items()
.into_iter()
.filter_map(|item| match item {
CommandItem::Builtin(cmd) => Some(cmd.command()),
CommandItem::UserPrompt(_) => None,
})
.collect();
assert!(
!cmds.contains(&"settings"),
"expected '/settings' to be hidden when audio device selection is disabled, got {cmds:?}"
);
}
#[test]
fn debug_commands_are_hidden_from_popup() {
let popup = CommandPopup::new(Vec::new(), CommandPopupFlags::default());
+5
View File
@@ -298,6 +298,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_audio_device_selection_enabled(&mut self, enabled: bool) {
self.composer.set_audio_device_selection_enabled(enabled);
self.request_redraw();
}
pub fn set_voice_transcription_enabled(&mut self, enabled: bool) {
self.composer.set_voice_transcription_enabled(enabled);
self.request_redraw();
+25 -3
View File
@@ -14,6 +14,7 @@ pub(crate) fn builtins_for_input(
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
allow_elevate_sandbox: bool,
) -> Vec<(&'static str, SlashCommand)> {
built_in_slash_commands()
@@ -26,6 +27,7 @@ pub(crate) fn builtins_for_input(
.filter(|(_, cmd)| connectors_enabled || *cmd != SlashCommand::Apps)
.filter(|(_, cmd)| personality_command_enabled || *cmd != SlashCommand::Personality)
.filter(|(_, cmd)| realtime_conversation_enabled || *cmd != SlashCommand::Realtime)
.filter(|(_, cmd)| audio_device_selection_enabled || *cmd != SlashCommand::Settings)
.collect()
}
@@ -36,6 +38,7 @@ pub(crate) fn find_builtin_command(
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
allow_elevate_sandbox: bool,
) -> Option<SlashCommand> {
builtins_for_input(
@@ -43,6 +46,7 @@ pub(crate) fn find_builtin_command(
connectors_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
allow_elevate_sandbox,
)
.into_iter()
@@ -57,6 +61,7 @@ pub(crate) fn has_builtin_prefix(
connectors_enabled: bool,
personality_command_enabled: bool,
realtime_conversation_enabled: bool,
audio_device_selection_enabled: bool,
allow_elevate_sandbox: bool,
) -> bool {
builtins_for_input(
@@ -64,6 +69,7 @@ pub(crate) fn has_builtin_prefix(
connectors_enabled,
personality_command_enabled,
realtime_conversation_enabled,
audio_device_selection_enabled,
allow_elevate_sandbox,
)
.into_iter()
@@ -77,14 +83,14 @@ mod tests {
#[test]
fn debug_command_still_resolves_for_dispatch() {
let cmd = find_builtin_command("debug-config", true, true, true, false, false);
let cmd = find_builtin_command("debug-config", true, true, true, false, false, false);
assert_eq!(cmd, Some(SlashCommand::DebugConfig));
}
#[test]
fn clear_command_resolves_for_dispatch() {
assert_eq!(
find_builtin_command("clear", true, true, true, false, false),
find_builtin_command("clear", true, true, true, false, false, false),
Some(SlashCommand::Clear)
);
}
@@ -92,7 +98,23 @@ mod tests {
#[test]
fn realtime_command_is_hidden_when_realtime_is_disabled() {
assert_eq!(
find_builtin_command("realtime", true, true, true, false, false),
find_builtin_command("realtime", true, true, true, false, true, false),
None
);
}
#[test]
fn settings_command_is_hidden_when_realtime_is_disabled() {
assert_eq!(
find_builtin_command("settings", true, true, true, false, false, false),
None
);
}
#[test]
fn settings_command_is_hidden_when_audio_device_selection_is_disabled() {
assert_eq!(
find_builtin_command("settings", true, true, true, true, false, false),
None
);
}
+206
View File
@@ -37,6 +37,9 @@ use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
use crate::app_event::RealtimeAudioDeviceKind;
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
use crate::audio_device::list_realtime_audio_device_names;
use crate::bottom_pane::StatusLineItem;
use crate::bottom_pane::StatusLineSetupView;
use crate::status::RateLimitWindowDisplay;
@@ -855,6 +858,10 @@ impl ChatWidget {
&& cfg!(not(target_os = "linux"))
}
fn realtime_audio_device_selection_enabled(&self) -> bool {
self.realtime_conversation_enabled() && cfg!(feature = "voice-input")
}
/// Synchronize the bottom-pane "task running" indicator with the current lifecycles.
///
/// The bottom pane only has one running flag, but this module treats it as a derived state of
@@ -2882,6 +2889,9 @@ impl ChatWidget {
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@@ -3056,6 +3066,9 @@ impl ChatWidget {
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@@ -3219,6 +3232,9 @@ impl ChatWidget {
widget
.bottom_pane
.set_realtime_conversation_enabled(widget.realtime_conversation_enabled());
widget
.bottom_pane
.set_audio_device_selection_enabled(widget.realtime_audio_device_selection_enabled());
widget
.bottom_pane
.set_status_line_enabled(!widget.configured_status_line_items().is_empty());
@@ -3531,6 +3547,12 @@ impl ChatWidget {
self.start_realtime_conversation();
}
}
SlashCommand::Settings => {
if !self.realtime_audio_device_selection_enabled() {
return;
}
self.open_realtime_audio_popup();
}
SlashCommand::Personality => {
self.open_personality_popup();
}
@@ -5270,6 +5292,161 @@ impl ChatWidget {
});
}
pub(crate) fn open_realtime_audio_popup(&mut self) {
let items = [
RealtimeAudioDeviceKind::Microphone,
RealtimeAudioDeviceKind::Speaker,
]
.into_iter()
.map(|kind| {
let description = Some(format!(
"Current: {}",
self.current_realtime_audio_selection_label(kind)
));
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind });
})];
SelectionItem {
name: kind.title().to_string(),
description,
actions,
dismiss_on_select: true,
..Default::default()
}
})
.collect();
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Settings".to_string()),
subtitle: Some("Configure settings for Codex.".to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) {
match list_realtime_audio_device_names(kind) {
Ok(device_names) => {
self.open_realtime_audio_device_selection_with_names(kind, device_names);
}
Err(err) => {
self.add_error_message(format!(
"Failed to load realtime {} devices: {err}",
kind.noun()
));
}
}
}
#[cfg(any(target_os = "linux", not(feature = "voice-input")))]
pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) {
let _ = kind;
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
fn open_realtime_audio_device_selection_with_names(
&mut self,
kind: RealtimeAudioDeviceKind,
device_names: Vec<String>,
) {
let current_selection = self.current_realtime_audio_device_name(kind);
let current_available = current_selection
.as_deref()
.is_some_and(|name| device_names.iter().any(|device_name| device_name == name));
let mut items = vec![SelectionItem {
name: "System default".to_string(),
description: Some("Use your operating system default device.".to_string()),
is_current: current_selection.is_none(),
actions: vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None });
})],
dismiss_on_select: true,
..Default::default()
}];
if let Some(selection) = current_selection.as_deref()
&& !current_available
{
items.push(SelectionItem {
name: format!("Unavailable: {selection}"),
description: Some("Configured device is not currently available.".to_string()),
is_current: true,
is_disabled: true,
disabled_reason: Some("Reconnect the device or choose another one.".to_string()),
..Default::default()
});
}
items.extend(device_names.into_iter().map(|device_name| {
let persisted_name = device_name.clone();
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::PersistRealtimeAudioDeviceSelection {
kind,
name: Some(persisted_name.clone()),
});
})];
SelectionItem {
is_current: current_selection.as_deref() == Some(device_name.as_str()),
name: device_name,
actions,
dismiss_on_select: true,
..Default::default()
}
}));
let mut header = ColumnRenderable::new();
header.push(Line::from(format!("Select {}", kind.title()).bold()));
header.push(Line::from(
"Saved devices apply to realtime voice only.".dim(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
pub(crate) fn open_realtime_audio_restart_prompt(&mut self, kind: RealtimeAudioDeviceKind) {
let restart_actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::RestartRealtimeAudioDevice { kind });
})];
let items = vec![
SelectionItem {
name: "Restart now".to_string(),
description: Some(format!("Restart local {} audio now.", kind.noun())),
actions: restart_actions,
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "Apply later".to_string(),
description: Some(format!(
"Keep the current {} until local audio starts again.",
kind.noun()
)),
dismiss_on_select: true,
..Default::default()
},
];
let mut header = ColumnRenderable::new();
header.push(Line::from(format!("Restart {} now?", kind.title()).bold()));
header.push(Line::from(
"Configuration is saved. Restart local audio to use it immediately.".dim(),
));
self.bottom_pane.show_selection_view(SelectionViewParams {
header: Box::new(header),
footer_hint: Some(standard_popup_hint_line()),
items,
..Default::default()
});
}
fn model_menu_header(&self, title: &str, subtitle: &str) -> Box<dyn Renderable> {
let title = title.to_string();
let subtitle = subtitle.to_string();
@@ -6523,6 +6700,8 @@ impl ChatWidget {
let realtime_conversation_enabled = self.realtime_conversation_enabled();
self.bottom_pane
.set_realtime_conversation_enabled(realtime_conversation_enabled);
self.bottom_pane
.set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled());
if !realtime_conversation_enabled && self.realtime_conversation.is_live() {
self.request_realtime_conversation_close(Some(
"Realtime voice mode was closed because the feature was disabled.".to_string(),
@@ -6612,6 +6791,17 @@ impl ChatWidget {
self.config.personality = Some(personality);
}
pub(crate) fn set_realtime_audio_device(
&mut self,
kind: RealtimeAudioDeviceKind,
name: Option<String>,
) {
match kind {
RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone = name,
RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker = name,
}
}
/// Set the syntax theme override in the widget's config copy.
pub(crate) fn set_tui_theme(&mut self, theme: Option<String>) {
self.config.tui_theme = theme;
@@ -6640,6 +6830,22 @@ impl ChatWidget {
.unwrap_or_else(|| self.current_collaboration_mode.model())
}
pub(crate) fn realtime_conversation_is_live(&self) -> bool {
self.realtime_conversation.is_active()
}
fn current_realtime_audio_device_name(&self, kind: RealtimeAudioDeviceKind) -> Option<String> {
match kind {
RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone.clone(),
RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker.clone(),
}
}
fn current_realtime_audio_selection_label(&self, kind: RealtimeAudioDeviceKind) -> String {
self.current_realtime_audio_device_name(kind)
.unwrap_or_else(|| "System default".to_string())
}
fn sync_personality_command_enabled(&mut self) {
self.bottom_pane
.set_personality_command_enabled(self.config.features.enabled(Feature::Personality));
+15
View File
@@ -13,6 +13,17 @@ use tokio::sync::mpsc::unbounded_channel;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
const TUI_NOTIFY_CLIENT: &str = "codex-tui";
async fn initialize_app_server_client_name(thread: &CodexThread) {
if let Err(err) = thread
.set_app_server_client_name(Some(TUI_NOTIFY_CLIENT.to_string()))
.await
{
tracing::error!("failed to set app server client name: {err}");
}
}
/// Spawn the agent bootstrapper and op forwarding loop, returning the
/// `UnboundedSender<Op>` used by the UI to submit operations.
pub(crate) fn spawn_agent(
@@ -42,6 +53,7 @@ pub(crate) fn spawn_agent(
return;
}
};
initialize_app_server_client_name(thread.as_ref()).await;
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
let ev = codex_protocol::protocol::Event {
@@ -87,6 +99,8 @@ pub(crate) fn spawn_agent_from_existing(
let app_event_tx_clone = app_event_tx;
tokio::spawn(async move {
initialize_app_server_client_name(thread.as_ref()).await;
// Forward the captured `SessionConfigured` event so it can be rendered in the UI.
let ev = codex_protocol::protocol::Event {
id: "".to_string(),
@@ -123,6 +137,7 @@ pub(crate) fn spawn_op_forwarder(thread: std::sync::Arc<CodexThread>) -> Unbound
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
tokio::spawn(async move {
initialize_app_server_client_name(thread.as_ref()).await;
while let Some(op) = codex_op_rx.recv().await {
if let Err(e) = thread.submit(op).await {
tracing::error!("failed to submit op: {e}");
+50 -5
View File
@@ -41,6 +41,10 @@ impl RealtimeConversationUiState {
| RealtimeConversationPhase::Stopping
)
}
pub(super) fn is_active(&self) -> bool {
matches!(self.phase, RealtimeConversationPhase::Active)
}
}
#[derive(Clone, Debug, PartialEq)]
@@ -278,8 +282,50 @@ impl ChatWidget {
#[cfg(target_os = "linux")]
fn start_realtime_local_audio(&mut self) {}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
if !self.realtime_conversation.is_active() {
return;
}
match kind {
RealtimeAudioDeviceKind::Microphone => {
self.stop_realtime_microphone();
self.start_realtime_local_audio();
}
RealtimeAudioDeviceKind::Speaker => {
self.stop_realtime_speaker();
match crate::voice::RealtimeAudioPlayer::start(&self.config) {
Ok(player) => {
self.realtime_conversation.audio_player = Some(player);
}
Err(err) => {
self.add_error_message(format!("Failed to start speaker output: {err}"));
}
}
}
}
self.request_redraw();
}
#[cfg(any(target_os = "linux", not(feature = "voice-input")))]
pub(crate) fn restart_realtime_audio_device(&mut self, kind: RealtimeAudioDeviceKind) {
let _ = kind;
}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_local_audio(&mut self) {
self.stop_realtime_microphone();
self.stop_realtime_speaker();
}
#[cfg(target_os = "linux")]
fn stop_realtime_local_audio(&mut self) {
self.realtime_conversation.meter_placeholder_id = None;
}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_microphone(&mut self) {
if let Some(flag) = self.realtime_conversation.capture_stop_flag.take() {
flag.store(true, Ordering::Relaxed);
}
@@ -289,13 +335,12 @@ impl ChatWidget {
if let Some(id) = self.realtime_conversation.meter_placeholder_id.take() {
self.remove_transcription_placeholder(&id);
}
}
#[cfg(not(target_os = "linux"))]
fn stop_realtime_speaker(&mut self) {
if let Some(player) = self.realtime_conversation.audio_player.take() {
player.clear();
}
}
#[cfg(target_os = "linux")]
fn stop_realtime_local_audio(&mut self) {
self.realtime_conversation.meter_placeholder_id = None;
}
}
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Settings
Configure settings for Codex.
› 1. Microphone Current: System default
2. Speaker Current: System default
Press enter to confirm or esc to go back
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Settings
Configure settings for Codex.
› 1. Microphone Current: System default
2. Speaker Current: System default
Press enter to confirm or esc to go back
@@ -0,0 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Microphone
Saved devices apply to realtime voice only.
1. System default Use your operating system
default device.
› 2. Unavailable: Studio Mic (current) (disabled) Configured device is not
currently available.
(disabled: Reconnect the
device or choose another
one.)
3. Built-in Mic
4. USB Mic
Press enter to confirm or esc to go back
+60
View File
@@ -7,6 +7,8 @@
use super::*;
use crate::app_event::AppEvent;
use crate::app_event::ExitMode;
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
use crate::app_event::RealtimeAudioDeviceKind;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::FeedbackAudience;
use crate::bottom_pane::LocalImageAttachment;
@@ -6001,6 +6003,62 @@ async fn personality_selection_popup_snapshot() {
assert_snapshot!("personality_selection_popup", popup);
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
#[tokio::test]
async fn realtime_audio_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await;
chat.open_realtime_audio_popup();
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("realtime_audio_selection_popup", popup);
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
#[tokio::test]
async fn realtime_audio_selection_popup_narrow_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await;
chat.open_realtime_audio_popup();
let popup = render_bottom_popup(&chat, 56);
assert_snapshot!("realtime_audio_selection_popup_narrow", popup);
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
#[tokio::test]
async fn realtime_microphone_picker_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await;
chat.config.realtime_audio.microphone = Some("Studio Mic".to_string());
chat.open_realtime_audio_device_selection_with_names(
RealtimeAudioDeviceKind::Microphone,
vec!["Built-in Mic".to_string(), "USB Mic".to_string()],
);
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("realtime_microphone_picker_popup", popup);
}
#[cfg(all(not(target_os = "linux"), feature = "voice-input"))]
#[tokio::test]
async fn realtime_audio_picker_emits_persist_event() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await;
chat.open_realtime_audio_device_selection_with_names(
RealtimeAudioDeviceKind::Speaker,
vec!["Desk Speakers".to_string(), "Headphones".to_string()],
);
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_matches!(
rx.try_recv(),
Ok(AppEvent::PersistRealtimeAudioDeviceSelection {
kind: RealtimeAudioDeviceKind::Speaker,
name: Some(name),
}) if name == "Headphones"
);
}
#[tokio::test]
async fn model_picker_hides_show_in_picker_false_models_from_cache() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("test-visible-model")).await;
@@ -6019,6 +6077,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() {
is_default: false,
upgrade: None,
show_in_picker,
availability_nux: None,
supported_in_api: true,
input_modalities: default_input_modalities(),
};
@@ -6287,6 +6346,7 @@ async fn single_reasoning_option_skips_selection() {
is_default: false,
upgrade: None,
show_in_picker: true,
availability_nux: None,
supported_in_api: true,
input_modalities: default_input_modalities(),
};

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