diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs
index 3a3eb71fdc..11bfade15c 100644
--- a/codex-rs/core/src/codex.rs
+++ b/codex-rs/core/src/codex.rs
@@ -3871,12 +3871,13 @@ impl Session {
pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) {
let history = self.clone_history().await;
- let base_instructions =
- self.get_base_instructions()
- .await
- .unwrap_or_else(|| BaseInstructions {
- text: String::new(),
- });
+ let empty_base_instructions = BaseInstructions {
+ text: String::new(),
+ };
+ let base_instructions = self
+ .get_base_instructions()
+ .await
+ .unwrap_or(empty_base_instructions);
let Some(estimated_total_tokens) =
history.estimate_token_count_with_base_instructions(&base_instructions)
else {
diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs
index cafb389c68..cb97e4a3b3 100644
--- a/codex-rs/core/tests/suite/client.rs
+++ b/codex-rs/core/tests/suite/client.rs
@@ -953,6 +953,47 @@ async fn includes_base_instructions_override_in_request() {
);
}
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn omits_explicit_null_base_instructions_from_request() {
+ skip_if_no_network!();
+ // Mock server
+ let server = MockServer::start().await;
+ let resp_mock = mount_sse_once(
+ &server,
+ sse(vec![ev_response_created("resp1"), ev_completed("resp1")]),
+ )
+ .await;
+
+ let mut builder = test_codex()
+ .with_auth(CodexAuth::from_api_key("Test API Key"))
+ .with_config(|config| {
+ config.base_instructions = Some(None);
+ });
+ let codex = builder
+ .build(&server)
+ .await
+ .expect("create new conversation")
+ .codex;
+
+ codex
+ .submit(Op::UserInput {
+ items: vec![UserInput::Text {
+ text: "hello".into(),
+ text_elements: Vec::new(),
+ }],
+ final_output_json_schema: None,
+ })
+ .await
+ .unwrap();
+
+ wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
+
+ let request = resp_mock.single_request();
+ let request_body = request.body_json();
+
+ assert_eq!(request_body.get("instructions"), None);
+}
+
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn chatgpt_auth_sends_correct_request() {
skip_if_no_network!();
diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs
index de580de6f3..e78bfe6e45 100644
--- a/codex-rs/protocol/src/lib.rs
+++ b/codex-rs/protocol/src/lib.rs
@@ -23,4 +23,5 @@ pub mod plan_tool;
pub mod protocol;
pub mod request_permissions;
pub mod request_user_input;
+mod serde_helpers;
pub mod user_input;
diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs
index 6a410ef1bc..05926a9544 100644
--- a/codex-rs/protocol/src/protocol.rs
+++ b/codex-rs/protocol/src/protocol.rs
@@ -48,6 +48,8 @@ use crate::plan_tool::UpdatePlanArgs;
use crate::request_permissions::RequestPermissionsEvent;
use crate::request_permissions::RequestPermissionsResponse;
use crate::request_user_input::RequestUserInputResponse;
+use crate::serde_helpers::deserialize_double_option;
+use crate::serde_helpers::serialize_double_option;
use crate::user_input::UserInput;
use codex_git_utils::GitSha;
use codex_utils_absolute_path::AbsolutePathBuf;
@@ -2542,8 +2544,8 @@ pub struct SessionMeta {
/// from ModelsManager.
#[serde(
default,
- deserialize_with = "serde_with::rust::double_option::deserialize",
- serialize_with = "serde_with::rust::double_option::serialize",
+ deserialize_with = "deserialize_double_option",
+ serialize_with = "serialize_double_option",
skip_serializing_if = "Option::is_none"
)]
pub base_instructions: Option