diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 8f276e331..d81a5d27a 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -238,6 +238,96 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + // TODO(aibrahim): Replace empty string instruction overrides with explicit tri-state + // app-server semantics: omitted, explicitly none, or explicit value. + config: Some(HashMap::from([( + "include_permissions_instructions".to_string(), + json!(false), + )])), + base_instructions: Some(String::new()), + developer_instructions: Some(String::new()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let request_body = response_mock.single_request().body_json(); + let empty_developer_input_texts = request_body["input"] + .as_array() + .expect("input array") + .iter() + .filter(|item| item.get("role").and_then(serde_json::Value::as_str) == Some("developer")) + .filter_map(|item| item.get("content").and_then(serde_json::Value::as_array)) + .flatten() + .filter(|content| { + content.get("type").and_then(serde_json::Value::as_str) == Some("input_text") + }) + .filter_map(|content| content.get("text").and_then(serde_json::Value::as_str)) + .filter(|text| text.is_empty()) + .collect::>(); + assert_eq!( + json!({ + "hasInstructions": request_body.get("instructions").is_some(), + "emptyDeveloperInputTexts": empty_developer_input_texts, + }), + json!({ + "hasInstructions": false, + "emptyDeveloperInputTexts": [], + }) + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_accepts_text_at_limit_with_mention_item() -> Result<()> { let responses = vec![create_final_assistant_message_sse_response("Done")?]; diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 882b2a0e2..ef4dd5fb8 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -24,6 +24,7 @@ pub const WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY: &str = "ws_request_h pub struct CompactionInput<'a> { pub model: &'a str, pub input: &'a [ResponseItem], + #[serde(skip_serializing_if = "str::is_empty")] pub instructions: &'a str, pub tools: Vec, pub parallel_tool_calls: bool, @@ -153,6 +154,7 @@ impl From for OpenAiVerbosity { #[derive(Debug, Serialize, Clone, PartialEq)] pub struct ResponsesApiRequest { pub model: String, + #[serde(skip_serializing_if = "String::is_empty")] pub instructions: String, pub input: Vec, pub tools: Vec, @@ -198,6 +200,7 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest { #[derive(Debug, Serialize)] pub struct ResponseCreateWsRequest { pub model: String, + #[serde(skip_serializing_if = "String::is_empty")] pub instructions: String, #[serde(skip_serializing_if = "Option::is_none")] pub previous_response_id: Option, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 007da558b..9c71df3af 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3746,6 +3746,7 @@ impl Session { // stays isolated as its own top-level developer message for guardian subagents. if !separate_guardian_developer_message && let Some(developer_instructions) = turn_context.developer_instructions.as_deref() + && !developer_instructions.is_empty() { developer_sections.push(developer_instructions.to_string()); } @@ -3860,6 +3861,7 @@ impl Session { // subagent sees a distinct, easy-to-audit instruction block. if separate_guardian_developer_message && let Some(developer_instructions) = turn_context.developer_instructions.as_deref() + && !developer_instructions.is_empty() && let Some(guardian_developer_message) = crate::context_manager::updates::build_developer_update_item(vec![ developer_instructions.to_string(),