diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index d4cc733f15..cf6521fba6 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -334,6 +334,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } RealtimeEvent::CloseRequested(_) => {} + RealtimeEvent::ToolActionRequested(request) => { + let notification = ThreadRealtimeItemAddedNotification { + thread_id: conversation_id.to_string(), + item: serde_json::json!({ + "type": "tool_action_request", + "call_id": request.call_id, + "action": request.action, + }), + }; + outgoing + .send_server_notification(ServerNotification::ThreadRealtimeItemAdded( + notification, + )) + .await; + } RealtimeEvent::Error(message) => { let notification = ThreadRealtimeErrorNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index de4d3a2d40..7ea8e01bed 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -369,7 +369,7 @@ impl RealtimeWebsocketWriter { pub async fn send_session_update(&self, instructions: String) -> Result<(), ApiError> { self.send_json(RealtimeOutboundMessage::SessionUpdate { - session: SessionUpdateSession { + session: Box::new(SessionUpdateSession { kind: "realtime".to_string(), instructions, output_modalities: vec!["audio".to_string()], @@ -396,54 +396,161 @@ impl RealtimeWebsocketWriter { voice: "marin".to_string(), }, }, - tools: vec![SessionTool { - kind: "function".to_string(), - name: "codex".to_string(), - description: - "Delegate a request to Codex and return the final result to the user." - .to_string(), - parameters: SessionToolParameters { - kind: "object".to_string(), - properties: BTreeMap::from([ - ( - "prompt".to_string(), - SessionToolProperty { - kind: "string".to_string(), - description: "The user request to delegate to Codex." - .to_string(), - }, - ), - ( - "send_immediately".to_string(), - SessionToolProperty { - kind: "boolean".to_string(), - description: "When true, send this to Codex immediately, steering any running turn. When false or omitted, queue it for the next turn.".to_string(), - }, - ), - ]), - required: vec!["prompt".to_string()], + tools: vec![ + SessionTool { + kind: "function".to_string(), + name: "codex".to_string(), + description: + "Delegate a request to Codex and return the final result to the user." + .to_string(), + parameters: SessionToolParameters { + kind: "object".to_string(), + properties: BTreeMap::from([ + ( + "prompt".to_string(), + SessionToolProperty { + kind: "string".to_string(), + description: "The user request to delegate to Codex." + .to_string(), + }, + ), + ( + "send_immediately".to_string(), + SessionToolProperty { + kind: "boolean".to_string(), + description: "When true, send this to Codex immediately, steering any running turn. When false or omitted, queue it for the next turn.".to_string(), + }, + ), + ]), + required: vec!["prompt".to_string()], + }, }, - }, SessionTool { - kind: "function".to_string(), - name: "cancel_current_operation".to_string(), - description: "Cancel the current Codex operation, equivalent to pressing Ctrl-C without exiting Codex.".to_string(), - parameters: SessionToolParameters { - kind: "object".to_string(), - properties: BTreeMap::new(), - required: Vec::new(), + SessionTool { + kind: "function".to_string(), + name: "cancel_current_operation".to_string(), + description: "Cancel the current Codex operation, equivalent to pressing Ctrl-C without exiting Codex.".to_string(), + parameters: SessionToolParameters { + kind: "object".to_string(), + properties: BTreeMap::new(), + required: Vec::new(), + }, }, - }, SessionTool { - kind: "function".to_string(), - name: "turn_off_realtime_mode".to_string(), - description: "Turn off realtime voice mode, equivalent to using /realtime to stop live voice.".to_string(), - parameters: SessionToolParameters { - kind: "object".to_string(), - properties: BTreeMap::new(), - required: Vec::new(), + SessionTool { + kind: "function".to_string(), + name: "turn_off_realtime_mode".to_string(), + description: "Turn off realtime voice mode, equivalent to using /realtime to stop live voice.".to_string(), + parameters: SessionToolParameters { + kind: "object".to_string(), + properties: BTreeMap::new(), + required: Vec::new(), + }, }, - }], + SessionTool { + kind: "function".to_string(), + name: "manage_message_queue".to_string(), + description: "Inspect or edit queued draft messages. Supported actions: list, replace_last, remove_last, clear. Queue editing affects queued draft messages only, not pending steers.".to_string(), + parameters: SessionToolParameters { + kind: "object".to_string(), + properties: BTreeMap::from([ + ( + "action".to_string(), + SessionToolProperty { + kind: "string".to_string(), + description: "Queue action to run. Use one of: list, replace_last, remove_last, clear.".to_string(), + }, + ), + ( + "message".to_string(), + SessionToolProperty { + kind: "string".to_string(), + description: "Replacement text for action=replace_last.".to_string(), + }, + ), + ]), + required: vec!["action".to_string()], + }, + }, + SessionTool { + kind: "function".to_string(), + name: "manage_runtime_settings".to_string(), + description: "Inspect or update runtime settings for future Codex turns. Call with no setting fields to list current settings, possible settings, and allowed values. Supported setting keys: model, working_directory, reasoning_effort, fast_mode, personality, collaboration_mode. Changes are not persisted to disk.".to_string(), + parameters: SessionToolParameters { + kind: "object".to_string(), + properties: BTreeMap::from([ + ( + "model".to_string(), + SessionToolProperty { + kind: "string".to_string(), + description: "Optional model slug to use for future Codex turns.".to_string(), + }, + ), + ( + "working_directory".to_string(), + SessionToolProperty { + kind: "string".to_string(), + description: "Optional working directory for future Codex turns. Relative paths are resolved against the current working directory.".to_string(), + }, + ), + ( + "reasoning_effort".to_string(), + SessionToolProperty { + kind: "string".to_string(), + description: "Optional reasoning effort. Supported values: default, minimal, low, medium, high, xhigh.".to_string(), + }, + ), + ( + "fast_mode".to_string(), + SessionToolProperty { + kind: "boolean".to_string(), + description: "Optional Fast mode toggle for future Codex turns.".to_string(), + }, + ), + ( + "personality".to_string(), + SessionToolProperty { + kind: "string".to_string(), + description: "Optional personality. Supported values: none, friendly, pragmatic.".to_string(), + }, + ), + ( + "collaboration_mode".to_string(), + SessionToolProperty { + kind: "string".to_string(), + description: "Optional collaboration mode. Supported values: default, plan.".to_string(), + }, + ), + ]), + required: Vec::new(), + }, + }, + SessionTool { + kind: "function".to_string(), + name: "run_tui_command".to_string(), + description: "Run a small set of built-in TUI actions. Supported commands: compact, review, plan, diff, agent. review can take an optional prompt for custom review instructions. plan can take an optional prompt to switch to Plan mode and submit work immediately.".to_string(), + parameters: SessionToolParameters { + kind: "object".to_string(), + properties: BTreeMap::from([ + ( + "command".to_string(), + SessionToolProperty { + kind: "string".to_string(), + description: "Built-in TUI command to run. Use one of: compact, review, plan, diff, agent.".to_string(), + }, + ), + ( + "prompt".to_string(), + SessionToolProperty { + kind: "string".to_string(), + description: "Optional text argument for command=review or command=plan.".to_string(), + }, + ), + ]), + required: vec!["command".to_string()], + }, + }, + ], tool_choice: "auto".to_string(), - }, + }), }) .await } @@ -552,6 +659,7 @@ impl RealtimeWebsocketEvents { | RealtimeEvent::ResponseCancelled(_) | RealtimeEvent::ConversationItemAdded(_) | RealtimeEvent::ConversationItemDone { .. } + | RealtimeEvent::ToolActionRequested(_) | RealtimeEvent::Error(_) => {} } } @@ -748,6 +856,8 @@ mod tests { use crate::endpoint::realtime_websocket::protocol::RealtimeInterruptRequested; use crate::endpoint::realtime_websocket::protocol::RealtimeOutputAudioDelta; use crate::endpoint::realtime_websocket::protocol::RealtimeResponseCancelled; + use crate::endpoint::realtime_websocket::protocol::RealtimeToolAction; + use crate::endpoint::realtime_websocket::protocol::RealtimeToolActionRequested; use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptDelta; use crate::endpoint::realtime_websocket::protocol::RealtimeTranscriptEntry; use http::HeaderValue; @@ -989,6 +1099,106 @@ mod tests { ); } + #[test] + fn parse_manage_message_queue_requested_event() { + let payload = json!({ + "type": "response.done", + "response": { + "output": [ + { + "id": "item_queue", + "type": "function_call", + "name": "manage_message_queue", + "call_id": "queue_123", + "arguments": "{\"action\":\"list\"}" + } + ] + } + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str()), + Some(RealtimeEvent::ToolActionRequested( + RealtimeToolActionRequested { + call_id: "queue_123".to_string(), + action: RealtimeToolAction::ManageMessageQueue { + action: "list".to_string(), + message: None, + }, + } + )) + ); + } + + #[test] + fn parse_manage_runtime_settings_requested_event() { + let payload = json!({ + "type": "response.done", + "response": { + "output": [ + { + "id": "item_settings", + "type": "function_call", + "name": "manage_runtime_settings", + "call_id": "settings_123", + "arguments": "{\"model\":\"gpt-5\",\"working_directory\":\"src\",\"reasoning_effort\":\"low\"}" + } + ] + } + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str()), + Some(RealtimeEvent::ToolActionRequested( + RealtimeToolActionRequested { + call_id: "settings_123".to_string(), + action: RealtimeToolAction::ManageRuntimeSettings { + model: Some("gpt-5".to_string()), + working_directory: Some("src".to_string()), + reasoning_effort: Some("low".to_string()), + fast_mode: None, + personality: None, + collaboration_mode: None, + }, + } + )) + ); + } + + #[test] + fn parse_run_tui_command_requested_event() { + let payload = json!({ + "type": "response.done", + "response": { + "output": [ + { + "id": "item_command", + "type": "function_call", + "name": "run_tui_command", + "call_id": "command_123", + "arguments": "{\"command\":\"plan\",\"prompt\":\"make a plan\"}" + } + ] + } + }) + .to_string(); + + assert_eq!( + parse_realtime_event(payload.as_str()), + Some(RealtimeEvent::ToolActionRequested( + RealtimeToolActionRequested { + call_id: "command_123".to_string(), + action: RealtimeToolAction::RunTuiCommand { + command: "plan".to_string(), + prompt: Some("make a plan".to_string()), + }, + } + )) + ); + } + #[test] fn parse_input_audio_speech_started_event() { let payload = json!({ @@ -1295,6 +1505,27 @@ mod tests { first_json["session"]["tools"][2]["parameters"]["properties"], json!({}) ); + assert_eq!( + first_json["session"]["tools"] + .as_array() + .expect("tools array") + .iter() + .map(|tool| tool["name"].as_str().expect("tool name")) + .collect::>(), + vec![ + "codex", + "cancel_current_operation", + "turn_off_realtime_mode", + "manage_message_queue", + "manage_runtime_settings", + "run_tui_command", + ] + ); + assert_eq!( + first_json["session"]["tools"][4]["parameters"]["properties"]["working_directory"] + ["type"], + Value::String("string".to_string()) + ); ws.send(Message::Text( json!({ diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs index 949cedd04c..a48f23df43 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/protocol.rs @@ -6,6 +6,8 @@ pub use codex_protocol::protocol::RealtimeInputAudioSpeechStarted; pub use codex_protocol::protocol::RealtimeInterruptRequested; pub use codex_protocol::protocol::RealtimeOutputAudioDelta; pub use codex_protocol::protocol::RealtimeResponseCancelled; +pub use codex_protocol::protocol::RealtimeToolAction; +pub use codex_protocol::protocol::RealtimeToolActionRequested; pub use codex_protocol::protocol::RealtimeTranscriptDelta; pub use codex_protocol::protocol::RealtimeTranscriptEntry; use serde::Deserialize; @@ -36,7 +38,7 @@ pub(super) enum RealtimeOutboundMessage { audio_end_ms: u32, }, #[serde(rename = "session.update")] - SessionUpdate { session: SessionUpdateSession }, + SessionUpdate { session: Box }, #[serde(rename = "conversation.item.create")] ConversationItemCreate { item: ConversationItem }, } @@ -273,6 +275,9 @@ pub(super) fn parse_realtime_event(payload: &str) -> Option { if let Some(close) = parse_close_requested(&parsed) { return Some(RealtimeEvent::CloseRequested(close)); } + if let Some(tool_action) = parse_tool_action_requested(&parsed) { + return Some(RealtimeEvent::ToolActionRequested(tool_action)); + } if let Some(cancelled) = parse_response_cancelled(&parsed) { return Some(RealtimeEvent::ResponseCancelled(cancelled)); } @@ -357,6 +362,179 @@ fn parse_close_requested(parsed: &Value) -> Option { }) } +fn parse_tool_action_requested(parsed: &Value) -> Option { + #[derive(Debug, Deserialize, Default)] + struct ReplaceLastQueuedMessageArguments { + #[serde(default)] + message: String, + } + + #[derive(Debug, Deserialize, Default)] + struct ManageMessageQueueArguments { + #[serde(default)] + action: String, + #[serde(default)] + message: Option, + } + + #[derive(Debug, Deserialize, Default)] + struct UpdateRuntimeSettingsArguments { + #[serde(default)] + model: Option, + #[serde(default)] + working_directory: Option, + #[serde(default)] + reasoning_effort: Option, + #[serde(default)] + fast_mode: Option, + #[serde(default)] + personality: Option, + #[serde(default)] + collaboration_mode: Option, + } + + #[derive(Debug, Deserialize, Default)] + struct RunTuiCommandArguments { + #[serde(default)] + command: String, + #[serde(default)] + prompt: Option, + } + + let parse_call_id = |function_call: &Value| { + function_call + .get("call_id") + .and_then(Value::as_str) + .map(str::to_string) + }; + + if let Some(function_call) = find_function_call(parsed, "manage_message_queue") { + let arguments = function_call + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + let parsed_arguments = + serde_json::from_str::(arguments).unwrap_or_default(); + return Some(RealtimeToolActionRequested { + call_id: parse_call_id(function_call)?, + action: RealtimeToolAction::ManageMessageQueue { + action: parsed_arguments.action, + message: parsed_arguments.message, + }, + }); + } + + if let Some(function_call) = find_function_call(parsed, "list_message_queue") { + return Some(RealtimeToolActionRequested { + call_id: parse_call_id(function_call)?, + action: RealtimeToolAction::ListMessageQueue, + }); + } + + if let Some(function_call) = find_function_call(parsed, "replace_last_queued_message") { + let arguments = function_call + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + let parsed_arguments = serde_json::from_str::(arguments) + .unwrap_or_else(|_| ReplaceLastQueuedMessageArguments { + message: arguments.to_string(), + }); + return Some(RealtimeToolActionRequested { + call_id: parse_call_id(function_call)?, + action: RealtimeToolAction::ReplaceLastQueuedMessage { + message: parsed_arguments.message, + }, + }); + } + + if let Some(function_call) = find_function_call(parsed, "remove_last_queued_message") { + return Some(RealtimeToolActionRequested { + call_id: parse_call_id(function_call)?, + action: RealtimeToolAction::RemoveLastQueuedMessage, + }); + } + + if let Some(function_call) = find_function_call(parsed, "clear_queued_messages") { + return Some(RealtimeToolActionRequested { + call_id: parse_call_id(function_call)?, + action: RealtimeToolAction::ClearQueuedMessages, + }); + } + + if let Some(function_call) = find_function_call(parsed, "list_runtime_settings") { + return Some(RealtimeToolActionRequested { + call_id: parse_call_id(function_call)?, + action: RealtimeToolAction::ListRuntimeSettings, + }); + } + + if let Some(function_call) = find_function_call(parsed, "manage_runtime_settings") { + let arguments = function_call + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + let parsed_arguments = + serde_json::from_str::(arguments).unwrap_or_default(); + return Some(RealtimeToolActionRequested { + call_id: parse_call_id(function_call)?, + action: RealtimeToolAction::ManageRuntimeSettings { + model: parsed_arguments.model, + working_directory: parsed_arguments.working_directory, + reasoning_effort: parsed_arguments.reasoning_effort, + fast_mode: parsed_arguments.fast_mode, + personality: parsed_arguments.personality, + collaboration_mode: parsed_arguments.collaboration_mode, + }, + }); + } + + if let Some(function_call) = find_function_call(parsed, "update_runtime_settings") { + let arguments = function_call + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + let parsed_arguments = + serde_json::from_str::(arguments).unwrap_or_default(); + return Some(RealtimeToolActionRequested { + call_id: parse_call_id(function_call)?, + action: RealtimeToolAction::UpdateRuntimeSettings { + model: parsed_arguments.model, + working_directory: parsed_arguments.working_directory, + reasoning_effort: parsed_arguments.reasoning_effort, + fast_mode: parsed_arguments.fast_mode, + personality: parsed_arguments.personality, + collaboration_mode: parsed_arguments.collaboration_mode, + }, + }); + } + + if let Some(function_call) = find_function_call(parsed, "run_tui_command") { + let arguments = function_call + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + let parsed_arguments = + serde_json::from_str::(arguments).unwrap_or_default(); + return Some(RealtimeToolActionRequested { + call_id: parse_call_id(function_call)?, + action: RealtimeToolAction::RunTuiCommand { + command: parsed_arguments.command, + prompt: parsed_arguments.prompt, + }, + }); + } + + if let Some(function_call) = find_function_call(parsed, "compact_conversation") { + return Some(RealtimeToolActionRequested { + call_id: parse_call_id(function_call)?, + action: RealtimeToolAction::CompactConversation, + }); + } + + None +} + fn parse_response_cancelled(parsed: &Value) -> Option { let response = parsed.get("response")?.as_object()?; let status = response.get("status").and_then(Value::as_str)?; diff --git a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs index 85f647e920..0431ff81b1 100644 --- a/codex-rs/codex-api/tests/realtime_websocket_e2e.rs +++ b/codex-rs/codex-api/tests/realtime_websocket_e2e.rs @@ -161,6 +161,27 @@ async fn realtime_ws_e2e_session_create_and_event_flow() { first_json["session"]["tools"][1]["parameters"]["properties"], json!({}) ); + assert_eq!( + first_json["session"]["tools"] + .as_array() + .expect("tools array") + .iter() + .map(|tool| tool["name"].as_str().expect("tool name")) + .collect::>(), + vec![ + "codex", + "cancel_current_operation", + "turn_off_realtime_mode", + "manage_message_queue", + "manage_runtime_settings", + "run_tui_command", + ] + ); + assert_eq!( + first_json["session"]["tools"][4]["parameters"]["properties"]["working_directory"] + ["type"], + Value::String("string".to_string()) + ); ws.send(Message::Text( json!({ diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 388e046c7c..40b2109713 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2547,6 +2547,19 @@ impl Session { { debug!("failed to send final realtime handoff tool output: {err}"); } + if let EventMsg::TurnAborted(event) = msg + && event.reason == TurnAbortReason::Interrupted + && let Some(call_id) = self.conversation.active_handoff_id().await + && let Err(err) = self + .conversation + .tool_call_complete( + call_id, + "Cancelled the current Codex operation.".to_string(), + ) + .await + { + debug!("failed to send interrupted realtime handoff tool output: {err}"); + } if matches!(msg, EventMsg::TurnComplete(_) | EventMsg::TurnAborted(_)) { self.conversation.clear_active_handoff().await; } @@ -4072,6 +4085,23 @@ async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiv handle_realtime_conversation_close(&sess, sub.id.clone()).await; false } + Op::RealtimeConversationToolCallComplete(params) => { + if let Err(err) = sess + .conversation + .tool_call_complete(params.call_id, params.output_text) + .await + { + sess.send_event_raw(Event { + id: sub.id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: err.to_string(), + codex_error_info: Some(CodexErrorInfo::Other), + }), + }) + .await; + } + false + } Op::OverrideTurnContext { cwd, approval_policy, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index b59fc6e275..4409db19fe 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::TurnAbortReason; use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::start_mock_server; @@ -1321,6 +1322,134 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_interrupt_resolves_active_handoff() -> Result<()> { + skip_if_no_network!(Ok(())); + + let (_gate_completion_tx, gate_completion_rx) = oneshot::channel(); + let first_chunks = vec![ + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_response_created("resp-1")), + }, + StreamingSseChunk { + gate: None, + body: sse_event(responses::ev_assistant_message( + "msg-1", + "assistant message 1", + )), + }, + StreamingSseChunk { + gate: Some(gate_completion_rx), + body: sse_event(responses::ev_completed("resp-1")), + }, + ]; + let (api_server, _completions) = start_streaming_sse_server(vec![first_chunks]).await; + + let realtime_server = start_websocket_server(vec![vec![ + vec![ + json!({ + "type": "session.updated", + "session": { "id": "sess_interrupt", "instructions": "backend prompt" } + }), + realtime_handoff_requested_event("handoff_interrupt", "item_interrupt", "delegate now"), + ], + vec![realtime_interrupt_requested_event("cancel_1")], + vec![], + vec![], + vec![], + vec![], + ]]) + .await; + + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + } + }); + let test = builder.build_with_streaming_server(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + prompt: "backend prompt".to_string(), + session_id: None, + })) + .await?; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::SessionUpdated { session_id, .. }, + }) if session_id == "sess_interrupt" => Some(()), + _ => None, + }) + .await; + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::HandoffRequested(handoff), + }) if handoff.handoff_id == "handoff_interrupt" => Some(()), + _ => None, + }) + .await; + + let first_append = realtime_server.wait_for_request(0, 1).await; + assert_eq!( + first_append.body_json()["type"].as_str(), + Some("conversation.item.create") + ); + assert_eq!( + first_append.body_json()["item"]["type"].as_str(), + Some("message") + ); + assert_eq!( + first_append.body_json()["item"]["role"].as_str(), + Some("assistant") + ); + assert_eq!( + first_append.body_json()["item"]["content"][0]["text"].as_str(), + Some("assistant message 1") + ); + + let _ = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::TurnAborted(event) if event.reason == TurnAbortReason::Interrupted => Some(()), + _ => None, + }) + .await; + + let cancel_output_request = wait_for_matching_websocket_request( + &realtime_server, + "cancel_current_operation tool output", + |request| { + request.body_json()["item"]["type"].as_str() == Some("function_call_output") + && request.body_json()["item"]["call_id"].as_str() == Some("cancel_1") + }, + ) + .await; + assert_eq!( + cancel_output_request.body_json()["item"]["output"].as_str(), + Some("{\"content\":\"Cancelled the current Codex operation.\"}") + ); + + let interrupted_handoff_request = wait_for_matching_websocket_request( + &realtime_server, + "interrupted realtime handoff tool output", + |request| { + request.body_json()["item"]["type"].as_str() == Some("function_call_output") + && request.body_json()["item"]["call_id"].as_str() == Some("handoff_interrupt") + }, + ) + .await; + assert_eq!( + interrupted_handoff_request.body_json()["item"]["output"].as_str(), + Some("{\"content\":\"Cancelled the current Codex operation.\"}") + ); + + realtime_server.shutdown().await; + api_server.shutdown().await; + Ok(()) +} + fn sse_event(event: Value) -> String { responses::sse(vec![event]) } @@ -1342,6 +1471,23 @@ fn realtime_handoff_requested_event(handoff_id: &str, item_id: &str, prompt: &st }) } +fn realtime_interrupt_requested_event(call_id: &str) -> Value { + json!({ + "type": "response.done", + "response": { + "output": [ + { + "id": "item_cancel", + "type": "function_call", + "name": "cancel_current_operation", + "call_id": call_id, + "arguments": "{}", + } + ] + } + }) +} + fn message_input_texts(body: &Value, role: &str) -> Vec { body.get("input") .and_then(Value::as_array) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5eeebb9d75..cdfa8324c5 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -169,6 +169,63 @@ pub struct RealtimeCloseRequested { pub call_id: String, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum RealtimeToolAction { + ManageMessageQueue { + action: String, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, + ListMessageQueue, + ReplaceLastQueuedMessage { + message: String, + }, + RemoveLastQueuedMessage, + ClearQueuedMessages, + ManageRuntimeSettings { + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + working_directory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + fast_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + personality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + collaboration_mode: Option, + }, + ListRuntimeSettings, + UpdateRuntimeSettings { + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + working_directory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + fast_mode: Option, + #[serde(skip_serializing_if = "Option::is_none")] + personality: Option, + #[serde(skip_serializing_if = "Option::is_none")] + collaboration_mode: Option, + }, + RunTuiCommand { + command: String, + #[serde(skip_serializing_if = "Option::is_none")] + prompt: Option, + }, + CompactConversation, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RealtimeToolActionRequested { + pub call_id: String, + pub action: RealtimeToolAction, +} + #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] pub struct RealtimeInputAudioSpeechStarted { #[serde(skip_serializing_if = "Option::is_none")] @@ -199,6 +256,7 @@ pub enum RealtimeEvent { HandoffRequested(RealtimeHandoffRequested), InterruptRequested(RealtimeInterruptRequested), CloseRequested(RealtimeCloseRequested), + ToolActionRequested(RealtimeToolActionRequested), Error(String), } @@ -219,6 +277,12 @@ pub struct ConversationTextParams { pub text: String, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct RealtimeToolCallCompleteParams { + pub call_id: String, + pub output_text: String, +} + /// Submission operation #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] @@ -247,6 +311,9 @@ pub enum Op { /// Close the running realtime conversation stream. RealtimeConversationClose, + /// Send a tool-call result back to the running realtime conversation stream. + RealtimeConversationToolCallComplete(RealtimeToolCallCompleteParams), + /// Legacy user input. /// /// Prefer [`Op::UserTurn`] so the caller provides full turn context diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index adb723d1df..821e85247b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2417,6 +2417,11 @@ impl App { AppEvent::UpdatePersonality(personality) => { self.on_update_personality(personality); } + AppEvent::UpdateWorkingDirectory(cwd) => { + self.config.cwd = cwd.clone(); + self.file_search.update_search_dir(cwd); + self.refresh_status_line(); + } AppEvent::OpenRealtimeAudioDeviceSelection { kind } => { self.chat_widget.open_realtime_audio_device_selection(kind); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9f9a8d6de1..ee57ceac0e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -187,6 +187,9 @@ pub(crate) enum AppEvent { /// Update the current personality in the running app and widget. UpdatePersonality(Personality), + /// Update the app-level working directory state after the chat widget already applied it. + UpdateWorkingDirectory(PathBuf), + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 775c161845..5d1a7011b1 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1641,7 +1641,9 @@ impl ChatWidget { self.saw_plan_item_this_turn = false; } // If there is a queued user message, send exactly one now to begin the next turn. - self.maybe_send_next_queued_input(); + if !self.maybe_close_realtime_conversation_when_idle() { + self.maybe_send_next_queued_input(); + } // Emit a notification when the turn completes (suppressed if focused). self.notify(Notification::AgentTurnComplete { response: last_agent_message.unwrap_or_default(), @@ -2027,7 +2029,9 @@ impl ChatWidget { self.mcp_startup_status = None; self.update_task_running_state(); - self.maybe_send_next_queued_input(); + if !self.maybe_close_realtime_conversation_when_idle() { + self.maybe_send_next_queued_input(); + } self.request_redraw(); } @@ -2072,6 +2076,7 @@ impl ChatWidget { self.restore_user_message_to_composer(combined); } self.refresh_pending_input_preview(); + self.maybe_close_realtime_conversation_when_idle(); self.request_redraw(); } @@ -7335,10 +7340,9 @@ impl ChatWidget { self.bottom_pane .set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled()); if !realtime_conversation_enabled && self.realtime_conversation.is_live() { - self.request_realtime_conversation_close(Some( + self.force_close_realtime_conversation(Some( "Realtime voice mode was closed because the feature was disabled.".to_string(), )); - self.reset_realtime_conversation_state(); } } if feature == Feature::FastMode { @@ -7468,6 +7472,14 @@ impl ChatWidget { } } + pub(crate) fn set_working_directory(&mut self, cwd: PathBuf) { + self.current_cwd = Some(cwd.clone()); + self.config.cwd = cwd; + self.request_status_line_branch_refresh(); + self.refresh_status_line(); + self.request_redraw(); + } + /// Set the syntax theme override in the widget's config copy. pub(crate) fn set_tui_theme(&mut self, theme: Option) { self.config.tui_theme = theme; diff --git a/codex-rs/tui/src/chatwidget/realtime.rs b/codex-rs/tui/src/chatwidget/realtime.rs index f5c8946db8..b79ac2782d 100644 --- a/codex-rs/tui/src/chatwidget/realtime.rs +++ b/codex-rs/tui/src/chatwidget/realtime.rs @@ -8,6 +8,10 @@ use codex_protocol::protocol::RealtimeConversationStartedEvent; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::RealtimeHandoffRequested; use codex_protocol::protocol::RealtimeOutputAudioDelta; +use codex_protocol::protocol::RealtimeToolAction; +use codex_protocol::protocol::RealtimeToolActionRequested; +use codex_protocol::protocol::RealtimeToolCallCompleteParams; +use serde_json::json; const REALTIME_CONVERSATION_PROMPT: &str = concat!( "You are in a realtime voice conversation in the Codex TUI. ", @@ -21,6 +25,10 @@ const REALTIME_CONVERSATION_PROMPT: &str = concat!( "text output that the user can read in the TUI. ", "Use codex whenever the user asks for codebase-specific facts, debugging, file changes, ", "command output, or anything that benefits from tools. ", + "Use manage_message_queue to inspect or edit queued draft work. Supported actions are list, replace_last, remove_last, and clear. ", + "Use manage_runtime_settings to inspect or change runtime settings for future turns, including model, working_directory, reasoning_effort, fast_mode, personality, and collaboration_mode. ", + "If you call manage_runtime_settings without any setting fields, it returns the current settings and the list of possible settings and allowed values. ", + "Use run_tui_command for built-in TUI actions. Supported commands are compact, review, plan, diff, and agent. ", "When you speak to the user directly, be extremely concise. ", "Prefer short spoken answers because the user can see the detailed text output themselves. ", "Do not read long code snippets, long file paths, diffs, or command output aloud unless the ", @@ -40,6 +48,7 @@ pub(super) enum RealtimeConversationPhase { pub(super) struct RealtimeConversationUiState { phase: RealtimeConversationPhase, requested_close: bool, + close_when_idle: bool, session_id: Option, warned_unsupported_composer_submission: bool, meter_placeholder_id: Option, @@ -71,6 +80,18 @@ impl RealtimeConversationUiState { pub(super) fn set_phase_for_test(&mut self, phase: RealtimeConversationPhase) { self.phase = phase; } + + pub(super) fn phase_for_test(&self) -> RealtimeConversationPhase { + self.phase + } + + pub(super) fn requested_close_for_test(&self) -> bool { + self.requested_close + } + + pub(super) fn close_when_idle_for_test(&self) -> bool { + self.close_when_idle + } } #[derive(Clone, Debug, PartialEq)] @@ -280,6 +301,7 @@ impl ChatWidget { pub(super) fn start_realtime_conversation(&mut self) { self.realtime_conversation.phase = RealtimeConversationPhase::Starting; self.realtime_conversation.requested_close = false; + self.realtime_conversation.close_when_idle = false; self.realtime_conversation.session_id = None; self.realtime_conversation .warned_unsupported_composer_submission = false; @@ -301,10 +323,20 @@ impl ChatWidget { self.realtime_conversation.requested_close = true; self.realtime_conversation.phase = RealtimeConversationPhase::Stopping; - self.submit_op(Op::RealtimeConversationClose); self.stop_realtime_local_audio(); self.set_footer_hint_override(None); + if self.bottom_pane.is_task_running() { + self.realtime_conversation.close_when_idle = true; + if !self.submit_op(Op::Interrupt) { + self.realtime_conversation.close_when_idle = false; + self.submit_op(Op::RealtimeConversationClose); + } + } else { + self.realtime_conversation.close_when_idle = false; + self.submit_op(Op::RealtimeConversationClose); + } + if let Some(message) = info_message { self.add_info_message(message, None); } else { @@ -312,11 +344,43 @@ impl ChatWidget { } } + pub(super) fn force_close_realtime_conversation(&mut self, info_message: Option) { + if !self.realtime_conversation.is_live() { + if let Some(message) = info_message { + self.add_info_message(message, None); + } + return; + } + + self.realtime_conversation.requested_close = true; + self.realtime_conversation.close_when_idle = false; + self.stop_realtime_local_audio(); + self.set_footer_hint_override(None); + self.submit_op(Op::RealtimeConversationClose); + self.reset_realtime_conversation_state(); + + if let Some(message) = info_message { + self.add_info_message(message, None); + } else { + self.request_redraw(); + } + } + + pub(super) fn maybe_close_realtime_conversation_when_idle(&mut self) -> bool { + if !self.realtime_conversation.close_when_idle || self.bottom_pane.is_task_running() { + return false; + } + + self.realtime_conversation.close_when_idle = false; + self.submit_op(Op::RealtimeConversationClose) + } + pub(super) fn reset_realtime_conversation_state(&mut self) { self.stop_realtime_local_audio(); self.set_footer_hint_override(None); self.realtime_conversation.phase = RealtimeConversationPhase::Inactive; self.realtime_conversation.requested_close = false; + self.realtime_conversation.close_when_idle = false; self.realtime_conversation.session_id = None; self.realtime_conversation .warned_unsupported_composer_submission = false; @@ -365,9 +429,17 @@ impl ChatWidget { RealtimeEvent::InterruptRequested(_) => {} RealtimeEvent::CloseRequested(_) => { self.request_realtime_conversation_close(Some( - "Realtime voice mode turned off.".to_string(), + if self.bottom_pane.is_task_running() { + "Stopping realtime voice mode after current work is cancelled." + } else { + "Realtime voice mode turned off." + } + .to_string(), )); } + RealtimeEvent::ToolActionRequested(request) => { + self.on_realtime_tool_action_requested(request); + } RealtimeEvent::Error(message) => { self.add_error_message(format!("Realtime voice error: {message}")); self.reset_realtime_conversation_state(); @@ -446,6 +518,832 @@ impl ChatWidget { } } + fn on_realtime_tool_action_requested(&mut self, request: RealtimeToolActionRequested) { + let output_text = match request.action { + RealtimeToolAction::ManageMessageQueue { action, message } => { + self.manage_message_queue_via_realtime(action, message) + } + RealtimeToolAction::ListMessageQueue => self.realtime_message_queue_output(), + RealtimeToolAction::ReplaceLastQueuedMessage { message } => { + self.replace_last_queued_message_via_realtime(message) + } + RealtimeToolAction::RemoveLastQueuedMessage => { + self.remove_last_queued_message_via_realtime() + } + RealtimeToolAction::ClearQueuedMessages => self.clear_queued_messages_via_realtime(), + RealtimeToolAction::ManageRuntimeSettings { + model, + working_directory, + reasoning_effort, + fast_mode, + personality, + collaboration_mode, + } => self.update_runtime_settings_via_realtime( + model, + working_directory, + reasoning_effort, + fast_mode, + personality, + collaboration_mode, + ), + RealtimeToolAction::ListRuntimeSettings => self.realtime_runtime_settings_output(), + RealtimeToolAction::UpdateRuntimeSettings { + model, + working_directory, + reasoning_effort, + fast_mode, + personality, + collaboration_mode, + } => self.update_runtime_settings_via_realtime( + model, + working_directory, + reasoning_effort, + fast_mode, + personality, + collaboration_mode, + ), + RealtimeToolAction::RunTuiCommand { command, prompt } => { + self.run_tui_command_via_realtime(command, prompt) + } + RealtimeToolAction::CompactConversation => self.compact_conversation_via_realtime(), + }; + self.complete_realtime_tool_call(request.call_id, output_text); + } + + fn complete_realtime_tool_call(&self, call_id: String, output_text: String) { + self.app_event_tx + .send(AppEvent::CodexOp(Op::RealtimeConversationToolCallComplete( + RealtimeToolCallCompleteParams { + call_id, + output_text, + }, + ))); + } + + fn realtime_message_queue_output(&self) -> String { + let queued_messages = self + .queued_user_messages + .iter() + .enumerate() + .map(|(index, message)| { + json!({ + "position": index + 1, + "text": message.text, + "local_image_count": message.local_images.len(), + "remote_image_count": message.remote_image_urls.len(), + }) + }) + .collect::>(); + let pending_steers = self + .pending_steers + .iter() + .enumerate() + .map(|(index, pending)| { + json!({ + "position": index + 1, + "text": pending.user_message.text, + "local_image_count": pending.user_message.local_images.len(), + "remote_image_count": pending.user_message.remote_image_urls.len(), + }) + }) + .collect::>(); + + json!({ + "queued_messages": queued_messages, + "pending_steers": pending_steers, + "supported_actions": ["list", "replace_last", "remove_last", "clear"], + "editable_scope": "Queue editing tools affect queued_messages only.", + }) + .to_string() + } + + fn manage_message_queue_via_realtime( + &mut self, + action: String, + message: Option, + ) -> String { + match action.trim().to_ascii_lowercase().as_str() { + "list" => self.realtime_message_queue_output(), + "replace_last" => self.replace_last_queued_message_via_realtime( + message.unwrap_or_default(), + ), + "remove_last" => self.remove_last_queued_message_via_realtime(), + "clear" => self.clear_queued_messages_via_realtime(), + other => json!({ + "status": "error", + "message": format!( + "Unsupported queue action `{other}`. Use list, replace_last, remove_last, or clear." + ), + "supported_actions": ["list", "replace_last", "remove_last", "clear"], + }) + .to_string(), + } + } + + fn replace_last_queued_message_via_realtime(&mut self, message: String) -> String { + let replacement = message.trim().to_string(); + if replacement.is_empty() { + return json!({ + "status": "error", + "message": "The replacement message must not be empty.", + }) + .to_string(); + } + + let Some(last_message) = self.queued_user_messages.back_mut() else { + return json!({ + "status": "error", + "message": "There are no queued draft messages to replace.", + }) + .to_string(); + }; + + if !last_message.local_images.is_empty() || !last_message.remote_image_urls.is_empty() { + return json!({ + "status": "error", + "message": "The last queued draft includes attachments and cannot be replaced via realtime tools.", + }) + .to_string(); + } + + let previous_message = std::mem::replace(&mut last_message.text, replacement.clone()); + last_message.text_elements.clear(); + last_message.mention_bindings.clear(); + self.refresh_pending_input_preview(); + self.request_redraw(); + + json!({ + "status": "ok", + "previous_message": previous_message, + "message": replacement, + "queued_message_count": self.queued_user_messages.len(), + }) + .to_string() + } + + fn remove_last_queued_message_via_realtime(&mut self) -> String { + let Some(removed_message) = self.queued_user_messages.pop_back() else { + return json!({ + "status": "error", + "message": "There are no queued draft messages to remove.", + }) + .to_string(); + }; + + self.refresh_pending_input_preview(); + self.request_redraw(); + + json!({ + "status": "ok", + "removed_message": removed_message.text, + "queued_message_count": self.queued_user_messages.len(), + }) + .to_string() + } + + fn clear_queued_messages_via_realtime(&mut self) -> String { + let removed_count = self.queued_user_messages.len(); + let cleared_messages = self + .queued_user_messages + .drain(..) + .map(|message| message.text) + .collect::>(); + self.refresh_pending_input_preview(); + self.request_redraw(); + + json!({ + "status": "ok", + "cleared_count": removed_count, + "cleared_messages": cleared_messages, + }) + .to_string() + } + + fn realtime_runtime_settings_output(&self) -> String { + json!({ + "status": "ok", + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string() + } + + fn current_runtime_settings_json(&self) -> serde_json::Value { + json!({ + "working_directory": self.config.cwd.display().to_string(), + "model": self.current_model(), + "reasoning_effort": Self::status_line_reasoning_effort_label( + self.effective_reasoning_effort(), + ), + "fast_mode": matches!(self.current_service_tier(), Some(ServiceTier::Fast)), + "personality": self.realtime_personality_name( + self.config.personality.unwrap_or(Personality::None), + ), + "collaboration_mode": self.realtime_collaboration_mode_name(self.active_mode_kind()), + }) + } + + fn possible_runtime_settings_json(&self) -> serde_json::Value { + let available_models = self + .models_manager + .try_list_models() + .map(|models| { + models + .into_iter() + .map(|preset| preset.model) + .collect::>() + }) + .unwrap_or_else(|_| Vec::new()); + let plan_available = collaboration_modes::plan_mask(self.models_manager.as_ref()).is_some(); + + json!({ + "keys": [ + "model", + "working_directory", + "reasoning_effort", + "fast_mode", + "personality", + "collaboration_mode", + ], + "model_values": available_models, + "working_directory": { + "description": "Absolute path or path relative to the current working directory.", + }, + "reasoning_effort_values": ["default", "none", "minimal", "low", "medium", "high", "xhigh"], + "fast_mode_values": [false, true], + "personality_values": ["none", "friendly", "pragmatic"], + "collaboration_mode_values": if plan_available { + json!(["default", "plan"]) + } else { + json!(["default"]) + }, + }) + } + + fn update_runtime_settings_via_realtime( + &mut self, + model: Option, + working_directory: Option, + reasoning_effort: Option, + fast_mode: Option, + personality: Option, + collaboration_mode: Option, + ) -> String { + let requested_anything = model.is_some() + || working_directory.is_some() + || reasoning_effort.is_some() + || fast_mode.is_some() + || personality.is_some() + || collaboration_mode.is_some(); + if !requested_anything { + return self.realtime_runtime_settings_output(); + } + + let resolved_cwd = match working_directory { + Some(working_directory) => { + match self.resolve_realtime_working_directory(working_directory.as_str()) { + Ok(path) => Some(path), + Err(message) => { + return json!({ + "status": "error", + "message": message, + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string(); + } + } + } + None => None, + }; + + let parsed_reasoning_effort = match reasoning_effort { + Some(reasoning_effort) => { + match self.parse_realtime_reasoning_effort(reasoning_effort.as_str()) { + Ok(effort) => Some(effort), + Err(message) => { + return json!({ + "status": "error", + "message": message, + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string(); + } + } + } + None => None, + }; + + let parsed_personality = match personality { + Some(personality) => match self.parse_realtime_personality(personality.as_str()) { + Ok(personality) => Some(personality), + Err(message) => { + return json!({ + "status": "error", + "message": message, + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string(); + } + }, + None => None, + }; + + let parsed_collaboration_mode = match collaboration_mode { + Some(collaboration_mode) => { + match self.parse_realtime_collaboration_mode(collaboration_mode.as_str()) { + Ok(mode) => Some(mode), + Err(message) => { + return json!({ + "status": "error", + "message": message, + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string(); + } + } + } + None => None, + }; + + let needs_model_lookup = + model.is_some() || parsed_reasoning_effort.is_some() || parsed_personality.is_some(); + let target_preset = if needs_model_lookup { + let requested_model = model + .as_deref() + .unwrap_or_else(|| self.current_model()) + .to_string(); + match self.find_realtime_model_preset(requested_model.as_str()) { + Ok(preset) => Some(preset), + Err(message) => { + return json!({ + "status": "error", + "message": message, + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string(); + } + } + } else { + None + }; + + let target_model = target_preset + .as_ref() + .map(|preset| preset.model.clone()) + .unwrap_or_else(|| self.current_model().to_string()); + let target_effort = if let Some(effort) = parsed_reasoning_effort { + effort + } else if model.is_some() && target_model != self.current_model() { + target_preset + .as_ref() + .map(|preset| preset.default_reasoning_effort) + } else { + self.effective_reasoning_effort() + }; + + if let Some(preset) = &target_preset + && let Some(effort) = target_effort + && !preset + .supported_reasoning_efforts + .iter() + .any(|option| option.effort == effort) + { + return json!({ + "status": "error", + "message": format!( + "Model {} does not support reasoning effort {}.", + preset.model, + Self::status_line_reasoning_effort_label(Some(effort)), + ), + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string(); + } + + if let Some(personality) = parsed_personality + && personality != Personality::None + && !self.config.features.enabled(Feature::Personality) + { + return json!({ + "status": "error", + "message": "The personality feature is disabled in this session.", + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string(); + } + + if let Some(personality) = parsed_personality + && personality != Personality::None + && !target_preset + .as_ref() + .is_some_and(|preset| preset.supports_personality) + { + return json!({ + "status": "error", + "message": format!( + "Model {} does not support personalities.", + target_model + ), + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string(); + } + + let target_collaboration_mask = match parsed_collaboration_mode { + Some(ModeKind::Default) => { + collaboration_modes::default_mask(self.models_manager.as_ref()) + } + Some(ModeKind::Plan) => collaboration_modes::plan_mask(self.models_manager.as_ref()), + Some(_) => None, + None => None, + }; + if parsed_collaboration_mode.is_some() && target_collaboration_mask.is_none() { + return json!({ + "status": "error", + "message": "The requested collaboration mode is not available right now.", + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string(); + } + + let mut updated_fields = serde_json::Map::new(); + let mut override_cwd = None; + let mut override_model = None; + let mut override_effort = None; + let mut override_service_tier = None; + let mut override_personality = None; + + if let Some(path) = resolved_cwd { + self.set_working_directory(path.clone()); + self.app_event_tx + .send(AppEvent::UpdateWorkingDirectory(path.clone())); + self.app_event_tx.send(AppEvent::CodexOp(Op::ListSkills { + cwds: Vec::new(), + force_reload: false, + })); + updated_fields.insert( + "working_directory".to_string(), + json!(path.display().to_string()), + ); + override_cwd = Some(path); + } + + if model.is_some() || parsed_reasoning_effort.is_some() { + if model.is_some() { + updated_fields.insert("model".to_string(), json!(target_model.clone())); + override_model = Some(target_model.clone()); + } + updated_fields.insert( + "reasoning_effort".to_string(), + json!(Self::status_line_reasoning_effort_label(target_effort)), + ); + override_effort = Some(target_effort); + self.set_model(&target_model); + self.set_reasoning_effort(target_effort); + } + + if let Some(enabled) = fast_mode { + let next_service_tier = enabled.then_some(ServiceTier::Fast); + self.set_service_tier(next_service_tier); + updated_fields.insert("fast_mode".to_string(), json!(enabled)); + override_service_tier = Some(next_service_tier); + } + + if let Some(personality) = parsed_personality { + self.set_personality(personality); + updated_fields.insert( + "personality".to_string(), + json!(self.realtime_personality_name(personality)), + ); + override_personality = Some(personality); + } + + if let Some(mask) = target_collaboration_mask { + self.set_collaboration_mask(mask); + updated_fields.insert( + "collaboration_mode".to_string(), + json!(self.realtime_collaboration_mode_name(self.active_mode_kind())), + ); + } + + if override_cwd.is_some() + || override_model.is_some() + || override_effort.is_some() + || override_service_tier.is_some() + || override_personality.is_some() + { + self.app_event_tx + .send(AppEvent::CodexOp(Op::OverrideTurnContext { + cwd: override_cwd, + approval_policy: None, + sandbox_policy: None, + windows_sandbox_level: None, + model: override_model, + effort: override_effort, + summary: None, + service_tier: override_service_tier, + collaboration_mode: None, + personality: override_personality, + })); + } + + self.refresh_status_line(); + self.request_redraw(); + + json!({ + "status": "ok", + "updated": updated_fields, + "current_settings": self.current_runtime_settings_json(), + "possible_settings": self.possible_runtime_settings_json(), + }) + .to_string() + } + + fn compact_conversation_via_realtime(&mut self) -> String { + if self.bottom_pane.is_task_running() { + return json!({ + "status": "error", + "message": "Codex must be idle before compacting the conversation.", + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string(); + } + + self.clear_token_usage(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + json!({ + "status": "ok", + "message": "Started compacting the current conversation.", + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string() + } + + fn run_tui_command_via_realtime(&mut self, command: String, prompt: Option) -> String { + match command.trim().to_ascii_lowercase().as_str() { + "compact" => self.compact_conversation_via_realtime(), + "review" => self.review_via_realtime(prompt), + "plan" => self.plan_via_realtime(prompt), + "diff" => self.diff_via_realtime(), + "agent" => self.agent_via_realtime(), + other => json!({ + "status": "error", + "message": format!( + "Unsupported TUI command `{other}`. Use compact, review, plan, diff, or agent." + ), + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string(), + } + } + + fn review_via_realtime(&mut self, prompt: Option) -> String { + let review_request = match prompt.as_deref().map(str::trim) { + Some(prompt) if !prompt.is_empty() => ReviewRequest { + target: ReviewTarget::Custom { + instructions: prompt.to_string(), + }, + user_facing_hint: None, + }, + _ => ReviewRequest { + target: ReviewTarget::UncommittedChanges, + user_facing_hint: None, + }, + }; + if !self.submit_op(Op::Review { + review_request: review_request.clone(), + }) { + return json!({ + "status": "error", + "message": "Failed to start review.", + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string(); + } + + json!({ + "status": "ok", + "command": "review", + "target": review_request.target, + "message": "Started review.", + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string() + } + + fn plan_via_realtime(&mut self, prompt: Option) -> String { + if !self.collaboration_modes_enabled() { + return json!({ + "status": "error", + "message": "Collaboration modes are disabled.", + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string(); + } + + let Some(mask) = collaboration_modes::plan_mask(self.models_manager.as_ref()) else { + return json!({ + "status": "error", + "message": "Plan mode is unavailable right now.", + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string(); + }; + + self.set_collaboration_mask(mask); + + let trimmed_prompt = prompt.unwrap_or_default().trim().to_string(); + if trimmed_prompt.is_empty() { + return json!({ + "status": "ok", + "command": "plan", + "message": "Switched to Plan mode.", + "current_settings": self.current_runtime_settings_json(), + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string(); + } + + let user_message = UserMessage { + text: trimmed_prompt, + local_images: Vec::new(), + remote_image_urls: Vec::new(), + text_elements: Vec::new(), + mention_bindings: Vec::new(), + }; + if !self.is_session_configured() + || self.bottom_pane.is_task_running() + || self.is_review_mode + { + self.queue_user_message(user_message); + json!({ + "status": "ok", + "command": "plan", + "message": "Switched to Plan mode and queued the plan request.", + "current_settings": self.current_runtime_settings_json(), + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string() + } else { + self.submit_user_message(user_message); + json!({ + "status": "ok", + "command": "plan", + "message": "Switched to Plan mode and submitted the plan request.", + "current_settings": self.current_runtime_settings_json(), + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string() + } + } + + fn diff_via_realtime(&mut self) -> String { + self.add_diff_in_progress(); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let text = match get_git_diff().await { + Ok((is_git_repo, diff_text)) => { + if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + } + } + Err(err) => format!("Failed to compute diff: {err}"), + }; + tx.send(AppEvent::DiffResult(text)); + }); + + json!({ + "status": "ok", + "command": "diff", + "message": "Started computing the current git diff.", + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string() + } + + fn agent_via_realtime(&mut self) -> String { + self.app_event_tx.send(AppEvent::OpenAgentPicker); + json!({ + "status": "ok", + "command": "agent", + "message": "Opened the agent picker.", + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + .to_string() + } + + fn resolve_realtime_working_directory( + &self, + working_directory: &str, + ) -> Result { + let trimmed = working_directory.trim(); + if trimmed.is_empty() { + return Err("The working_directory must not be empty.".to_string()); + } + + let requested_path = PathBuf::from(trimmed); + let resolved_path = if requested_path.is_absolute() { + requested_path + } else { + self.config.cwd.join(requested_path) + }; + let canonical_path = std::fs::canonicalize(&resolved_path).map_err(|err| { + format!( + "Failed to resolve working_directory {}: {err}", + resolved_path.display() + ) + })?; + if !canonical_path.is_dir() { + return Err(format!( + "The working_directory must point to a directory: {}", + canonical_path.display() + )); + } + Ok(canonical_path) + } + + fn find_realtime_model_preset( + &self, + model: &str, + ) -> Result { + let models = self.models_manager.try_list_models().map_err(|_| { + "Models are being updated right now. Try again in a moment.".to_string() + })?; + models + .into_iter() + .find(|preset| preset.model == model) + .ok_or_else(|| { + format!( + "Unknown model `{model}`. Call manage_runtime_settings with no setting fields or use a model from /model." + ) + }) + } + + fn parse_realtime_reasoning_effort( + &self, + reasoning_effort: &str, + ) -> Result, String> { + match reasoning_effort.trim().to_ascii_lowercase().as_str() { + "default" => Ok(None), + "none" => Ok(Some(ReasoningEffortConfig::None)), + "minimal" => Ok(Some(ReasoningEffortConfig::Minimal)), + "low" => Ok(Some(ReasoningEffortConfig::Low)), + "medium" => Ok(Some(ReasoningEffortConfig::Medium)), + "high" => Ok(Some(ReasoningEffortConfig::High)), + "xhigh" => Ok(Some(ReasoningEffortConfig::XHigh)), + other => Err(format!( + "Unsupported reasoning_effort `{other}`. Use default, none, minimal, low, medium, high, or xhigh." + )), + } + } + + fn parse_realtime_personality(&self, personality: &str) -> Result { + match personality.trim().to_ascii_lowercase().as_str() { + "none" => Ok(Personality::None), + "friendly" => Ok(Personality::Friendly), + "pragmatic" => Ok(Personality::Pragmatic), + other => Err(format!( + "Unsupported personality `{other}`. Use none, friendly, or pragmatic." + )), + } + } + + fn parse_realtime_collaboration_mode(&self, mode: &str) -> Result { + match mode.trim().to_ascii_lowercase().as_str() { + "default" => Ok(ModeKind::Default), + "plan" => Ok(ModeKind::Plan), + other => Err(format!( + "Unsupported collaboration_mode `{other}`. Use default or plan." + )), + } + } + + fn realtime_personality_name(&self, personality: Personality) -> &'static str { + match personality { + Personality::None => "none", + Personality::Friendly => "friendly", + Personality::Pragmatic => "pragmatic", + } + } + + fn realtime_collaboration_mode_name(&self, mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default", + } + } + #[cfg(all(not(target_os = "linux"), feature = "voice-input"))] fn start_realtime_local_audio(&mut self) { if self.realtime_conversation.capture_stop_flag.is_some() { diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d1dcfa2c12..be11cb7f0a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -48,6 +48,7 @@ use codex_protocol::items::TurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::MessagePhase; use codex_protocol::openai_models::ModelPreset; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::parse_command::ParsedCommand; @@ -85,6 +86,8 @@ use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::RealtimeConversationRealtimeEvent; use codex_protocol::protocol::RealtimeEvent; use codex_protocol::protocol::RealtimeHandoffRequested; +use codex_protocol::protocol::RealtimeToolAction; +use codex_protocol::protocol::RealtimeToolActionRequested; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::SessionSource; @@ -95,6 +98,7 @@ use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UndoCompletedEvent; @@ -1942,6 +1946,55 @@ fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { } } +fn next_realtime_close_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { + loop { + match op_rx.try_recv() { + Ok(Op::RealtimeConversationClose) => return, + Ok(_) => continue, + Err(TryRecvError::Empty) => { + panic!("expected realtime close op but queue was empty") + } + Err(TryRecvError::Disconnected) => { + panic!("expected realtime close op but channel closed") + } + } + } +} + +fn next_app_event(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> AppEvent { + rx.try_recv().expect("expected app event") +} + +fn next_realtime_tool_call_complete( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> codex_protocol::protocol::RealtimeToolCallCompleteParams { + loop { + match rx.try_recv() { + Ok(AppEvent::CodexOp(Op::RealtimeConversationToolCallComplete(params))) => { + return params; + } + Ok(_) => continue, + Err(TryRecvError::Empty) => { + panic!("expected realtime tool completion but queue was empty") + } + Err(TryRecvError::Disconnected) => { + panic!("expected realtime tool completion but channel closed") + } + } + } +} + +fn next_review_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> ReviewRequest { + loop { + match op_rx.try_recv() { + Ok(Op::Review { review_request }) => return review_request, + Ok(_) => continue, + Err(TryRecvError::Empty) => panic!("expected review op but queue was empty"), + Err(TryRecvError::Disconnected) => panic!("expected review op but channel closed"), + } + } +} + fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver) { while let Ok(op) = op_rx.try_recv() { assert!( @@ -10461,6 +10514,403 @@ async fn realtime_handoff_can_send_immediately_while_turn_is_running() { } } +#[tokio::test] +async fn realtime_close_request_interrupts_before_disconnect_when_turn_is_running() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.realtime_conversation + .set_phase_for_test(super::realtime::RealtimeConversationPhase::Active); + chat.on_task_started(); + + chat.handle_codex_event(Event { + id: "rt-close-1".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::CloseRequested( + codex_protocol::protocol::RealtimeCloseRequested { + call_id: "close_1".to_string(), + }, + ), + }), + }); + + assert_eq!( + chat.realtime_conversation.phase_for_test(), + super::realtime::RealtimeConversationPhase::Stopping + ); + assert!(chat.realtime_conversation.requested_close_for_test()); + assert!(chat.realtime_conversation.close_when_idle_for_test()); + next_interrupt_op(&mut op_rx); + + let inserted = drain_insert_history(&mut rx); + let rendered = inserted + .iter() + .map(|lines| lines_to_single_string(lines)) + .find(|text| text.contains("Stopping realtime voice mode after current work is cancelled.")) + .expect("expected close deferral info message"); + assert!(rendered.contains("Stopping realtime voice mode after current work is cancelled.")); + + chat.handle_codex_event(Event { + id: "turn-abort".into(), + msg: EventMsg::TurnAborted(TurnAbortedEvent { + turn_id: Some("turn-1".to_string()), + reason: TurnAbortReason::Interrupted, + }), + }); + + next_realtime_close_op(&mut op_rx); + assert!(!chat.realtime_conversation.close_when_idle_for_test()); +} + +#[tokio::test] +async fn realtime_close_request_closes_immediately_when_idle() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + chat.realtime_conversation + .set_phase_for_test(super::realtime::RealtimeConversationPhase::Active); + + chat.handle_codex_event(Event { + id: "rt-close-2".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::CloseRequested( + codex_protocol::protocol::RealtimeCloseRequested { + call_id: "close_2".to_string(), + }, + ), + }), + }); + + next_realtime_close_op(&mut op_rx); + assert!(!chat.realtime_conversation.close_when_idle_for_test()); + + let inserted = drain_insert_history(&mut rx); + let rendered = inserted + .iter() + .map(|lines| lines_to_single_string(lines)) + .find(|text| text.contains("Realtime voice mode turned off.")) + .expect("expected immediate close info message"); + assert!(rendered.contains("Realtime voice mode turned off.")); +} + +#[tokio::test] +async fn realtime_manage_message_queue_updates_queue_and_completes_tool_call() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + chat.queued_user_messages + .push_back(UserMessage::from("old queued message".to_string())); + + chat.handle_codex_event(Event { + id: "rt-queue-replace".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ToolActionRequested(RealtimeToolActionRequested { + call_id: "queue-tool-1".to_string(), + action: RealtimeToolAction::ManageMessageQueue { + action: "replace_last".to_string(), + message: Some("new queued message".to_string()), + }, + }), + }), + }); + + assert_eq!( + chat.queued_user_messages + .back() + .map(|message| message.text.as_str()), + Some("new queued message") + ); + + match next_app_event(&mut rx) { + AppEvent::CodexOp(Op::RealtimeConversationToolCallComplete(params)) => { + assert_eq!(params.call_id, "queue-tool-1"); + assert_eq!( + serde_json::from_str::(¶ms.output_text) + .expect("valid json tool output"), + serde_json::json!({ + "status": "ok", + "previous_message": "old queued message", + "message": "new queued message", + "queued_message_count": 1, + }) + ); + } + other => panic!("unexpected app event: {other:?}"), + } +} + +#[tokio::test] +async fn realtime_manage_runtime_settings_updates_state_and_emits_follow_up_ops() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + let temp = tempdir().expect("tempdir"); + let next_cwd = temp.path().join("nested"); + std::fs::create_dir_all(&next_cwd).expect("create nested dir"); + let canonical_cwd = next_cwd.canonicalize().expect("canonicalize cwd"); + + chat.handle_codex_event(Event { + id: "rt-settings-1".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ToolActionRequested(RealtimeToolActionRequested { + call_id: "settings-tool-1".to_string(), + action: RealtimeToolAction::ManageRuntimeSettings { + model: Some("gpt-5.3-codex".to_string()), + working_directory: Some(next_cwd.display().to_string()), + reasoning_effort: Some("low".to_string()), + fast_mode: None, + personality: None, + collaboration_mode: None, + }, + }), + }), + }); + + assert_eq!(chat.config.cwd, canonical_cwd); + assert_eq!(chat.current_model(), "gpt-5.3-codex"); + assert_eq!(chat.current_reasoning_effort(), Some(ReasoningEffort::Low)); + + match next_app_event(&mut rx) { + AppEvent::UpdateWorkingDirectory(cwd) => assert_eq!(cwd, canonical_cwd), + other => panic!("unexpected app event: {other:?}"), + } + match next_app_event(&mut rx) { + AppEvent::CodexOp(Op::ListSkills { cwds, force_reload }) => { + assert!(cwds.is_empty()); + assert!(!force_reload); + } + other => panic!("unexpected app event: {other:?}"), + } + match next_app_event(&mut rx) { + AppEvent::CodexOp(Op::OverrideTurnContext { + cwd, model, effort, .. + }) => { + assert_eq!(cwd, Some(canonical_cwd.clone())); + assert_eq!(model.as_deref(), Some("gpt-5.3-codex")); + assert_eq!(effort, Some(Some(ReasoningEffort::Low))); + } + other => panic!("unexpected app event: {other:?}"), + } + match next_app_event(&mut rx) { + AppEvent::CodexOp(Op::RealtimeConversationToolCallComplete(params)) => { + assert_eq!(params.call_id, "settings-tool-1"); + let output = + serde_json::from_str::(¶ms.output_text).expect("json"); + assert_eq!(output["status"], "ok"); + assert_eq!(output["updated"]["model"], "gpt-5.3-codex"); + assert_eq!( + output["updated"]["working_directory"], + canonical_cwd.display().to_string() + ); + assert_eq!(output["updated"]["reasoning_effort"], "low"); + assert_eq!( + output["current_settings"]["working_directory"], + canonical_cwd.display().to_string() + ); + assert_eq!(output["current_settings"]["model"], "gpt-5.3-codex"); + assert_eq!(output["current_settings"]["reasoning_effort"], "low"); + assert_eq!( + output["possible_settings"]["keys"], + serde_json::json!([ + "model", + "working_directory", + "reasoning_effort", + "fast_mode", + "personality", + "collaboration_mode", + ]) + ); + } + other => panic!("unexpected app event: {other:?}"), + } +} + +#[tokio::test] +async fn realtime_manage_runtime_settings_lists_possible_settings() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + + chat.handle_codex_event(Event { + id: "rt-settings-list".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ToolActionRequested(RealtimeToolActionRequested { + call_id: "settings-tool-list".to_string(), + action: RealtimeToolAction::ManageRuntimeSettings { + model: None, + working_directory: None, + reasoning_effort: None, + fast_mode: None, + personality: None, + collaboration_mode: None, + }, + }), + }), + }); + + match next_app_event(&mut rx) { + AppEvent::CodexOp(Op::RealtimeConversationToolCallComplete(params)) => { + assert_eq!(params.call_id, "settings-tool-list"); + let output = + serde_json::from_str::(¶ms.output_text).expect("json"); + assert_eq!(output["status"], "ok"); + assert_eq!(output["current_settings"]["model"], "gpt-5.2-codex"); + assert_eq!( + output["possible_settings"]["keys"], + serde_json::json!([ + "model", + "working_directory", + "reasoning_effort", + "fast_mode", + "personality", + "collaboration_mode", + ]) + ); + assert_eq!( + output["possible_settings"]["reasoning_effort_values"], + serde_json::json!([ + "default", "none", "minimal", "low", "medium", "high", "xhigh" + ]) + ); + } + other => panic!("unexpected app event: {other:?}"), + } +} + +#[tokio::test] +async fn realtime_run_tui_command_compact_emits_compact_and_tool_completion() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "rt-compact-1".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ToolActionRequested(RealtimeToolActionRequested { + call_id: "compact-tool-1".to_string(), + action: RealtimeToolAction::RunTuiCommand { + command: "compact".to_string(), + prompt: None, + }, + }), + }), + }); + + match next_app_event(&mut rx) { + AppEvent::CodexOp(Op::Compact) => {} + other => panic!("unexpected app event: {other:?}"), + } + match next_app_event(&mut rx) { + AppEvent::CodexOp(Op::RealtimeConversationToolCallComplete(params)) => { + assert_eq!(params.call_id, "compact-tool-1"); + assert_eq!( + serde_json::from_str::(¶ms.output_text).expect("json"), + serde_json::json!({ + "status": "ok", + "message": "Started compacting the current conversation.", + "possible_commands": ["compact", "review", "plan", "diff", "agent"], + }) + ); + } + other => panic!("unexpected app event: {other:?}"), + } +} + +#[tokio::test] +async fn realtime_run_tui_command_review_emits_review_op_and_tool_completion() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "rt-review-1".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ToolActionRequested(RealtimeToolActionRequested { + call_id: "review-tool-1".to_string(), + action: RealtimeToolAction::RunTuiCommand { + command: "review".to_string(), + prompt: Some("focus on risky changes".to_string()), + }, + }), + }), + }); + + assert_eq!( + next_review_op(&mut op_rx), + ReviewRequest { + target: ReviewTarget::Custom { + instructions: "focus on risky changes".to_string(), + }, + user_facing_hint: None, + } + ); + let params = next_realtime_tool_call_complete(&mut rx); + assert_eq!(params.call_id, "review-tool-1"); + let output = serde_json::from_str::(¶ms.output_text).expect("json"); + assert_eq!(output["status"], "ok"); + assert_eq!(output["command"], "review"); + assert_eq!( + output["possible_commands"], + serde_json::json!(["compact", "review", "plan", "diff", "agent"]) + ); +} + +#[tokio::test] +async fn realtime_run_tui_command_plan_switches_mode_and_queues_prompt() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.2-codex")).await; + + chat.handle_codex_event(Event { + id: "rt-plan-1".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ToolActionRequested(RealtimeToolActionRequested { + call_id: "plan-tool-1".to_string(), + action: RealtimeToolAction::RunTuiCommand { + command: "plan".to_string(), + prompt: Some("plan the migration".to_string()), + }, + }), + }), + }); + + assert_eq!(chat.active_mode_kind(), ModeKind::Plan); + assert_eq!(chat.queued_user_messages.len(), 1); + assert_eq!( + chat.queued_user_messages + .front() + .map(|message| message.text.as_str()), + Some("plan the migration") + ); + + let params = next_realtime_tool_call_complete(&mut rx); + assert_eq!(params.call_id, "plan-tool-1"); + let output = serde_json::from_str::(¶ms.output_text).expect("json"); + assert_eq!(output["status"], "ok"); + assert_eq!(output["command"], "plan"); + assert_eq!( + output["message"], + "Switched to Plan mode and queued the plan request." + ); +} + +#[tokio::test] +async fn realtime_run_tui_command_agent_opens_picker_and_completes_tool_call() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.handle_codex_event(Event { + id: "rt-agent-1".into(), + msg: EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent { + payload: RealtimeEvent::ToolActionRequested(RealtimeToolActionRequested { + call_id: "agent-tool-1".to_string(), + action: RealtimeToolAction::RunTuiCommand { + command: "agent".to_string(), + prompt: None, + }, + }), + }), + }); + + match next_app_event(&mut rx) { + AppEvent::OpenAgentPicker => {} + other => panic!("unexpected app event: {other:?}"), + } + match next_app_event(&mut rx) { + AppEvent::CodexOp(Op::RealtimeConversationToolCallComplete(params)) => { + assert_eq!(params.call_id, "agent-tool-1"); + let output = + serde_json::from_str::(¶ms.output_text).expect("json"); + assert_eq!(output["status"], "ok"); + assert_eq!(output["command"], "agent"); + } + other => panic!("unexpected app event: {other:?}"), + } +} + #[tokio::test] async fn review_queues_user_messages_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;