diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index fcc35a60dd..75acf96f2a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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). diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index e282e924fc..65648bbdb0 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -325,6 +325,15 @@ pub struct ToolsV2 { pub view_image: Option, } +#[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, 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, pub personality: Option, pub ephemeral: Option, + pub dynamic_tools: Option>, /// 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/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 659e2bf72c..a4860ea512 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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": { diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 68e233b843..0cb6fb1bc6 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -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( diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index d0b8c7f949..ab2f31fc51 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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::>(); + 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, +) -> 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")?; diff --git a/codex-rs/app-server/src/dynamic_tools.rs b/codex-rs/app-server/src/dynamic_tools.rs new file mode 100644 index 0000000000..a1b424d0ee --- /dev/null +++ b/codex-rs/app-server/src/dynamic_tools.rs @@ -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, + conversation: Arc, +) { + 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::(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}"); + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index fde208106d..c6d943ae46 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -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; diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index b0798b11ba..4189435c05 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -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, } } diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs new file mode 100644 index 0000000000..dc985ac49f --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -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::(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::(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::(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::(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> { + 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::() + .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 { + 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 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 95ec61f20a..4d9e105147 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -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; diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index c98da19345..a496e4a590 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -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, }, ]; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index d375a2db2f..3abe73e2e9 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -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?; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 9fc2bae165..8a66fca0e1 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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, @@ -244,16 +247,12 @@ impl Codex { conversation_history: InitialHistory, session_source: SessionSource, agent_control: AgentControl, + dynamic_tools: Vec, ) -> CodexResult { 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, pub(crate) tool_call_gate: Arc, pub(crate) truncation_policy: TruncationPolicy, + pub(crate) dynamic_tools: Vec, } impl TurnContext { @@ -500,6 +501,7 @@ pub(crate) struct SessionConfiguration { original_config_do_not_use: Arc, /// Source of the session (cli, vscode, exec, mcp, ...) session_source: SessionSource, + dynamic_tools: Vec, } 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, config: Arc, 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, + id: String, + response: DynamicToolResponse, + ) { + sess.notify_dynamic_tool_response(&id, response).await; + } + pub async fn add_to_history(sess: &Arc, config: &Arc, 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, diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 9a94f988d5..5e1de2ef1f 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -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); diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 9c12272d94..b99fde92bf 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -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, effort: Option, }, + /// Update the active (or default) model personality. + SetModelPersonality { personality: Option }, /// 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) -> 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)); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 2042e330ef..7e1d80ace5 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -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; diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 8fb77e29e4..c07c144c11 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -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!( diff --git a/codex-rs/core/src/models_manager/model_presets.rs b/codex-rs/core/src/models_manager/model_presets.rs index e60b229ec7..a8f4931f8d 100644 --- a/codex-rs/core/src/models_manager/model_presets.rs +++ b/codex-rs/core/src/models_manager/model_presets.rs @@ -36,6 +36,7 @@ static PRESETS: Lazy> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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> = 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, diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs index 2d4a79cc22..2d263c0539 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/core/src/rollout/policy.rs @@ -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(_) diff --git a/codex-rs/core/src/state/turn.rs b/codex-rs/core/src/state/turn.rs index 66e2f694e9..ccc50d066b 100644 --- a/codex-rs/core/src/state/turn.rs +++ b/codex-rs/core/src/state/turn.rs @@ -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>, pending_user_input: HashMap>, + pending_dynamic_tools: HashMap>, pending_input: Vec, } @@ -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, + ) -> Option> { + self.pending_dynamic_tools.insert(key, tx) + } + + pub(crate) fn remove_pending_dynamic_tool( + &mut self, + key: &str, + ) -> Option> { + self.pending_dynamic_tools.remove(key) + } + pub(crate) fn push_pending_input(&mut self, input: ResponseInputItem) { self.pending_input.push(input); } diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index bc5cd4f19e..9b633ded37 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -196,12 +196,21 @@ impl ThreadManager { } pub async fn start_thread(&self, config: Config) -> CodexResult { + self.start_thread_with_tools(config, Vec::new()).await + } + + pub async fn start_thread_with_tools( + &self, + config: Config, + dynamic_tools: Vec, + ) -> CodexResult { 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, ) -> CodexResult { 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, agent_control: AgentControl, + dynamic_tools: Vec, ) -> CodexResult { 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, agent_control: AgentControl, session_source: SessionSource, + dynamic_tools: Vec, ) -> CodexResult { 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 diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs new file mode 100644 index 0000000000..a68c70b98d --- /dev/null +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -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 { + 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 { + 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() +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 8b63c9567f..a00c6eba51 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -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; diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index 2bc19ddd03..b1f41923bc 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -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>, + 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 } diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index e330d93a03..c3ff4354a8 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -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, @@ -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), } @@ -1101,6 +1102,26 @@ pub(crate) fn mcp_tool_to_openai_tool( }) } +fn dynamic_tool_to_openai_tool( + tool: &DynamicToolSpec, +) -> Result { + 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 { + let mut input_schema = input_schema.clone(); + sanitize_json_schema(&mut input_schema); + serde_json::from_value::(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>, + 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::>(); 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(); diff --git a/codex-rs/core/tests/suite/list_models.rs b/codex-rs/core/tests/suite/list_models.rs index 1791d28b01..f6db54af7b 100644 --- a/codex-rs/core/tests/suite/list_models.rs +++ b/codex-rs/core/tests/suite/list_models.rs @@ -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", diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index c17994cbca..410ca08e4e 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -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); }); diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index 1aa5aa12ae..2c8e124c1f 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -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) diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index c4a5c27d9c..76c22caa75 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -607,7 +607,8 @@ impl EventProcessor for EventProcessorWithHumanOutput { | EventMsg::UndoCompleted(_) | EventMsg::UndoStarted(_) | EventMsg::ThreadRolledBack(_) - | EventMsg::RequestUserInput(_) => {} + | EventMsg::RequestUserInput(_) + | EventMsg::DynamicToolCallRequest(_) => {} } CodexStatus::Running } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index b400b4cc79..dcaf8a89e6 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -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(_) diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index ae956797ea..ba6b12aadf 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -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")] diff --git a/codex-rs/protocol/src/dynamic_tools.rs b/codex-rs/protocol/src/dynamic_tools.rs new file mode 100644 index 0000000000..e55d372d8e --- /dev/null +++ b/codex-rs/protocol/src/dynamic_tools.rs @@ -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, +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index 69d09da089..60b01bbd73 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -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; diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index ac030a303b..3f29484ffd 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -78,6 +78,9 @@ pub struct ModelPreset { pub default_reasoning_effort: ReasoningEffort, /// Supported reasoning effort options. pub supported_reasoning_efforts: Vec, + /// 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) -> 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 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 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(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 7bf3c90eab..e9cb8147d7 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -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), diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 1d53f5d70d..d3185adf34 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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) -> &'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, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9a2d979f1c..f1ec28f3f1 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, }, + /// Persist the selected personality to the appropriate config. + PersistPersonalitySelection { + personality: Personality, + }, + /// Open the reasoning selection popup after picking a model. OpenReasoningPopup { model: ModelPreset, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d1f8d691a2..c4fba97f65 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -235,6 +235,7 @@ pub(crate) struct ChatComposer { steer_enabled: bool, collaboration_modes_enabled: bool, collaboration_mode_indicator: Option, + 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 { 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) { diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index f2c178dfa2..d0805707fa 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -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 = 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:?}"), + } + } } diff --git a/codex-rs/tui/src/bottom_pane/file_search_popup.rs b/codex-rs/tui/src/bottom_pane/file_search_popup.rs index 48de1cff54..e0a0cc2f49 100644 --- a/codex-rs/tui/src/bottom_pane/file_search_popup.rs +++ b/codex-rs/tui/src/bottom_pane/file_search_popup.rs @@ -132,6 +132,7 @@ impl WidgetRef for &FileSearchPopup { display_shortcut: None, description: None, wrap_indent: None, + is_disabled: false, disabled_reason: None, }) .collect() diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 40787a9c25..7591b99326 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -42,6 +42,7 @@ pub(crate) struct SelectionItem { pub selected_description: Option, pub is_current: bool, pub is_default: bool, + pub is_disabled: bool, pub actions: Vec, pub dismiss_on_select: bool, pub search_value: Option, @@ -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(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index e772a60215..a570bfb384 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -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() } diff --git a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs index c1675b01aa..c9ca076d60 100644 --- a/codex-rs/tui/src/bottom_pane/selection_popup_common.rs +++ b/codex-rs/tui/src/bottom_pane/selection_popup_common.rs @@ -23,7 +23,8 @@ pub(crate) struct GenericDisplayRow { pub match_indices: Option>, // indices to bold (char positions) pub description: Option, // optional grey text after the name pub disabled_reason: Option, // optional disabled message - pub wrap_indent: Option, // optional indent for wrapped lines + pub is_disabled: bool, + pub wrap_indent: Option, // optional indent for wrapped lines } pub(crate) fn wrap_styled_line<'a>(line: &'a Line<'a>, width: u16) -> Vec> { @@ -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( diff --git a/codex-rs/tui/src/bottom_pane/skill_popup.rs b/codex-rs/tui/src/bottom_pane/skill_popup.rs index 44afb1ff0b..4736953208 100644 --- a/codex-rs/tui/src/bottom_pane/skill_popup.rs +++ b/codex-rs/tui/src/bottom_pane/skill_popup.rs @@ -96,6 +96,7 @@ impl SkillPopup { match_indices: indices, display_shortcut: None, description: Some(description), + is_disabled: false, disabled_reason: None, wrap_indent: None, } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ce87b33eed..aa420a3ea9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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 = 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 = 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 { 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() { diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap new file mode 100644 index 0000000000..6df7d5ad6b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e76abdbd7a..fab175edcf 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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, diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 2562d5e689..28d27d2301 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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 diff --git a/codex-rs/tui/src/tooltips.rs b/codex-rs/tui/src/tooltips.rs index 402a18ba2b..8acfcce892 100644 --- a/codex-rs/tui/src/tooltips.rs +++ b/codex-rs/tui/src/tooltips.rs @@ -104,7 +104,12 @@ pub(crate) mod announcement { } fn blocking_init_announcement_tip() -> Option { - 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()