diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 3c21552dcb..14dba39e29 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -31,6 +31,7 @@ use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; use crate::client_common::ResponsesApiRequest; +use crate::client_common::TurnType; use crate::client_common::create_reasoning_param_for_request; use crate::client_common::create_text_param_for_request; use crate::config::Config; @@ -244,7 +245,7 @@ impl ModelClient { let max_attempts = self.provider.request_max_retries(); for attempt in 0..=max_attempts { match self - .attempt_stream_responses(attempt, &payload_json, &auth_manager) + .attempt_stream_responses(attempt, &payload_json, &auth_manager, prompt.turn_type) .await { Ok(stream) => { @@ -272,6 +273,7 @@ impl ModelClient { attempt: u64, payload_json: &Value, auth_manager: &Option>, + turn_type: TurnType, ) -> std::result::Result { // Always fetch the latest auth in case a prior attempt refreshed the token. let auth = auth_manager.as_ref().and_then(|m| m.auth()); @@ -293,6 +295,13 @@ impl ModelClient { // Send session_id for compatibility. .header("conversation_id", self.conversation_id.to_string()) .header("session_id", self.conversation_id.to_string()) + .header( + "action_kind", + match turn_type { + TurnType::Review => "review", + TurnType::Regular => "turn", + }, + ) .header(reqwest::header::ACCEPT, "text/event-stream") .json(payload_json); diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index d30da5192a..f25a2093b4 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -23,6 +23,13 @@ use tokio::sync::mpsc; /// Review thread system prompt. Edit `core/src/review_prompt.md` to customize. pub const REVIEW_PROMPT: &str = include_str!("../review_prompt.md"); +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TurnType { + #[default] + Regular, + Review, +} + /// API request payload for a single model turn #[derive(Default, Debug, Clone)] pub struct Prompt { @@ -41,6 +48,9 @@ pub struct Prompt { /// Optional the output schema for the model's response. pub output_schema: Option, + + /// The type of turn being executed + pub turn_type: TurnType, } impl Prompt { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d11a53b59a..4d3e4cf9d6 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -42,6 +42,7 @@ use crate::apply_patch::convert_apply_patch_to_protocol; use crate::client::ModelClient; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; +use crate::client_common::TurnType; use crate::config::Config; use crate::config_types::ShellEnvironmentPolicy; use crate::conversation_history::ConversationHistory; @@ -1933,6 +1934,13 @@ async fn run_turn( sub_id: String, input: Vec, ) -> CodexResult { + fn resolve_turn_type(turn_context: &TurnContext) -> TurnType { + if turn_context.is_review_mode { + TurnType::Review + } else { + TurnType::Regular + } + } let mcp_tools = sess.services.mcp_connection_manager.list_all_tools(); let router = Arc::new(ToolRouter::from_config( &turn_context.tools_config, @@ -1950,6 +1958,7 @@ async fn run_turn( parallel_tool_calls, base_instructions_override: turn_context.base_instructions.clone(), output_schema: turn_context.final_output_json_schema.clone(), + turn_type: resolve_turn_type(&turn_context), }; let mut retries = 0; diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index eb14dabb81..29dc5f6b81 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -349,12 +349,14 @@ async fn includes_conversation_id_and_model_headers_in_request() { let request_conversation_id = request.headers.get("conversation_id").unwrap(); let request_authorization = request.headers.get("authorization").unwrap(); let request_originator = request.headers.get("originator").unwrap(); + let turn_kind = request.headers.get("action_kind").unwrap(); assert_eq!( request_conversation_id.to_str().unwrap(), conversation_id.to_string() ); assert_eq!(request_originator.to_str().unwrap(), "codex_cli_rs"); + assert_eq!(turn_kind.to_str().unwrap(), "turn"); assert_eq!( request_authorization.to_str().unwrap(), "Bearer Test API Key" diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index f3eeb3a310..61fe1f198e 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -338,6 +338,78 @@ async fn review_uses_custom_review_model_from_config() { server.verify().await; } +/// Ensure the client marks review turns with `action_kind: review` on the +/// outbound Responses API request. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn review_sends_action_kind_header() { + skip_if_no_network!(); + + // Minimal stream: just a completed event + let sse_raw = r#"[ + {"type":"response.completed", "response": {"id": "__ID__"}} + ]"#; + // Expect 2 requests total: first the review thread, then a follow-up turn. + let server = start_responses_server_with_sse(sse_raw, 2).await; + let codex_home = TempDir::new().unwrap(); + let codex = new_conversation_for_server(&server, &codex_home, |_| {}).await; + + codex + .submit(Op::Review { + review_request: ReviewRequest { + prompt: "check action_kind header".to_string(), + user_facing_hint: "check action_kind header".to_string(), + }, + }) + .await + .unwrap(); + + // Wait for completion to ensure the request was sent. + let _entered = wait_for_event(&codex, |ev| matches!(ev, EventMsg::EnteredReviewMode(_))).await; + let _closed = wait_for_event(&codex, |ev| matches!(ev, EventMsg::ExitedReviewMode(_))).await; + let _complete = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + // Assert the outbound request included the correct header for the review request. + let mut requests = server.received_requests().await.unwrap(); + assert!( + !requests.is_empty(), + "expected at least one request (review)" + ); + let review_req = &requests[0]; + let review_kind = review_req.headers.get("action_kind"); + assert!( + review_kind.is_some(), + "expected action_kind header on review" + ); + assert_eq!(review_kind.unwrap().to_str().unwrap(), "review"); + + // Now send a normal follow-up turn and ensure its header is "turn". + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "follow-up after review".to_string(), + }], + }) + .await + .unwrap(); + let _complete2 = wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + requests = server.received_requests().await.unwrap(); + assert_eq!( + requests.len(), + 2, + "expected 2 requests (review + follow-up)" + ); + let follow_req = &requests[1]; + let follow_kind = follow_req.headers.get("action_kind"); + assert!( + follow_kind.is_some(), + "expected action_kind header on follow-up" + ); + assert_eq!(follow_kind.unwrap().to_str().unwrap(), "turn"); + + server.verify().await; +} + /// When a review session begins, it must not prepend prior chat history from /// the parent session. The request `input` should contain only the review /// prompt from the user.