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