diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 41d7b9010a..ece7e2218e 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2708,6 +2708,12 @@ }, "threadId": { "type": "string" + }, + "userInstructions": { + "type": [ + "string", + "null" + ] } }, "required": [ @@ -3061,6 +3067,12 @@ }, "threadId": { "type": "string" + }, + "userInstructions": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 3981b069de..818062392d 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12781,6 +12781,12 @@ }, "threadId": { "type": "string" + }, + "userInstructions": { + "type": [ + "string", + "null" + ] } }, "required": [ @@ -14085,6 +14091,12 @@ }, "threadId": { "type": "string" + }, + "userInstructions": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 478c29ba72..058f01a94c 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -10629,6 +10629,12 @@ }, "threadId": { "type": "string" + }, + "userInstructions": { + "type": [ + "string", + "null" + ] } }, "required": [ @@ -11933,6 +11939,12 @@ }, "threadId": { "type": "string" + }, + "userInstructions": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index a8fa95e2e9..92590dd937 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -168,6 +168,12 @@ }, "threadId": { "type": "string" + }, + "userInstructions": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 3c8eb552ae..43652bd134 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1081,6 +1081,12 @@ }, "threadId": { "type": "string" + }, + "userInstructions": { + "type": [ + "string", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index a7ba311803..8b6af4b020 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -27,7 +27,7 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, userInstructions?: string | null | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index 770344de8e..b020c613cf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -36,7 +36,7 @@ model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, userInstructions?: string | null | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, /** * If true, persist additional rollout EventMsg variants required to * reconstruct a richer thread history on subsequent resume/fork/read. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 8c87d25ae6..f64d2cd410 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2794,6 +2794,14 @@ pub struct ThreadResumeParams { pub sandbox: Option, #[ts(optional = nullable)] pub config: Option>, + #[serde( + default, + deserialize_with = "super::serde_helpers::deserialize_double_option", + serialize_with = "super::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub user_instructions: Option>, #[ts(optional = nullable)] pub base_instructions: Option, #[ts(optional = nullable)] @@ -2874,6 +2882,14 @@ pub struct ThreadForkParams { pub sandbox: Option, #[ts(optional = nullable)] pub config: Option>, + #[serde( + default, + deserialize_with = "super::serde_helpers::deserialize_double_option", + serialize_with = "super::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub user_instructions: Option>, #[ts(optional = nullable)] pub base_instructions: Option, #[ts(optional = nullable)] @@ -8623,6 +8639,46 @@ mod tests { assert_eq!(serialized_without_override.get("userInstructions"), None); } + #[test] + fn thread_resume_params_preserve_explicit_null_user_instructions() { + let params: ThreadResumeParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "userInstructions": null, + })) + .expect("params should deserialize"); + assert_eq!(params.user_instructions, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("userInstructions"), + Some(&serde_json::Value::Null) + ); + + let serialized_without_override = + serde_json::to_value(ThreadResumeParams::default()).expect("params should serialize"); + assert_eq!(serialized_without_override.get("userInstructions"), None); + } + + #[test] + fn thread_fork_params_preserve_explicit_null_user_instructions() { + let params: ThreadForkParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "userInstructions": null, + })) + .expect("params should deserialize"); + assert_eq!(params.user_instructions, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("userInstructions"), + Some(&serde_json::Value::Null) + ); + + let serialized_without_override = + serde_json::to_value(ThreadForkParams::default()).expect("params should serialize"); + assert_eq!(serialized_without_override.get("userInstructions"), None); + } + #[test] fn turn_start_params_preserve_explicit_null_service_tier() { let params: TurnStartParams = serde_json::from_value(json!({ diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 25ed50b4bc..ede5448322 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4080,6 +4080,7 @@ impl CodexMessageProcessor { approvals_reviewer, sandbox, config: mut request_overrides, + user_instructions, base_instructions, developer_instructions, personality, @@ -4113,7 +4114,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - /*user_instructions*/ None, + user_instructions, base_instructions, developer_instructions, personality, @@ -4603,6 +4604,7 @@ impl CodexMessageProcessor { approvals_reviewer, sandbox, config: cli_overrides, + user_instructions, base_instructions, developer_instructions, ephemeral, @@ -4684,7 +4686,7 @@ impl CodexMessageProcessor { approval_policy, approvals_reviewer, sandbox, - /*user_instructions*/ None, + user_instructions, base_instructions, developer_instructions, /*personality*/ None, @@ -9753,6 +9755,7 @@ mod tests { approvals_reviewer: None, sandbox: None, config: None, + user_instructions: None, base_instructions: None, developer_instructions: None, personality: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 9907fc4b1d..56defcb012 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -26,6 +26,8 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use core_test_support::responses; +use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -183,6 +185,153 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_fork_honors_explicit_null_thread_instructions() -> Result<()> { + skip_if_no_network!(Ok(())); + + 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_sequence(&server, vec![body.clone(), body.clone(), body]).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + std::fs::write( + codex_home.path().join("AGENTS.md"), + "Config user instructions sentinel", + )?; + + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let disabled_instruction_config = json!({ + "include_permissions_instructions": false, + "include_apps_instructions": false, + "include_environment_context": false, + "features.apps": false, + "features.plugins": false, + "features.codex_hooks": false, + "skills.bundled.enabled": false, + }); + + let fork_params = [ + ( + json!({ + "threadId": conversation_id.clone(), + "config": disabled_instruction_config.clone(), + }), + /*expect_user_instructions*/ true, + ), + ( + json!({ + "threadId": conversation_id.clone(), + "config": disabled_instruction_config.clone(), + "userInstructions": null, + }), + /*expect_user_instructions*/ false, + ), + ]; + + let mut forked_thread_ids = Vec::new(); + for (params, _expect_user_instructions) in fork_params { + let fork_id = mcp.send_raw_request("thread/fork", Some(params)).await?; + let fork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(fork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(fork_resp)?; + forked_thread_ids.push(thread.id.clone()); + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: "continue".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_id)), + ) + .await??; + let _: TurnStartResponse = to_response::(turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + } + + let refork_id = mcp + .send_raw_request( + "thread/fork", + Some(json!({ + "threadId": forked_thread_ids[1].clone(), + "config": disabled_instruction_config.clone(), + })), + ) + .await?; + let refork_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(refork_id)), + ) + .await??; + let ThreadForkResponse { thread, .. } = to_response::(refork_resp)?; + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![UserInput::Text { + text: "continue again".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_id)), + ) + .await??; + let _: TurnStartResponse = to_response::(turn_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + for (request, expect_user_instructions) in requests.into_iter().zip([true, false, false]) { + let user_texts = request.message_input_texts("user"); + assert_eq!( + user_texts + .iter() + .any(|text| text.contains("Config user instructions sentinel")), + expect_user_instructions, + "unexpected config user instruction presence: {user_texts:?}" + ); + } + + Ok(()) +} + #[tokio::test] async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 80ae756888..46f7778c31 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -1810,6 +1810,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { .send_thread_resume_request(ThreadResumeParams { thread_id: thread.id, model: Some("gpt-5.2-codex".to_string()), + user_instructions: Some(Some("Resumed user instructions sentinel".to_string())), personality: Some(Personality::Friendly), ..Default::default() }) @@ -1860,6 +1861,13 @@ async fn thread_resume_accepts_personality_override() -> Result<()> { instructions_text.contains(CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT), "expected default base instructions from history, got {instructions_text:?}" ); + let user_texts = request.message_input_texts("user"); + assert!( + user_texts + .iter() + .any(|text| text.contains("Resumed user instructions sentinel")), + "expected resumed user instructions in user input, got {user_texts:?}" + ); Ok(()) }