Merge branch 'main' into fix/mcp-image-output-nonfirst

This commit is contained in:
Kbediako
2026-01-27 01:24:45 +11:00
committed by GitHub
49 changed files with 1313 additions and 122 deletions
@@ -524,6 +524,12 @@ server_request_definitions! {
response: v2::ToolRequestUserInputResponse,
},
/// Execute a dynamic tool call on the client.
DynamicToolCall => "item/tool/call" {
params: v2::DynamicToolCallParams,
response: v2::DynamicToolCallResponse,
},
/// DEPRECATED APIs below
/// Request to approve a patch.
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
@@ -325,6 +325,15 @@ pub struct ToolsV2 {
pub view_image: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolSpec {
pub name: String,
pub description: String,
pub input_schema: JsonValue,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
@@ -905,6 +914,8 @@ pub struct Model {
pub description: String,
pub supported_reasoning_efforts: Vec<ReasoningEffortOption>,
pub default_reasoning_effort: ReasoningEffort,
#[serde(default)]
pub supports_personality: bool,
// Only one model should be marked as default.
pub is_default: bool,
}
@@ -1088,6 +1099,7 @@ pub struct ThreadStartParams {
pub developer_instructions: Option<String>,
pub personality: Option<Personality>,
pub ephemeral: Option<bool>,
pub dynamic_tools: Option<Vec<DynamicToolSpec>>,
/// If true, opt into emitting raw response items on the event stream.
///
/// This is for internal use only (e.g. Codex Cloud).
@@ -2372,6 +2384,25 @@ pub struct FileChangeRequestApprovalResponse {
pub decision: FileChangeApprovalDecision,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolCallParams {
pub thread_id: String,
pub turn_id: String,
pub call_id: String,
pub tool: String,
pub arguments: JsonValue,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct DynamicToolCallResponse {
pub output: String,
pub success: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+14 -1
View File
@@ -114,7 +114,20 @@ Start a fresh thread when you need a new Codex conversation.
"cwd": "/Users/me/project",
"approvalPolicy": "never",
"sandbox": "workspaceWrite",
"personality": "friendly"
"personality": "friendly",
"dynamicTools": [
{
"name": "lookup_ticket",
"description": "Fetch a ticket by id",
"inputSchema": {
"type": "object",
"properties": {
"id": { "type": "string" }
},
"required": ["id"]
}
}
],
} }
{ "id": 10, "result": {
"thread": {
@@ -25,6 +25,7 @@ use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
use codex_app_server_protocol::ContextCompactedNotification;
use codex_app_server_protocol::DeprecationNoticeNotification;
use codex_app_server_protocol::DynamicToolCallParams;
use codex_app_server_protocol::ErrorNotification;
use codex_app_server_protocol::ExecCommandApprovalParams;
use codex_app_server_protocol::ExecCommandApprovalResponse;
@@ -85,6 +86,7 @@ use codex_core::protocol::TurnDiffEvent;
use codex_core::review_format::format_review_findings_block;
use codex_core::review_prompts;
use codex_protocol::ThreadId;
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
use codex_protocol::plan_tool::UpdatePlanArgs;
use codex_protocol::protocol::ReviewOutputEvent;
use codex_protocol::request_user_input::RequestUserInputAnswer as CoreRequestUserInputAnswer;
@@ -318,6 +320,40 @@ pub(crate) async fn apply_bespoke_event_handling(
}
}
}
EventMsg::DynamicToolCallRequest(request) => {
if matches!(api_version, ApiVersion::V2) {
let call_id = request.call_id;
let params = DynamicToolCallParams {
thread_id: conversation_id.to_string(),
turn_id: request.turn_id,
call_id: call_id.clone(),
tool: request.tool,
arguments: request.arguments,
};
let rx = outgoing
.send_request(ServerRequestPayload::DynamicToolCall(params))
.await;
tokio::spawn(async move {
crate::dynamic_tools::on_call_response(call_id, rx, conversation).await;
});
} else {
error!(
"dynamic tool calls are only supported on api v2 (call_id: {})",
request.call_id
);
let call_id = request.call_id;
let _ = conversation
.submit(Op::DynamicToolResponse {
id: call_id.clone(),
response: CoreDynamicToolResponse {
call_id,
output: "dynamic tool calls require api v2".to_string(),
success: false,
},
})
.await;
}
}
// TODO(celia): properly construct McpToolCall TurnItem in core.
EventMsg::McpToolCallBegin(begin_event) => {
let notification = construct_mcp_tool_call_notification(
@@ -31,6 +31,7 @@ use codex_app_server_protocol::CollaborationModeListResponse;
use codex_app_server_protocol::CommandExecParams;
use codex_app_server_protocol::ConversationGitInfo;
use codex_app_server_protocol::ConversationSummary;
use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec;
use codex_app_server_protocol::ExecOneOffCommandResponse;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::FeedbackUploadResponse;
@@ -171,6 +172,7 @@ use codex_login::run_login_server;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::Personality;
use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentStatus;
@@ -1411,35 +1413,81 @@ impl CodexMessageProcessor {
}
async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) {
let ThreadStartParams {
model,
model_provider,
cwd,
approval_policy,
sandbox,
config,
base_instructions,
developer_instructions,
dynamic_tools,
experimental_raw_events,
personality,
ephemeral,
} = params;
let mut typesafe_overrides = self.build_thread_config_overrides(
params.model,
params.model_provider,
params.cwd,
params.approval_policy,
params.sandbox,
params.base_instructions,
params.developer_instructions,
params.personality,
model,
model_provider,
cwd,
approval_policy,
sandbox,
base_instructions,
developer_instructions,
personality,
);
typesafe_overrides.ephemeral = Some(params.ephemeral.unwrap_or_default());
typesafe_overrides.ephemeral = ephemeral;
let config =
match derive_config_from_params(&self.cli_overrides, params.config, typesafe_overrides)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
let config = match derive_config_from_params(
&self.cli_overrides,
config,
typesafe_overrides,
)
.await
{
Ok(config) => config,
Err(err) => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("error deriving config: {err}"),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
};
match self.thread_manager.start_thread(config).await {
let dynamic_tools = dynamic_tools.unwrap_or_default();
let core_dynamic_tools = if dynamic_tools.is_empty() {
Vec::new()
} else {
let snapshot = collect_mcp_snapshot(&config).await;
let mcp_tool_names = snapshot.tools.keys().cloned().collect::<HashSet<_>>();
if let Err(message) = validate_dynamic_tools(&dynamic_tools, &mcp_tool_names) {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message,
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
dynamic_tools
.into_iter()
.map(|tool| CoreDynamicToolSpec {
name: tool.name,
description: tool.description,
input_schema: tool.input_schema,
})
.collect()
};
match self
.thread_manager
.start_thread_with_tools(config, core_dynamic_tools)
.await
{
Ok(new_conv) => {
let NewThread {
thread_id,
@@ -1489,7 +1537,7 @@ impl CodexMessageProcessor {
if let Err(err) = self
.attach_conversation_listener(
thread_id,
params.experimental_raw_events,
experimental_raw_events,
ApiVersion::V2,
)
.await
@@ -4322,6 +4370,41 @@ fn errors_to_info(
.collect()
}
fn validate_dynamic_tools(
tools: &[ApiDynamicToolSpec],
mcp_tool_names: &HashSet<String>,
) -> Result<(), String> {
let mut seen = HashSet::new();
for tool in tools {
let name = tool.name.trim();
if name.is_empty() {
return Err("dynamic tool name must not be empty".to_string());
}
if name != tool.name {
return Err(format!(
"dynamic tool name has leading/trailing whitespace: {}",
tool.name
));
}
if name == "mcp" || name.starts_with("mcp__") {
return Err(format!("dynamic tool name is reserved: {name}"));
}
if mcp_tool_names.contains(name) {
return Err(format!("dynamic tool name conflicts with MCP tool: {name}"));
}
if !seen.insert(name.to_string()) {
return Err(format!("duplicate dynamic tool name: {name}"));
}
if let Err(err) = codex_core::parse_tool_input_schema(&tool.input_schema) {
return Err(format!(
"dynamic tool input schema is not supported for {name}: {err}"
));
}
}
Ok(())
}
/// Derive the effective [`Config`] by layering three override sources.
///
/// Precedence (lowest to highest):
@@ -4602,6 +4685,28 @@ mod tests {
use serde_json::json;
use tempfile::TempDir;
#[test]
fn validate_dynamic_tools_rejects_unsupported_input_schema() {
let tools = vec![ApiDynamicToolSpec {
name: "my_tool".to_string(),
description: "test".to_string(),
input_schema: json!({"type": "null"}),
}];
let err = validate_dynamic_tools(&tools, &HashSet::new()).expect_err("invalid schema");
assert!(err.contains("my_tool"), "unexpected error: {err}");
}
#[test]
fn validate_dynamic_tools_accepts_sanitizable_input_schema() {
let tools = vec![ApiDynamicToolSpec {
name: "my_tool".to_string(),
description: "test".to_string(),
// Missing `type` is common; core sanitizes these to a supported schema.
input_schema: json!({"properties": {}}),
}];
validate_dynamic_tools(&tools, &HashSet::new()).expect("valid schema");
}
#[test]
fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> {
let conversation_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?;
+58
View File
@@ -0,0 +1,58 @@
use codex_app_server_protocol::DynamicToolCallResponse;
use codex_core::CodexThread;
use codex_protocol::dynamic_tools::DynamicToolResponse as CoreDynamicToolResponse;
use codex_protocol::protocol::Op;
use std::sync::Arc;
use tokio::sync::oneshot;
use tracing::error;
pub(crate) async fn on_call_response(
call_id: String,
receiver: oneshot::Receiver<serde_json::Value>,
conversation: Arc<CodexThread>,
) {
let response = receiver.await;
let value = match response {
Ok(value) => value,
Err(err) => {
error!("request failed: {err:?}");
let fallback = CoreDynamicToolResponse {
call_id: call_id.clone(),
output: "dynamic tool request failed".to_string(),
success: false,
};
if let Err(err) = conversation
.submit(Op::DynamicToolResponse {
id: call_id.clone(),
response: fallback,
})
.await
{
error!("failed to submit DynamicToolResponse: {err}");
}
return;
}
};
let response = serde_json::from_value::<DynamicToolCallResponse>(value).unwrap_or_else(|err| {
error!("failed to deserialize DynamicToolCallResponse: {err}");
DynamicToolCallResponse {
output: "dynamic tool response was invalid".to_string(),
success: false,
}
});
let response = CoreDynamicToolResponse {
call_id: call_id.clone(),
output: response.output,
success: response.success,
};
if let Err(err) = conversation
.submit(Op::DynamicToolResponse {
id: call_id,
response,
})
.await
{
error!("failed to submit DynamicToolResponse: {err}");
}
}
+1
View File
@@ -40,6 +40,7 @@ use tracing_subscriber::util::SubscriberInitExt;
mod bespoke_event_handling;
mod codex_message_processor;
mod config_api;
mod dynamic_tools;
mod error_code;
mod fuzzy_file_search;
mod message_processor;
+1
View File
@@ -28,6 +28,7 @@ fn model_from_preset(preset: ModelPreset) -> Model {
preset.supported_reasoning_efforts,
),
default_reasoning_effort: preset.default_reasoning_effort,
supports_personality: preset.supports_personality,
is_default: preset.is_default,
}
}
@@ -0,0 +1,286 @@
use anyhow::Context;
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::DynamicToolCallParams;
use codex_app_server_protocol::DynamicToolCallResponse;
use codex_app_server_protocol::DynamicToolSpec;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
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::responses;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::path::Path;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::MockServer;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
/// Ensures dynamic tool specs are serialized into the model request payload.
#[tokio::test]
async fn thread_start_injects_dynamic_tools_into_model_requests() -> 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()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
// Use a minimal JSON schema so we can assert the tool payload round-trips.
let input_schema = json!({
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false,
});
let dynamic_tool = DynamicToolSpec {
name: "demo_tool".to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: input_schema.clone(),
};
// Thread start injects dynamic tools into the thread's tool registry.
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
dynamic_tools: Some(vec![dynamic_tool.clone()]),
..Default::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::<ThreadStartResponse>(thread_resp)?;
// Start a turn so a model request is issued.
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
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 _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
// Inspect the captured model request to assert the tool spec made it through.
let bodies = responses_bodies(&server).await?;
let body = bodies
.first()
.context("expected at least one responses request")?;
let tool = find_tool(body, &dynamic_tool.name)
.context("expected dynamic tool to be injected into request")?;
assert_eq!(
tool.get("description"),
Some(&Value::String(dynamic_tool.description.clone()))
);
assert_eq!(tool.get("parameters"), Some(&input_schema));
Ok(())
}
/// Exercises the full dynamic tool call path (server request, client response, model output).
#[tokio::test]
async fn dynamic_tool_call_round_trip_sends_output_to_model() -> Result<()> {
let call_id = "dyn-call-1";
let tool_name = "demo_tool";
let tool_args = json!({ "city": "Paris" });
let tool_call_arguments = serde_json::to_string(&tool_args)?;
// First response triggers a dynamic tool call, second closes the turn.
let responses = vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_function_call(call_id, tool_name, &tool_call_arguments),
responses::ev_completed("resp-1"),
]),
create_final_assistant_message_sse_response("Done")?,
];
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let dynamic_tool = DynamicToolSpec {
name: tool_name.to_string(),
description: "Demo dynamic tool".to_string(),
input_schema: json!({
"type": "object",
"properties": {
"city": { "type": "string" }
},
"required": ["city"],
"additionalProperties": false,
}),
};
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
dynamic_tools: Some(vec![dynamic_tool]),
..Default::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::<ThreadStartResponse>(thread_resp)?;
// Start a turn so the tool call is emitted.
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Run the tool".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 { turn } = to_response::<TurnStartResponse>(turn_resp)?;
// Read the tool call request from the app server.
let request = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_request_message(),
)
.await??;
let (request_id, params) = match request {
ServerRequest::DynamicToolCall { request_id, params } => (request_id, params),
other => panic!("expected DynamicToolCall request, got {other:?}"),
};
let expected = DynamicToolCallParams {
thread_id: thread.id,
turn_id: turn.id,
call_id: call_id.to_string(),
tool: tool_name.to_string(),
arguments: tool_args.clone(),
};
assert_eq!(params, expected);
// Respond to the tool call so the model receives a function_call_output.
let response = DynamicToolCallResponse {
output: "dynamic-ok".to_string(),
success: true,
};
mcp.send_response(request_id, serde_json::to_value(response)?)
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let bodies = responses_bodies(&server).await?;
let output = bodies
.iter()
.find_map(|body| function_call_output_text(body, call_id))
.context("expected function_call_output in follow-up request")?;
assert_eq!(output, "dynamic-ok");
Ok(())
}
async fn responses_bodies(server: &MockServer) -> Result<Vec<Value>> {
let requests = server
.received_requests()
.await
.context("failed to fetch received requests")?;
requests
.into_iter()
.filter(|req| req.url.path().ends_with("/responses"))
.map(|req| {
req.body_json::<Value>()
.context("request body should be JSON")
})
.collect()
}
fn find_tool<'a>(body: &'a Value, name: &str) -> Option<&'a Value> {
body.get("tools")
.and_then(Value::as_array)
.and_then(|tools| {
tools
.iter()
.find(|tool| tool.get("name").and_then(Value::as_str) == Some(name))
})
}
fn function_call_output_text(body: &Value, call_id: &str) -> Option<String> {
body.get("input")
.and_then(Value::as_array)
.and_then(|items| {
items.iter().find(|item| {
item.get("type").and_then(Value::as_str) == Some("function_call_output")
&& item.get("call_id").and_then(Value::as_str) == Some(call_id)
})
})
.and_then(|item| item.get("output"))
.and_then(Value::as_str)
.map(str::to_string)
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
@@ -3,6 +3,7 @@ mod analytics;
mod app_list;
mod collaboration_mode_list;
mod config_rpc;
mod dynamic_tools;
mod initialize;
mod model_list;
mod output_schema;
@@ -72,6 +72,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
},
],
default_reasoning_effort: ReasoningEffort::Medium,
supports_personality: false,
is_default: true,
},
Model {
@@ -99,6 +100,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
},
],
default_reasoning_effort: ReasoningEffort::Medium,
supports_personality: false,
is_default: false,
},
Model {
@@ -118,6 +120,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
},
],
default_reasoning_effort: ReasoningEffort::Medium,
supports_personality: false,
is_default: false,
},
Model {
@@ -151,6 +154,7 @@ async fn list_models_returns_all_models_with_large_limit() -> Result<()> {
},
],
default_reasoning_effort: ReasoningEffort::Medium,
supports_personality: false,
is_default: false,
},
];
@@ -433,7 +433,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> {
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.2-codex".to_string()),
model: Some("exp-codex-personality".to_string()),
..Default::default()
})
.await?;
+48 -5
View File
@@ -38,6 +38,8 @@ use codex_protocol::approvals::ExecPolicyAmendment;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::items::TurnItem;
use codex_protocol::items::UserMessageItem;
use codex_protocol::models::BaseInstructions;
@@ -236,6 +238,7 @@ fn maybe_push_chat_wire_api_deprecation(
impl Codex {
/// Spawn a new [`Codex`] and initialize the session.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn spawn(
config: Config,
auth_manager: Arc<AuthManager>,
@@ -244,16 +247,12 @@ impl Codex {
conversation_history: InitialHistory,
session_source: SessionSource,
agent_control: AgentControl,
dynamic_tools: Vec<DynamicToolSpec>,
) -> CodexResult<CodexSpawnOk> {
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
let (tx_event, rx_event) = async_channel::unbounded();
let loaded_skills = skills_manager.skills_for_config(&config);
// let loaded_skills = if config.features.enabled(Feature::Skills) {
// Some(skills_manager.skills_for_config(&config))
// } else {
// None
// };
for err in &loaded_skills.errors {
error!(
@@ -320,6 +319,7 @@ impl Codex {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
session_source,
dynamic_tools,
};
// Generate a unique ID for the lifetime of this Codex session.
@@ -443,6 +443,7 @@ pub(crate) struct TurnContext {
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) tool_call_gate: Arc<ReadinessFlag>,
pub(crate) truncation_policy: TruncationPolicy,
pub(crate) dynamic_tools: Vec<DynamicToolSpec>,
}
impl TurnContext {
@@ -500,6 +501,7 @@ pub(crate) struct SessionConfiguration {
original_config_do_not_use: Arc<Config>,
/// Source of the session (cli, vscode, exec, mcp, ...)
session_source: SessionSource,
dynamic_tools: Vec<DynamicToolSpec>,
}
impl SessionConfiguration {
@@ -616,6 +618,7 @@ impl Session {
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
truncation_policy: model_info.truncation_policy.into(),
dynamic_tools: session_configuration.dynamic_tools.clone(),
}
}
@@ -1495,6 +1498,27 @@ impl Session {
}
}
pub async fn notify_dynamic_tool_response(&self, call_id: &str, response: DynamicToolResponse) {
let entry = {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.remove_pending_dynamic_tool(call_id)
}
None => None,
}
};
match entry {
Some(tx_response) => {
tx_response.send(response).ok();
}
None => {
warn!("No pending dynamic tool call found for call_id: {call_id}");
}
}
}
pub async fn notify_approval(&self, sub_id: &str, decision: ReviewDecision) {
let entry = {
let mut active = self.active_turn.lock().await;
@@ -2156,6 +2180,9 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
Op::UserInputAnswer { id, response } => {
handlers::request_user_input_response(&sess, id, response).await;
}
Op::DynamicToolResponse { id, response } => {
handlers::dynamic_tool_response(&sess, id, response).await;
}
Op::AddToHistory { text } => {
handlers::add_to_history(&sess, &config, text).await;
}
@@ -2252,6 +2279,7 @@ mod handlers {
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::user_input::UserInput;
use codex_rmcp_client::ElicitationAction;
use codex_rmcp_client::ElicitationResponse;
@@ -2489,6 +2517,14 @@ mod handlers {
sess.notify_user_input_response(&id, response).await;
}
pub async fn dynamic_tool_response(
sess: &Arc<Session>,
id: String,
response: DynamicToolResponse,
) {
sess.notify_dynamic_tool_response(&id, response).await;
}
pub async fn add_to_history(sess: &Arc<Session>, config: &Arc<Config>, text: String) {
let id = sess.conversation_id;
let config = Arc::clone(config);
@@ -2826,6 +2862,7 @@ async fn spawn_review_thread(
final_output_json_schema: None,
codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(),
tool_call_gate: Arc::new(ReadinessFlag::new()),
dynamic_tools: parent_turn_context.dynamic_tools.clone(),
truncation_policy: model_info.truncation_policy.into(),
};
@@ -3162,6 +3199,7 @@ async fn run_sampling_request(
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
turn_context.dynamic_tools.as_slice(),
));
let model_supports_parallel = turn_context
@@ -3942,6 +3980,7 @@ mod tests {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
};
let mut state = SessionState::new(session_configuration);
@@ -4021,6 +4060,7 @@ mod tests {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
};
let mut state = SessionState::new(session_configuration);
@@ -4284,6 +4324,7 @@ mod tests {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline(
@@ -4392,6 +4433,7 @@ mod tests {
cwd: config.cwd.clone(),
original_config_do_not_use: Arc::clone(&config),
session_source: SessionSource::Exec,
dynamic_tools: Vec::new(),
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline(
@@ -4712,6 +4754,7 @@ mod tests {
.map(|(name, tool)| (name, tool.tool))
.collect(),
),
turn_context.dynamic_tools.as_slice(),
);
let item = ResponseItem::CustomToolCall {
id: None,
+1
View File
@@ -57,6 +57,7 @@ pub(crate) async fn run_codex_thread_interactive(
initial_history.unwrap_or(InitialHistory::New),
SessionSource::SubAgent(SubAgentSource::Review),
parent_session.services.agent_control.clone(),
Vec::new(),
)
.await?;
let codex = Arc::new(codex);
+13
View File
@@ -4,6 +4,7 @@ use crate::config::types::Notice;
use crate::path_utils::resolve_symlink_write_paths;
use crate::path_utils::write_atomically;
use anyhow::Context;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::TrustLevel;
use codex_protocol::openai_models::ReasoningEffort;
use std::collections::BTreeMap;
@@ -24,6 +25,8 @@ pub enum ConfigEdit {
model: Option<String>,
effort: Option<ReasoningEffort>,
},
/// Update the active (or default) model personality.
SetModelPersonality { personality: Option<Personality> },
/// Toggle the acknowledgement flag under `[notice]`.
SetNoticeHideFullAccessWarning(bool),
/// Toggle the Windows world-writable directories warning acknowledgement flag.
@@ -269,6 +272,10 @@ impl ConfigDocument {
);
mutated
}),
ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value(
&["model_personality"],
personality.map(|personality| value(personality.to_string())),
)),
ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value(
Scope::Global,
&[Notice::TABLE_KEY, "hide_full_access_warning"],
@@ -712,6 +719,12 @@ impl ConfigEditsBuilder {
self
}
pub fn set_model_personality(mut self, personality: Option<Personality>) -> Self {
self.edits
.push(ConfigEdit::SetModelPersonality { personality });
self
}
pub fn set_hide_full_access_warning(mut self, acknowledged: bool) -> Self {
self.edits
.push(ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged));
+1
View File
@@ -126,6 +126,7 @@ pub use safety::get_platform_sandbox;
pub use safety::is_windows_elevated_sandbox_enabled;
pub use safety::set_windows_elevated_sandbox_enabled;
pub use safety::set_windows_sandbox_enabled;
pub use tools::spec::parse_tool_input_schema;
// Re-export the protocol types from the standalone `codex-protocol` crate so existing
// `codex_core::protocol::...` references continue to work across the workspace.
pub use codex_protocol::protocol;
+10 -10
View File
@@ -169,6 +169,16 @@ pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo {
model_info!(
slug,
base_instructions: GPT_5_2_CODEX_INSTRUCTIONS.to_string(),
model_instructions_template: Some(ModelInstructionsTemplate {
template: GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE.to_string(),
personality_messages: Some(PersonalityMessages(BTreeMap::from([(
Personality::Friendly,
PERSONALITY_FRIENDLY.to_string(),
), (
Personality::Pragmatic,
PERSONALITY_PRAGMATIC.to_string(),
)]))),
}),
apply_patch_tool_type: Some(ApplyPatchToolType::Freeform),
shell_type: ConfigShellToolType::ShellCommand,
supports_parallel_tool_calls: true,
@@ -203,16 +213,6 @@ pub(crate) fn find_model_info_for_slug(slug: &str) -> ModelInfo {
truncation_policy: TruncationPolicyConfig::tokens(10_000),
context_window: Some(CONTEXT_WINDOW_272K),
supported_reasoning_levels: supported_reasoning_level_low_medium_high_xhigh(),
model_instructions_template: Some(ModelInstructionsTemplate {
template: GPT_5_2_CODEX_INSTRUCTIONS_TEMPLATE.to_string(),
personality_messages: Some(PersonalityMessages(BTreeMap::from([(
Personality::Friendly,
PERSONALITY_FRIENDLY.to_string(),
), (
Personality::Pragmatic,
PERSONALITY_PRAGMATIC.to_string(),
)]))),
}),
)
} else if slug.starts_with("gpt-5.1-codex-max") {
model_info!(
@@ -36,6 +36,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
supports_personality: true,
is_default: true,
upgrade: None,
show_in_picker: true,
@@ -65,6 +66,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: true,
@@ -87,6 +89,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
.to_string(),
},
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: true,
@@ -116,6 +119,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: true,
@@ -145,6 +149,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
supports_personality: true,
is_default: false,
upgrade: None,
show_in_picker: false,
@@ -174,6 +179,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
description: "Extra high reasoning depth for complex problems".to_string(),
},
],
supports_personality: false,
is_default: false,
upgrade: None,
show_in_picker: false,
@@ -200,6 +206,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(),
},
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: false,
@@ -221,6 +228,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(),
},
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: false,
@@ -247,6 +255,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
.to_string(),
},
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: false,
@@ -276,6 +285,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(),
},
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: false,
@@ -301,6 +311,7 @@ static PRESETS: Lazy<Vec<ModelPreset>> = Lazy::new(|| {
description: "Maximizes reasoning depth for complex or ambiguous problems".to_string(),
},
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt_52_codex_upgrade()),
show_in_picker: false,
+1
View File
@@ -68,6 +68,7 @@ pub(crate) fn should_persist_event_msg(ev: &EventMsg) -> bool {
| EventMsg::ExecCommandEnd(_)
| EventMsg::ExecApprovalRequest(_)
| EventMsg::RequestUserInput(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::ElicitationRequest(_)
| EventMsg::ApplyPatchApprovalRequest(_)
| EventMsg::BackgroundEvent(_)
+18
View File
@@ -8,6 +8,7 @@ use tokio::sync::Notify;
use tokio_util::sync::CancellationToken;
use tokio_util::task::AbortOnDropHandle;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::request_user_input::RequestUserInputResponse;
use tokio::sync::oneshot;
@@ -70,6 +71,7 @@ impl ActiveTurn {
pub(crate) struct TurnState {
pending_approvals: HashMap<String, oneshot::Sender<ReviewDecision>>,
pending_user_input: HashMap<String, oneshot::Sender<RequestUserInputResponse>>,
pending_dynamic_tools: HashMap<String, oneshot::Sender<DynamicToolResponse>>,
pending_input: Vec<ResponseInputItem>,
}
@@ -92,6 +94,7 @@ impl TurnState {
pub(crate) fn clear_pending(&mut self) {
self.pending_approvals.clear();
self.pending_user_input.clear();
self.pending_dynamic_tools.clear();
self.pending_input.clear();
}
@@ -110,6 +113,21 @@ impl TurnState {
self.pending_user_input.remove(key)
}
pub(crate) fn insert_pending_dynamic_tool(
&mut self,
key: String,
tx: oneshot::Sender<DynamicToolResponse>,
) -> Option<oneshot::Sender<DynamicToolResponse>> {
self.pending_dynamic_tools.insert(key, tx)
}
pub(crate) fn remove_pending_dynamic_tool(
&mut self,
key: &str,
) -> Option<oneshot::Sender<DynamicToolResponse>> {
self.pending_dynamic_tools.remove(key)
}
pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) {
self.pending_input.push(input);
}
+22 -1
View File
@@ -196,12 +196,21 @@ impl ThreadManager {
}
pub async fn start_thread(&self, config: Config) -> CodexResult<NewThread> {
self.start_thread_with_tools(config, Vec::new()).await
}
pub async fn start_thread_with_tools(
&self,
config: Config,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
) -> CodexResult<NewThread> {
self.state
.spawn_thread(
config,
InitialHistory::New,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
dynamic_tools,
)
.await
}
@@ -224,7 +233,13 @@ impl ThreadManager {
auth_manager: Arc<AuthManager>,
) -> CodexResult<NewThread> {
self.state
.spawn_thread(config, initial_history, auth_manager, self.agent_control())
.spawn_thread(
config,
initial_history,
auth_manager,
self.agent_control(),
Vec::new(),
)
.await
}
@@ -262,6 +277,7 @@ impl ThreadManager {
history,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
Vec::new(),
)
.await
}
@@ -330,6 +346,7 @@ impl ThreadManagerState {
Arc::clone(&self.auth_manager),
agent_control,
session_source,
Vec::new(),
)
.await
}
@@ -341,6 +358,7 @@ impl ThreadManagerState {
initial_history: InitialHistory,
auth_manager: Arc<AuthManager>,
agent_control: AgentControl,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
) -> CodexResult<NewThread> {
self.spawn_thread_with_source(
config,
@@ -348,6 +366,7 @@ impl ThreadManagerState {
auth_manager,
agent_control,
self.session_source.clone(),
dynamic_tools,
)
.await
}
@@ -359,6 +378,7 @@ impl ThreadManagerState {
auth_manager: Arc<AuthManager>,
agent_control: AgentControl,
session_source: SessionSource,
dynamic_tools: Vec<codex_protocol::dynamic_tools::DynamicToolSpec>,
) -> CodexResult<NewThread> {
let CodexSpawnOk {
codex, thread_id, ..
@@ -370,6 +390,7 @@ impl ThreadManagerState {
initial_history,
session_source,
agent_control,
dynamic_tools,
)
.await?;
self.finalize_thread_spawn(codex, thread_id).await
@@ -0,0 +1,98 @@
use crate::codex::Session;
use crate::codex::TurnContext;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use async_trait::async_trait;
use codex_protocol::dynamic_tools::DynamicToolCallRequest;
use codex_protocol::dynamic_tools::DynamicToolResponse;
use codex_protocol::protocol::EventMsg;
use serde_json::Value;
use tokio::sync::oneshot;
use tracing::warn;
pub struct DynamicToolHandler;
#[async_trait]
impl ToolHandler for DynamicToolHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool {
true
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session,
turn,
call_id,
tool_name,
payload,
..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"dynamic tool handler received unsupported payload".to_string(),
));
}
};
let args: Value = parse_arguments(&arguments)?;
let response = request_dynamic_tool(&session, turn.as_ref(), call_id, tool_name, args)
.await
.ok_or_else(|| {
FunctionCallError::RespondToModel(
"dynamic tool call was cancelled before receiving a response".to_string(),
)
})?;
Ok(ToolOutput::Function {
content: response.output,
content_items: None,
success: Some(response.success),
})
}
}
async fn request_dynamic_tool(
session: &Session,
turn_context: &TurnContext,
call_id: String,
tool: String,
arguments: Value,
) -> Option<DynamicToolResponse> {
let _sub_id = turn_context.sub_id.clone();
let (tx_response, rx_response) = oneshot::channel();
let event_id = call_id.clone();
let prev_entry = {
let mut active = session.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.insert_pending_dynamic_tool(call_id.clone(), tx_response)
}
None => None,
}
};
if prev_entry.is_some() {
warn!("Overwriting existing pending dynamic tool call for call_id: {event_id}");
}
let event = EventMsg::DynamicToolCallRequest(DynamicToolCallRequest {
call_id,
turn_id: turn_context.sub_id.clone(),
tool,
arguments,
});
session.send_event(turn_context, event).await;
rx_response.await.ok()
}
+2
View File
@@ -1,5 +1,6 @@
pub mod apply_patch;
pub(crate) mod collab;
mod dynamic;
mod grep_files;
mod list_dir;
mod mcp;
@@ -18,6 +19,7 @@ use serde::Deserialize;
use crate::function_tool::FunctionCallError;
pub use apply_patch::ApplyPatchHandler;
pub use collab::CollabHandler;
pub use dynamic::DynamicToolHandler;
pub use grep_files::GrepFilesHandler;
pub use list_dir::ListDirHandler;
pub use mcp::McpHandler;
+3 -1
View File
@@ -10,6 +10,7 @@ use crate::tools::registry::ConfiguredToolSpec;
use crate::tools::registry::ToolRegistry;
use crate::tools::spec::ToolsConfig;
use crate::tools::spec::build_specs;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::LocalShellAction;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
@@ -34,8 +35,9 @@ impl ToolRouter {
pub fn from_config(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, mcp_types::Tool>>,
dynamic_tools: &[DynamicToolSpec],
) -> Self {
let builder = build_specs(config, mcp_tools);
let builder = build_specs(config, mcp_tools, dynamic_tools);
let (specs, registry) = builder.build();
Self { registry, specs }
+60 -13
View File
@@ -10,6 +10,7 @@ use crate::tools::handlers::collab::DEFAULT_WAIT_TIMEOUT_MS;
use crate::tools::handlers::collab::MAX_WAIT_TIMEOUT_MS;
use crate::tools::registry::ToolRegistryBuilder;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::dynamic_tools::DynamicToolSpec;
use codex_protocol::models::VIEW_IMAGE_TOOL_NAME;
use codex_protocol::openai_models::ApplyPatchToolType;
use codex_protocol::openai_models::ConfigShellToolType;
@@ -87,7 +88,7 @@ impl ToolsConfig {
/// Generic JSON‑Schema subset needed for our tool definitions
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "lowercase")]
pub(crate) enum JsonSchema {
pub enum JsonSchema {
Boolean {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
@@ -123,7 +124,7 @@ pub(crate) enum JsonSchema {
/// Whether additional properties are allowed, and if so, any required schema
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub(crate) enum AdditionalProperties {
pub enum AdditionalProperties {
Boolean(bool),
Schema(Box<JsonSchema>),
}
@@ -1101,6 +1102,26 @@ pub(crate) fn mcp_tool_to_openai_tool(
})
}
fn dynamic_tool_to_openai_tool(
tool: &DynamicToolSpec,
) -> Result<ResponsesApiTool, serde_json::Error> {
let input_schema = parse_tool_input_schema(&tool.input_schema)?;
Ok(ResponsesApiTool {
name: tool.name.clone(),
description: tool.description.clone(),
strict: false,
parameters: input_schema,
})
}
/// Parse the tool input_schema or return an error for invalid schema
pub fn parse_tool_input_schema(input_schema: &JsonValue) -> Result<JsonSchema, serde_json::Error> {
let mut input_schema = input_schema.clone();
sanitize_json_schema(&mut input_schema);
serde_json::from_value::<JsonSchema>(input_schema)
}
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
/// JsonSchema enum. This function:
/// - Ensures every schema object has a "type". If missing, infers it from
@@ -1216,9 +1237,11 @@ fn sanitize_json_schema(value: &mut JsonValue) {
pub(crate) fn build_specs(
config: &ToolsConfig,
mcp_tools: Option<HashMap<String, mcp_types::Tool>>,
dynamic_tools: &[DynamicToolSpec],
) -> ToolRegistryBuilder {
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::CollabHandler;
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::GrepFilesHandler;
use crate::tools::handlers::ListDirHandler;
use crate::tools::handlers::McpHandler;
@@ -1239,6 +1262,7 @@ pub(crate) fn build_specs(
let unified_exec_handler = Arc::new(UnifiedExecHandler);
let plan_handler = Arc::new(PlanHandler);
let apply_patch_handler = Arc::new(ApplyPatchHandler);
let dynamic_tool_handler = Arc::new(DynamicToolHandler);
let view_image_handler = Arc::new(ViewImageHandler);
let mcp_handler = Arc::new(McpHandler);
let mcp_resource_handler = Arc::new(McpResourceHandler);
@@ -1384,6 +1408,23 @@ pub(crate) fn build_specs(
}
}
if !dynamic_tools.is_empty() {
for tool in dynamic_tools {
match dynamic_tool_to_openai_tool(tool) {
Ok(converted_tool) => {
builder.push_spec(ToolSpec::Function(converted_tool));
builder.register_handler(tool.name.clone(), dynamic_tool_handler.clone());
}
Err(e) => {
tracing::error!(
"Failed to convert dynamic tool {:?} to OpenAI tool: {e:?}",
tool.name
);
}
}
}
}
builder
}
@@ -1496,7 +1537,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Live),
});
let (tools, _) = build_specs(&config, None).build();
let (tools, _) = build_specs(&config, None, &[]).build();
// Build actual map name -> spec
use std::collections::BTreeMap;
@@ -1560,7 +1601,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert_contains_tool_names(
&tools,
&["spawn_agent", "send_input", "wait", "close_agent"],
@@ -1578,7 +1619,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert!(
!tools.iter().any(|t| t.spec.name() == "request_user_input"),
"request_user_input should be disabled when collaboration_modes feature is off"
@@ -1590,7 +1631,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert_contains_tool_names(&tools, &["request_user_input"]);
}
@@ -1607,7 +1648,7 @@ mod tests {
features,
web_search_mode,
});
let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build();
let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), &[]).build();
let tool_names = tools.iter().map(|t| t.spec.name()).collect::<Vec<_>>();
assert_eq!(&tool_names, &expected_tools,);
}
@@ -1623,7 +1664,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
let tool = find_tool(&tools, "web_search");
assert_eq!(
@@ -1645,7 +1686,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Live),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
let tool = find_tool(&tools, "web_search");
assert_eq!(
@@ -1891,7 +1932,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Live),
});
let (tools, _) = build_specs(&tools_config, Some(HashMap::new())).build();
let (tools, _) = build_specs(&tools_config, Some(HashMap::new()), &[]).build();
// Only check the shell variant and a couple of core tools.
let mut subset = vec!["exec_command", "write_stdin", "update_plan"];
@@ -1913,7 +1954,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert!(!find_tool(&tools, "exec_command").supports_parallel_tool_calls);
assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls);
@@ -1932,7 +1973,7 @@ mod tests {
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None).build();
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert!(
tools
@@ -1999,6 +2040,7 @@ mod tests {
description: Some("Do something cool".to_string()),
},
)])),
&[],
)
.build();
@@ -2108,7 +2150,7 @@ mod tests {
),
]);
let (tools, _) = build_specs(&tools_config, Some(tools_map)).build();
let (tools, _) = build_specs(&tools_config, Some(tools_map), &[]).build();
// Only assert that the MCP tools themselves are sorted by fully-qualified name.
let mcp_names: Vec<_> = tools
@@ -2157,6 +2199,7 @@ mod tests {
description: Some("Search docs".to_string()),
},
)])),
&[],
)
.build();
@@ -2212,6 +2255,7 @@ mod tests {
description: Some("Pagination".to_string()),
},
)])),
&[],
)
.build();
@@ -2266,6 +2310,7 @@ mod tests {
description: Some("Tags".to_string()),
},
)])),
&[],
)
.build();
@@ -2322,6 +2367,7 @@ mod tests {
description: Some("AnyOf Value".to_string()),
},
)])),
&[],
)
.build();
@@ -2459,6 +2505,7 @@ Examples of valid command strings:
description: Some("Do something cool".to_string()),
},
)])),
&[],
)
.build();
+11
View File
@@ -94,6 +94,7 @@ fn gpt_52_codex() -> ModelPreset {
"Extra high reasoning depth for complex problems",
),
],
supports_personality: false,
is_default: true,
upgrade: None,
show_in_picker: true,
@@ -126,6 +127,7 @@ fn gpt_5_1_codex_max() -> ModelPreset {
"Extra high reasoning depth for complex problems",
),
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt52_codex_upgrade(
"gpt-5.1-codex-max",
@@ -160,6 +162,7 @@ fn gpt_5_1_codex_mini() -> ModelPreset {
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt52_codex_upgrade(
"gpt-5.1-codex-mini",
@@ -204,6 +207,7 @@ fn gpt_5_2() -> ModelPreset {
"Extra high reasoning for complex problems",
),
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt52_codex_upgrade(
"gpt-5.2",
@@ -246,6 +250,7 @@ fn bengalfox() -> ModelPreset {
"Extra high reasoning depth for complex problems",
),
],
supports_personality: true,
is_default: false,
upgrade: None,
show_in_picker: false,
@@ -278,6 +283,7 @@ fn boomslang() -> ModelPreset {
"Extra high reasoning depth for complex problems",
),
],
supports_personality: false,
is_default: false,
upgrade: None,
show_in_picker: false,
@@ -306,6 +312,7 @@ fn gpt_5_codex() -> ModelPreset {
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt52_codex_upgrade(
"gpt-5-codex",
@@ -340,6 +347,7 @@ fn gpt_5_codex_mini() -> ModelPreset {
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt52_codex_upgrade(
"gpt-5-codex-mini",
@@ -378,6 +386,7 @@ fn gpt_5_1_codex() -> ModelPreset {
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt52_codex_upgrade(
"gpt-5.1-codex",
@@ -420,6 +429,7 @@ fn gpt_5() -> ModelPreset {
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt52_codex_upgrade(
"gpt-5",
@@ -458,6 +468,7 @@ fn gpt_5_1() -> ModelPreset {
"Maximizes reasoning depth for complex or ambiguous problems",
),
],
supports_personality: false,
is_default: false,
upgrade: Some(gpt52_codex_upgrade(
"gpt-5.1",
+2 -2
View File
@@ -122,7 +122,7 @@ async fn config_personality_some_sets_instructions_template() -> anyhow::Result<
let server = start_mock_server().await;
let resp_mock = mount_sse_once(&server, sse_completed("resp-1")).await;
let mut builder = test_codex()
.with_model("gpt-5.2-codex")
.with_model("exp-codex-personality")
.with_config(|config| {
config.model_personality = Some(Personality::Friendly);
config.features.disable(Feature::RemoteModels);
@@ -179,7 +179,7 @@ async fn user_turn_personality_some_adds_update_message() -> anyhow::Result<()>
)
.await;
let mut builder = test_codex()
.with_model("gpt-5.2-codex")
.with_model("exp-codex-personality")
.with_config(|config| {
config.features.disable(Feature::RemoteModels);
});
+1
View File
@@ -94,6 +94,7 @@ Each response yields:
- `reasoningEffort` – one of `minimal|low|medium|high`
- `description` – human-friendly label for the effort
- `defaultReasoningEffort` – suggested effort for the UI
- `supportsPersonality` – whether the model supports personality-specific instructions
- `isDefault` – whether the model is recommended for most users
- `nextCursor` – pass into the next request to continue paging (optional)
@@ -607,7 +607,8 @@ impl EventProcessor for EventProcessorWithHumanOutput {
| EventMsg::UndoCompleted(_)
| EventMsg::UndoStarted(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::RequestUserInput(_) => {}
| EventMsg::RequestUserInput(_)
| EventMsg::DynamicToolCallRequest(_) => {}
}
CodexStatus::Running
}
@@ -360,6 +360,7 @@ async fn run_codex_tool_session_inner(
| EventMsg::UndoCompleted(_)
| EventMsg::ExitedReviewMode(_)
| EventMsg::RequestUserInput(_)
| EventMsg::DynamicToolCallRequest(_)
| EventMsg::ContextCompacted(_)
| EventMsg::ThreadRolledBack(_)
| EventMsg::CollabAgentSpawnBegin(_)
+2
View File
@@ -2,6 +2,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use strum_macros::Display;
use strum_macros::EnumIter;
use ts_rs::TS;
use crate::openai_models::ReasoningEffort;
@@ -78,6 +79,7 @@ pub enum SandboxMode {
TS,
PartialOrd,
Ord,
EnumIter,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
+30
View File
@@ -0,0 +1,30 @@
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use ts_rs::TS;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct DynamicToolSpec {
pub name: String,
pub description: String,
pub input_schema: JsonValue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct DynamicToolCallRequest {
pub call_id: String,
pub turn_id: String,
pub tool: String,
pub arguments: JsonValue,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
pub struct DynamicToolResponse {
pub call_id: String,
pub output: String,
pub success: bool,
}
+1
View File
@@ -4,6 +4,7 @@ pub use thread_id::ThreadId;
pub mod approvals;
pub mod config_types;
pub mod custom_prompts;
pub mod dynamic_tools;
pub mod items;
pub mod message_history;
pub mod models;
+18
View File
@@ -78,6 +78,9 @@ pub struct ModelPreset {
pub default_reasoning_effort: ReasoningEffort,
/// Supported reasoning effort options.
pub supported_reasoning_efforts: Vec<ReasoningEffortPreset>,
/// Whether this model supports personality-specific instructions.
#[serde(default)]
pub supports_personality: bool,
/// Whether this is the default model for new users.
pub is_default: bool,
/// recommended upgrade model
@@ -214,6 +217,12 @@ impl ModelInfo {
})
}
pub fn supports_personality(&self) -> bool {
self.model_instructions_template
.as_ref()
.is_some_and(ModelInstructionsTemplate::supports_personality)
}
pub fn get_model_instructions(&self, personality: Option<Personality>) -> String {
if let Some(personality) = personality
&& let Some(template) = &self.model_instructions_template
@@ -249,6 +258,13 @@ impl ModelInstructionsTemplate {
fn has_personality_placeholder(&self) -> bool {
self.template.contains(PERSONALITY_PLACEHOLDER)
}
fn supports_personality(&self) -> bool {
self.has_personality_placeholder()
&& self.personality_messages.as_ref().is_some_and(|messages| {
Personality::iter().all(|personality| messages.0.contains_key(&personality))
})
}
}
// serializes as a dictionary from personality to message
@@ -280,6 +296,7 @@ pub struct ModelsResponse {
// convert ModelInfo to ModelPreset
impl From<ModelInfo> for ModelPreset {
fn from(info: ModelInfo) -> Self {
let supports_personality = info.supports_personality();
ModelPreset {
id: info.slug.clone(),
model: info.slug.clone(),
@@ -289,6 +306,7 @@ impl From<ModelInfo> for ModelPreset {
.default_reasoning_level
.unwrap_or(ReasoningEffort::None),
supported_reasoning_efforts: info.supported_reasoning_levels.clone(),
supports_personality,
is_default: false, // default is the highest priority available model
upgrade: info.upgrade.as_ref().map(|upgrade| ModelUpgrade {
id: upgrade.model.clone(),
+12
View File
@@ -17,6 +17,8 @@ use crate::config_types::CollaborationMode;
use crate::config_types::Personality;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::custom_prompts::CustomPrompt;
use crate::dynamic_tools::DynamicToolCallRequest;
use crate::dynamic_tools::DynamicToolResponse;
use crate::items::TurnItem;
use crate::message_history::HistoryEntry;
use crate::models::BaseInstructions;
@@ -216,6 +218,14 @@ pub enum Op {
response: RequestUserInputResponse,
},
/// Resolve a dynamic tool call request.
DynamicToolResponse {
/// Call id for the in-flight request.
id: String,
/// Tool output payload.
response: DynamicToolResponse,
},
/// Append an entry to the persistent cross-session message history.
///
/// Note the entry is not guaranteed to be logged if the user has
@@ -750,6 +760,8 @@ pub enum EventMsg {
RequestUserInput(RequestUserInputEvent),
DynamicToolCallRequest(DynamicToolCallRequest),
ElicitationRequest(ElicitationRequestEvent),
ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent),
+67 -33
View File
@@ -60,6 +60,7 @@ use codex_core::protocol::SkillErrorInfo;
use codex_core::protocol::TokenUsage;
use codex_otel::OtelManager;
use codex_protocol::ThreadId;
use codex_protocol::config_types::Personality;
use codex_protocol::items::TurnItem;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ModelUpgrade;
@@ -932,14 +933,10 @@ impl App {
let auth = auth_manager.auth().await;
let auth_ref = auth.as_ref();
let model_info = thread_manager
.get_models_manager()
.get_model_info(model.as_str(), &config)
.await;
let otel_manager = OtelManager::new(
ThreadId::new(),
model.as_str(),
model_info.slug.as_str(),
model.as_str(),
auth_ref.and_then(CodexAuth::get_account_id),
auth_ref.and_then(CodexAuth::get_account_email),
auth_ref.map(|auth| auth.mode),
@@ -1505,6 +1502,9 @@ impl App {
AppEvent::UpdateCollaborationMode(mask) => {
self.chat_widget.set_collaboration_mask(mask);
}
AppEvent::UpdatePersonality(personality) => {
self.on_update_personality(personality);
}
AppEvent::OpenReasoningPopup { model } => {
self.chat_widget.open_reasoning_popup(model);
}
@@ -1758,6 +1758,41 @@ impl App {
}
}
}
AppEvent::PersistPersonalitySelection { personality } => {
let profile = self.active_profile.as_deref();
match ConfigEditsBuilder::new(&self.config.codex_home)
.with_profile(profile)
.set_model_personality(Some(personality))
.apply()
.await
{
Ok(()) => {
let label = Self::personality_label(personality);
let mut message = format!("Personality set to {label}");
if let Some(profile) = profile {
message.push_str(" for ");
message.push_str(profile);
message.push_str(" profile");
}
self.chat_widget.add_info_message(message, None);
}
Err(err) => {
tracing::error!(
error = %err,
"failed to persist personality selection"
);
if let Some(profile) = profile {
self.chat_widget.add_error_message(format!(
"Failed to save personality for profile `{profile}`: {err}"
));
} else {
self.chat_widget.add_error_message(format!(
"Failed to save default personality: {err}"
));
}
}
}
}
AppEvent::UpdateAskForApprovalPolicy(policy) => {
self.runtime_approval_policy_override = Some(policy);
if let Err(err) = self.config.approval_policy.set(policy) {
@@ -2074,9 +2109,23 @@ impl App {
return Ok(());
}
};
let config_snapshot = thread.config_snapshot().await;
let event = Event {
id: String::new(),
msg: EventMsg::SessionConfigured(self.session_configured_for_thread(thread_id)),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: thread_id,
forked_from_id: None,
model: config_snapshot.model,
model_provider_id: config_snapshot.model_provider_id,
approval_policy: config_snapshot.approval_policy,
sandbox_policy: config_snapshot.sandbox_policy,
cwd: config_snapshot.cwd,
reasoning_effort: config_snapshot.reasoning_effort,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
rollout_path: thread.rollout_path(),
}),
};
let channel =
ThreadEventChannel::new_with_session_configured(THREAD_EVENT_CHANNEL_CAPACITY, event);
@@ -2106,33 +2155,6 @@ impl App {
Ok(())
}
fn session_configured_for_thread(&self, thread_id: ThreadId) -> SessionConfiguredEvent {
let mut session_configured =
self.primary_session_configured
.clone()
.unwrap_or_else(|| SessionConfiguredEvent {
session_id: thread_id,
forked_from_id: None,
model: self.chat_widget.current_model().to_string(),
model_provider_id: self.config.model_provider_id.clone(),
approval_policy: *self.config.approval_policy.get(),
sandbox_policy: self.config.sandbox_policy.get().clone(),
cwd: self.config.cwd.clone(),
reasoning_effort: None,
history_log_id: 0,
history_entry_count: 0,
initial_messages: None,
rollout_path: Some(PathBuf::new()),
});
session_configured.session_id = thread_id;
session_configured.forked_from_id = None;
session_configured.history_log_id = 0;
session_configured.history_entry_count = 0;
session_configured.initial_messages = None;
session_configured.rollout_path = Some(PathBuf::new());
session_configured
}
fn reasoning_label(reasoning_effort: Option<ReasoningEffortConfig>) -> &'static str {
match reasoning_effort {
Some(ReasoningEffortConfig::Minimal) => "minimal",
@@ -2162,6 +2184,18 @@ impl App {
self.chat_widget.set_reasoning_effort(effort);
}
fn on_update_personality(&mut self, personality: Personality) {
self.config.model_personality = Some(personality);
self.chat_widget.set_personality(personality);
}
fn personality_label(personality: Personality) -> &'static str {
match personality {
Personality::Friendly => "Friendly",
Personality::Pragmatic => "Pragmatic",
}
}
async fn launch_external_editor(&mut self, tui: &mut tui::Tui) {
let editor_cmd = match external_editor::resolve_editor_command() {
Ok(cmd) => cmd,
+9
View File
@@ -24,6 +24,7 @@ use codex_core::features::Feature;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::Personality;
use codex_protocol::openai_models::ReasoningEffort;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -106,12 +107,20 @@ pub(crate) enum AppEvent {
/// Update the active collaboration mask in the running app and widget.
UpdateCollaborationMode(CollaborationModeMask),
/// Update the current personality in the running app and widget.
UpdatePersonality(Personality),
/// Persist the selected model and reasoning effort to the appropriate config.
PersistModelSelection {
model: String,
effort: Option<ReasoningEffort>,
},
/// Persist the selected personality to the appropriate config.
PersistPersonalitySelection {
personality: Personality,
},
/// Open the reasoning selection popup after picking a model.
OpenReasoningPopup {
model: ModelPreset,
+32 -12
View File
@@ -235,6 +235,7 @@ pub(crate) struct ChatComposer {
steer_enabled: bool,
collaboration_modes_enabled: bool,
collaboration_mode_indicator: Option<CollaborationModeIndicator>,
personality_command_enabled: bool,
}
#[derive(Clone, Debug)]
@@ -296,6 +297,7 @@ impl ChatComposer {
steer_enabled: false,
collaboration_modes_enabled: false,
collaboration_mode_indicator: None,
personality_command_enabled: false,
};
// Apply configuration via the setter to keep side-effects centralized.
this.set_disable_paste_burst(disable_paste_burst);
@@ -327,6 +329,10 @@ impl ChatComposer {
self.collaboration_mode_indicator = indicator;
}
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
self.personality_command_enabled = enabled;
}
fn layout_areas(&self, area: Rect) -> [Rect; 3] {
let footer_props = self.footer_props();
let footer_hint_height = self
@@ -1631,9 +1637,11 @@ impl ChatComposer {
if let Some((name, _rest, _rest_offset)) = parse_slash_name(&text) {
let treat_as_plain_text = input_starts_with_space || name.contains('/');
if !treat_as_plain_text {
let is_builtin =
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
.any(|(command_name, _)| command_name == name);
let is_builtin = Self::built_in_slash_commands_for_input(
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.any(|(command_name, _)| command_name == name);
let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:");
let is_known_prompt = name
.strip_prefix(&prompt_prefix)
@@ -1798,9 +1806,11 @@ impl ChatComposer {
let first_line = self.textarea.text().lines().next().unwrap_or("");
if let Some((name, rest, _rest_offset)) = parse_slash_name(first_line)
&& rest.is_empty()
&& let Some((_n, cmd)) =
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
.find(|(n, _)| *n == name)
&& let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.find(|(n, _)| *n == name)
{
self.textarea.set_text_clearing_elements("");
Some(InputResult::Command(cmd))
@@ -1820,9 +1830,11 @@ impl ChatComposer {
if let Some((name, rest, _rest_offset)) = parse_slash_name(&text)
&& !rest.is_empty()
&& !name.contains('/')
&& let Some((_n, cmd)) =
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
.find(|(command_name, _)| *command_name == name)
&& let Some((_n, cmd)) = Self::built_in_slash_commands_for_input(
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.find(|(command_name, _)| *command_name == name)
&& cmd == SlashCommand::Review
{
self.textarea.set_text_clearing_elements("");
@@ -2253,9 +2265,11 @@ impl ChatComposer {
return rest_after_name.is_empty();
}
let builtin_match =
Self::built_in_slash_commands_for_input(self.collaboration_modes_enabled)
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
let builtin_match = Self::built_in_slash_commands_for_input(
self.collaboration_modes_enabled,
self.personality_command_enabled,
)
.any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some());
if builtin_match {
return true;
@@ -2308,10 +2322,12 @@ impl ChatComposer {
_ => {
if is_editing_slash_command_name {
let collaboration_modes_enabled = self.collaboration_modes_enabled;
let personality_command_enabled = self.personality_command_enabled;
let mut command_popup = CommandPopup::new(
self.custom_prompts.clone(),
CommandPopupFlags {
collaboration_modes_enabled,
personality_command_enabled,
},
);
command_popup.on_composer_text_change(first_line.to_string());
@@ -2323,12 +2339,16 @@ impl ChatComposer {
fn built_in_slash_commands_for_input(
collaboration_modes_enabled: bool,
personality_command_enabled: bool,
) -> impl Iterator<Item = (&'static str, SlashCommand)> {
let allow_elevate_sandbox = windows_degraded_sandbox_active();
built_in_slash_commands()
.into_iter()
.filter(move |(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(move |(_, cmd)| collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(move |(_, cmd)| {
personality_command_enabled || *cmd != SlashCommand::Personality
})
}
pub(crate) fn set_custom_prompts(&mut self, prompts: Vec<CustomPrompt>) {
@@ -39,6 +39,7 @@ pub(crate) struct CommandPopup {
#[derive(Clone, Copy, Debug, Default)]
pub(crate) struct CommandPopupFlags {
pub(crate) collaboration_modes_enabled: bool,
pub(crate) personality_command_enabled: bool,
}
impl CommandPopup {
@@ -48,6 +49,9 @@ impl CommandPopup {
.into_iter()
.filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox)
.filter(|(_, cmd)| flags.collaboration_modes_enabled || *cmd != SlashCommand::Collab)
.filter(|(_, cmd)| {
flags.personality_command_enabled || *cmd != SlashCommand::Personality
})
.collect();
// Exclude prompts that collide with builtin command names and sort by name.
let exclude: HashSet<String> = builtins.iter().map(|(n, _)| (*n).to_string()).collect();
@@ -216,6 +220,7 @@ impl CommandPopup {
display_shortcut: None,
description: Some(description),
wrap_indent: None,
is_disabled: false,
disabled_reason: None,
}
})
@@ -466,6 +471,7 @@ mod tests {
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
personality_command_enabled: true,
},
);
popup.on_composer_text_change("/collab".to_string());
@@ -475,4 +481,46 @@ mod tests {
other => panic!("expected collab to be selected for exact match, got {other:?}"),
}
}
#[test]
fn personality_command_hidden_when_disabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
personality_command_enabled: false,
},
);
popup.on_composer_text_change("/pers".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(&"personality"),
"expected '/personality' to be hidden when disabled, got {cmds:?}"
);
}
#[test]
fn personality_command_visible_when_enabled() {
let mut popup = CommandPopup::new(
Vec::new(),
CommandPopupFlags {
collaboration_modes_enabled: true,
personality_command_enabled: true,
},
);
popup.on_composer_text_change("/personality".to_string());
match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "personality"),
other => panic!("expected personality to be selected for exact match, got {other:?}"),
}
}
}
@@ -132,6 +132,7 @@ impl WidgetRef for &FileSearchPopup {
display_shortcut: None,
description: None,
wrap_indent: None,
is_disabled: false,
disabled_reason: None,
})
.collect()
@@ -42,6 +42,7 @@ pub(crate) struct SelectionItem {
pub selected_description: Option<String>,
pub is_current: bool,
pub is_default: bool,
pub is_disabled: bool,
pub actions: Vec<SelectionAction>,
pub dismiss_on_select: bool,
pub search_value: Option<String>,
@@ -217,12 +218,14 @@ impl ListSelectionView {
.flatten()
.or_else(|| item.description.clone());
let wrap_indent = description.is_none().then_some(wrap_prefix_width);
let is_disabled = item.is_disabled || item.disabled_reason.is_some();
GenericDisplayRow {
name: display_name,
display_shortcut: item.display_shortcut,
match_indices: None,
description,
wrap_indent,
is_disabled,
disabled_reason: item.disabled_reason.clone(),
}
})
@@ -247,19 +250,27 @@ impl ListSelectionView {
}
fn accept(&mut self) {
if let Some(idx) = self.state.selected_idx
&& let Some(actual_idx) = self.filtered_indices.get(idx)
&& let Some(item) = self.items.get(*actual_idx)
let selected_item = self
.state
.selected_idx
.and_then(|idx| self.filtered_indices.get(idx))
.and_then(|actual_idx| self.items.get(*actual_idx));
if let Some(item) = selected_item
&& item.disabled_reason.is_none()
&& !item.is_disabled
{
self.last_selected_actual_idx = Some(*actual_idx);
if let Some(idx) = self.state.selected_idx
&& let Some(actual_idx) = self.filtered_indices.get(idx)
{
self.last_selected_actual_idx = Some(*actual_idx);
}
for act in &item.actions {
act(&self.app_event_tx);
}
if item.dismiss_on_select {
self.complete = true;
}
} else {
} else if selected_item.is_none() {
self.complete = true;
}
}
@@ -286,7 +297,7 @@ impl ListSelectionView {
&& self
.items
.get(*actual_idx)
.is_some_and(|item| item.disabled_reason.is_some())
.is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled)
{
self.state.move_down_wrap(len);
} else {
@@ -303,7 +314,7 @@ impl ListSelectionView {
&& self
.items
.get(*actual_idx)
.is_some_and(|item| item.disabled_reason.is_some())
.is_some_and(|item| item.disabled_reason.is_some() || item.is_disabled)
{
self.state.move_up_wrap(len);
} else {
@@ -395,7 +406,7 @@ impl BottomPaneView for ListSelectionView {
&& self
.items
.get(idx)
.is_some_and(|item| item.disabled_reason.is_none())
.is_some_and(|item| item.disabled_reason.is_none() && !item.is_disabled)
{
self.state.selected_idx = Some(idx);
self.accept();
+5
View File
@@ -215,6 +215,11 @@ impl BottomPane {
self.request_redraw();
}
pub fn set_personality_command_enabled(&mut self, enabled: bool) {
self.composer.set_personality_command_enabled(enabled);
self.request_redraw();
}
pub fn status_widget(&self) -> Option<&StatusIndicatorWidget> {
self.status.as_ref()
}
@@ -23,7 +23,8 @@ pub(crate) struct GenericDisplayRow {
pub match_indices: Option<Vec<usize>>, // indices to bold (char positions)
pub description: Option<String>, // optional grey text after the name
pub disabled_reason: Option<String>, // optional disabled message
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
pub is_disabled: bool,
pub wrap_indent: Option<usize>, // optional indent for wrapped lines
}
pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec<Line<'a>> {
@@ -282,13 +283,18 @@ pub(crate) fn render_rows(
}
let mut full_line = build_full_line(row, desc_col);
if Some(i) == state.selected_idx {
if Some(i) == state.selected_idx && !row.is_disabled {
// Match previous behavior: cyan + bold for the selected row.
// Reset the style first to avoid inheriting dim from keyboard shortcuts.
full_line.spans.iter_mut().for_each(|span| {
span.style = Style::default().fg(Color::Cyan).bold();
});
}
if row.is_disabled {
full_line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
});
}
// Wrap with subsequent indent aligned to the description column.
use crate::wrapping::RtOptions;
@@ -364,11 +370,16 @@ pub(crate) fn render_rows_single_line(
}
let mut full_line = build_full_line(row, desc_col);
if Some(i) == state.selected_idx {
if Some(i) == state.selected_idx && !row.is_disabled {
full_line.spans.iter_mut().for_each(|span| {
span.style = Style::default().fg(Color::Cyan).bold();
});
}
if row.is_disabled {
full_line.spans.iter_mut().for_each(|span| {
span.style = span.style.dim();
});
}
let full_line = truncate_line_with_ellipsis_if_overflow(full_line, area.width as usize);
full_line.render(
@@ -96,6 +96,7 @@ impl SkillPopup {
match_indices: indices,
display_shortcut: None,
description: Some(description),
is_disabled: false,
disabled_reason: None,
wrap_indent: None,
}
+124 -2
View File
@@ -95,6 +95,7 @@ use codex_protocol::approvals::ElicitationRequestEvent;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::Settings;
use codex_protocol::models::local_image_label_text;
use codex_protocol::parse_command::ParsedCommand;
@@ -738,6 +739,7 @@ impl ChatWidget {
None,
);
self.refresh_model_display();
self.sync_personality_command_enabled();
let session_info_cell = history_cell::new_session_info(
&self.config,
&model_for_header,
@@ -2023,6 +2025,7 @@ impl ChatWidget {
widget.bottom_pane.set_collaboration_modes_enabled(
widget.config.features.enabled(Feature::CollaborationModes),
);
widget.sync_personality_command_enabled();
widget.update_collaboration_mode_indicator();
widget
@@ -2147,6 +2150,7 @@ impl ChatWidget {
widget.bottom_pane.set_collaboration_modes_enabled(
widget.config.features.enabled(Feature::CollaborationModes),
);
widget.sync_personality_command_enabled();
widget
}
@@ -2272,6 +2276,7 @@ impl ChatWidget {
widget.bottom_pane.set_collaboration_modes_enabled(
widget.config.features.enabled(Feature::CollaborationModes),
);
widget.sync_personality_command_enabled();
widget.update_collaboration_mode_indicator();
widget
@@ -2507,6 +2512,9 @@ impl ChatWidget {
SlashCommand::Model => {
self.open_model_popup();
}
SlashCommand::Personality => {
self.open_personality_popup();
}
SlashCommand::Collab => {
if self.collaboration_modes_enabled() {
self.open_collaboration_modes_popup();
@@ -2830,6 +2838,10 @@ impl ChatWidget {
} else {
None
};
let personality = self
.config
.model_personality
.filter(|_| self.current_model_supports_personality());
let op = Op::UserTurn {
items,
cwd: self.config.cwd.clone(),
@@ -2840,7 +2852,7 @@ impl ChatWidget {
summary: self.config.model_reasoning_summary,
final_output_json_schema: None,
collaboration_mode,
personality: None,
personality,
};
self.codex_op_tx.send(op).unwrap_or_else(|e| {
@@ -3031,7 +3043,8 @@ impl ChatWidget {
| EventMsg::ItemCompleted(_)
| EventMsg::AgentMessageContentDelta(_)
| EventMsg::ReasoningContentDelta(_)
| EventMsg::ReasoningRawContentDelta(_) => {}
| EventMsg::ReasoningRawContentDelta(_)
| EventMsg::DynamicToolCallRequest(_) => {}
}
}
@@ -3382,6 +3395,76 @@ impl ChatWidget {
self.open_model_popup_with_presets(presets);
}
pub(crate) fn open_personality_popup(&mut self) {
if !self.is_session_configured() {
self.add_info_message(
"Personality selection is disabled until startup completes.".to_string(),
None,
);
return;
}
self.open_personality_popup_for_current_model();
}
fn open_personality_popup_for_current_model(&mut self) {
let current_model = self.current_model();
let current_personality = self.config.model_personality;
let personalities = [Personality::Friendly, Personality::Pragmatic];
let supports_personality = self.current_model_supports_personality();
let disabled_message = (!supports_personality).then(|| {
format!(
"Current model ({current_model}) doesn't support personalities. Try /model to switch to a newer model."
)
});
let items: Vec<SelectionItem> = personalities
.into_iter()
.map(|personality| {
let name = Self::personality_label(personality).to_string();
let description = Some(Self::personality_description(personality).to_string());
let actions: Vec<SelectionAction> = vec![Box::new(move |tx| {
tx.send(AppEvent::CodexOp(Op::OverrideTurnContext {
cwd: None,
approval_policy: None,
sandbox_policy: None,
model: None,
effort: None,
summary: None,
collaboration_mode: None,
personality: Some(personality),
}));
tx.send(AppEvent::UpdatePersonality(personality));
tx.send(AppEvent::PersistPersonalitySelection { personality });
})];
SelectionItem {
name,
description,
is_current: current_personality == Some(personality),
is_disabled: !supports_personality,
actions,
dismiss_on_select: true,
..Default::default()
}
})
.collect();
let mut header = ColumnRenderable::new();
header.push(Line::from("Select Personality".bold()));
header.push(Line::from(
"Choose a communication style for future responses.".dim(),
));
if let Some(message) = disabled_message {
header.push(Line::from(message.red()));
}
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();
@@ -4608,6 +4691,11 @@ impl ChatWidget {
}
}
/// Set the personality in the widget's config copy.
pub(crate) fn set_personality(&mut self, personality: Personality) {
self.config.model_personality = Some(personality);
}
/// Set the model in the widget's config copy and stored collaboration mode.
pub(crate) fn set_model(&mut self, model: &str) {
self.current_collaboration_mode =
@@ -4619,6 +4707,7 @@ impl ChatWidget {
mask.model = Some(model.to_string());
}
self.refresh_model_display();
self.sync_personality_command_enabled();
}
pub(crate) fn current_model(&self) -> &str {
@@ -4631,6 +4720,25 @@ impl ChatWidget {
.unwrap_or_else(|| self.current_collaboration_mode.model())
}
fn sync_personality_command_enabled(&mut self) {
self.bottom_pane
.set_personality_command_enabled(self.current_model_supports_personality());
}
fn current_model_supports_personality(&self) -> bool {
let model = self.current_model();
self.models_manager
.try_list_models(&self.config)
.ok()
.and_then(|models| {
models
.into_iter()
.find(|preset| preset.model == model)
.map(|preset| preset.supports_personality)
})
.unwrap_or(false)
}
#[allow(dead_code)] // Used in tests
pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode {
&self.current_collaboration_mode
@@ -4746,6 +4854,20 @@ impl ChatWidget {
self.bottom_pane.set_collaboration_mode_indicator(indicator);
}
fn personality_label(personality: Personality) -> &'static str {
match personality {
Personality::Friendly => "Friendly",
Personality::Pragmatic => "Pragmatic",
}
}
fn personality_description(personality: Personality) -> &'static str {
match personality {
Personality::Friendly => "Warm, collaborative, and helpful.",
Personality::Pragmatic => "Concise, task-focused, and direct.",
}
}
/// Cycle to the next collaboration mode variant (Plan -> Code -> Plan).
fn cycle_collaboration_mode(&mut self) {
if !self.collaboration_modes_enabled() {
@@ -0,0 +1,11 @@
---
source: tui/src/chatwidget/tests.rs
expression: popup
---
Select Personality
Choose a communication style for future responses.
› 1. Friendly Warm, collaborative, and helpful.
2. Pragmatic Concise, task-focused, and direct.
Press enter to confirm or esc to go back
+35 -2
View File
@@ -65,6 +65,7 @@ use codex_protocol::ThreadId;
use codex_protocol::account::PlanType;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::Settings;
use codex_protocol::openai_models::ModelPreset;
use codex_protocol::openai_models::ReasoningEffortPreset;
@@ -788,7 +789,7 @@ async fn make_chatwidget_manual(
},
};
let current_collaboration_mode = base_mode;
let widget = ChatWidget {
let mut widget = ChatWidget {
app_event_tx,
codex_op_tx: op_tx,
bottom_pane: bottom,
@@ -800,7 +801,7 @@ async fn make_chatwidget_manual(
auth_manager,
models_manager,
otel_manager,
session_header: SessionHeader::new(resolved_model),
session_header: SessionHeader::new(resolved_model.clone()),
initial_user_message: None,
token_info: None,
rate_limit_snapshot: None,
@@ -844,6 +845,7 @@ async fn make_chatwidget_manual(
current_rollout_path: None,
external_editor_state: ExternalEditorState::Closed,
};
widget.set_model(&resolved_model);
(widget, rx, op_rx)
}
@@ -2416,6 +2418,25 @@ async fn collab_mode_enabling_keeps_custom_until_selected() {
assert_eq!(chat.current_collaboration_mode().mode, ModeKind::Custom);
}
#[tokio::test]
async fn user_turn_includes_personality_from_config() {
let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("bengalfox")).await;
chat.thread_id = Some(ThreadId::new());
chat.set_model("bengalfox");
chat.set_personality(Personality::Friendly);
chat.bottom_pane
.set_composer_text("hello".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
match next_submit_op(&mut op_rx) {
Op::UserTurn {
personality: Some(Personality::Friendly),
..
} => {}
other => panic!("expected Op::UserTurn with friendly personality, got {other:?}"),
}
}
#[tokio::test]
async fn slash_quit_requests_exit() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -2960,6 +2981,16 @@ async fn model_selection_popup_snapshot() {
assert_snapshot!("model_selection_popup", popup);
}
#[tokio::test]
async fn personality_selection_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("bengalfox")).await;
chat.thread_id = Some(ThreadId::new());
chat.open_personality_popup();
let popup = render_bottom_popup(&chat, 80);
assert_snapshot!("personality_selection_popup", popup);
}
#[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;
@@ -2974,6 +3005,7 @@ async fn model_picker_hides_show_in_picker_false_models_from_cache() {
effort: ReasoningEffortConfig::Medium,
description: "medium".to_string(),
}],
supports_personality: false,
is_default: false,
upgrade: None,
show_in_picker,
@@ -3186,6 +3218,7 @@ async fn single_reasoning_option_skips_selection() {
description: "".to_string(),
default_reasoning_effort: ReasoningEffortConfig::High,
supported_reasoning_efforts: single_effort,
supports_personality: false,
is_default: false,
upgrade: None,
show_in_picker: true,
+3
View File
@@ -13,6 +13,7 @@ pub enum SlashCommand {
// DO NOT ALPHA-SORT! Enum order is presentation order in the popup, so
// more frequently used commands should be listed first.
Model,
Personality,
Approvals,
Permissions,
#[strum(serialize = "setup-elevated-sandbox")]
@@ -60,6 +61,7 @@ impl SlashCommand {
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Ps => "list background terminals",
SlashCommand::Model => "choose what model and reasoning effort to use",
SlashCommand::Personality => "choose a communication style for responses",
SlashCommand::Collab => "change collaboration mode (experimental)",
SlashCommand::Agent => "switch the active agent thread",
SlashCommand::Approvals => "choose what Codex can do without approval",
@@ -89,6 +91,7 @@ impl SlashCommand {
| SlashCommand::Compact
// | SlashCommand::Undo
| SlashCommand::Model
| SlashCommand::Personality
| SlashCommand::Approvals
| SlashCommand::Permissions
| SlashCommand::ElevateSandbox
+6 -1
View File
@@ -104,7 +104,12 @@ pub(crate) mod announcement {
}
fn blocking_init_announcement_tip() -> Option<String> {
let response = reqwest::blocking::Client::new()
// Avoid system proxy detection to prevent macOS system-configuration panics (#8912).
let client = reqwest::blocking::Client::builder()
.no_proxy()
.build()
.ok()?;
let response = client
.get(ANNOUNCEMENT_TIP_URL)
.timeout(Duration::from_millis(2000))
.send()