diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index 3a0f3fe566..f13010e0a4 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index b66e33988e..10b684720f 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3632,6 +3632,8 @@ mod tests { text: "Understood.".to_string(), }, ]), + realtime_start_instructions: Some("Use realtime output channels.".to_string()), + realtime_end_instructions: Some("Resume normal text responses.".to_string()), prompt: Some(Some("You are on a call".to_string())), realtime_session_id: Some("sess_456".to_string()), transport: None, @@ -3668,6 +3670,8 @@ mod tests { "text": "Understood." } ], + "realtimeStartInstructions": "Use realtime output channels.", + "realtimeEndInstructions": "Resume normal text responses.", "prompt": "You are on a call", "realtimeSessionId": "sess_456", "transport": null, @@ -3696,6 +3700,8 @@ mod tests { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: None, realtime_session_id: None, transport: None, @@ -3719,6 +3725,8 @@ mod tests { "outputModality": "audio", "includeStartupContext": null, "initialItems": null, + "realtimeStartInstructions": null, + "realtimeEndInstructions": null, "realtimeSessionId": null, "transport": null, "version": null, @@ -3742,6 +3750,8 @@ mod tests { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(None), realtime_session_id: None, transport: None, @@ -3765,6 +3775,8 @@ mod tests { "outputModality": "audio", "includeStartupContext": null, "initialItems": null, + "realtimeStartInstructions": null, + "realtimeEndInstructions": null, "prompt": null, "realtimeSessionId": null, "transport": null, @@ -3988,6 +4000,8 @@ mod tests { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("You are on a call".to_string())), realtime_session_id: None, transport: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs index 950590c366..1b7ae70043 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -106,6 +106,12 @@ pub struct ThreadRealtimeStartParams { /// limited to 128 items and 8,192 estimated text tokens in total. #[ts(optional = nullable)] pub initial_items: Option>, + /// Developer instructions given to the backing Codex model when this realtime session starts. + #[ts(optional = nullable)] + pub realtime_start_instructions: Option, + /// Developer instructions given to the backing Codex model when this realtime session ends. + #[ts(optional = nullable)] + pub realtime_end_instructions: Option, #[serde( default, deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b8c00d8a38..2e7a384bb3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -180,7 +180,7 @@ Example with notification opt-out: - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. -- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, pass `includeStartupContext: false` to omit Codex's generated startup context, and optionally pass `initialItems` to seed V3 with complete role-bearing text messages at session creation. Version `"v1"` uses legacy Bidi `conversation.handoff.*`, `"v2"` uses the Realtime Voice API, and `"v3"` preserves V1 Codex Voice behavior while using Frameless Bidi `delegation.*`. For V3 automatic Codex text, `codexResponseHandoffMode` accepts `"thinking"` (the default; all output uses channel-less thinking appends), `"commentary"` (all output uses the commentary channel), or `"bemTags"` (the raw BEM envelope selects the API channel: BEM `analysis` and `commentary` use `commentary`, while BEM `final` and unparsable output use `speakable`). The BEM envelope remains in the appended text for the frontend model to interpret. V1 and V2 ignore this setting. V3 handoffs do not prepend the legacy `"Agent Final Message"` label. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a Bidi WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Conversation `version: "v2"` requests remain unsupported for WebRTC. +- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, optionally pass `model` and `version` to override configured realtime selection for this session only, pass `includeStartupContext: false` to omit Codex's generated startup context, and optionally pass `initialItems` to seed V3 with complete role-bearing text messages at session creation. Pass `realtimeStartInstructions` and `realtimeEndInstructions` to control the developer instructions given to the backing Codex model when this session starts and ends. Version `"v1"` uses legacy Bidi `conversation.handoff.*`, `"v2"` uses the Realtime Voice API, and `"v3"` preserves V1 Codex Voice behavior while using Frameless Bidi `delegation.*`. For V3 automatic Codex text, `codexResponseHandoffMode` accepts `"thinking"` (the default; all output uses channel-less thinking appends), `"commentary"` (all output uses the commentary channel), or `"bemTags"` (the raw BEM envelope selects the API channel: BEM `analysis` and `commentary` use `commentary`, while BEM `final` and unparsable output use `speakable`). The BEM envelope remains in the appended text for the frontend model to interpret. V1 and V2 ignore this setting. V3 handoffs do not prepend the legacy `"Agent Final Message"` label. Pass `clientManagedHandoffs: true` to disable automatic Codex response delivery so only the client's explicit append calls produce handoffs. Pass `codexResponsesAsItems: true` to send automatic Codex responses as realtime conversation items instead, and optionally pass `codexResponseItemPrefix` to prepend experiment instructions to those items. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a Bidi WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`. Conversation `version: "v2"` requests remain unsupported for WebRTC. - `thread/realtime/appendAudio` — append an input audio chunk to the active realtime session (experimental); returns `{}`. - `thread/realtime/appendText` — append text input to the active realtime session with a required `role` of `user`, `developer`, or `assistant` (experimental); returns `{}`. Older clients that omit `role` default to `user`. - `thread/realtime/appendSpeech` — append text that the realtime model should speak to the user (experimental); returns `{}`. @@ -983,6 +983,13 @@ Then send `offer.sdp` to app-server. Core uses `experimental_realtime_ws_backend Omit `prompt` to use Codex's default realtime backend prompt. Send `prompt: null` or `prompt: ""` when the session should start without that default backend prompt. +Pass `realtimeStartInstructions` to provide the developer instructions given to +the backing Codex model when the thread enters realtime mode, and +`realtimeEndInstructions` to provide its developer instructions when the session +ends. These instructions configure Codex, not the realtime frontend model. They +are emitted on realtime state transitions, rather than repeated on every turn. +Each instructions field is limited to 8,192 estimated tokens. Omitting either +field preserves Codex's existing default instructions. Clients may also pass `model` on `thread/realtime/start` to select a different realtime session configuration without changing thread or user config. Clients may pass `version` to select the realtime protocol for this session diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index f2ca6132f3..2ac576cac7 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1095,6 +1095,8 @@ impl TurnRequestProcessor { role: item.role, }) .collect(), + realtime_start_instructions: params.realtime_start_instructions, + realtime_end_instructions: params.realtime_end_instructions, prompt: params.prompt, realtime_session_id: params.realtime_session_id, transport: params.transport.map(|transport| match transport { diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index e099c514c6..c408ac0d6a 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -100,6 +100,8 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("hello".to_string())), realtime_session_id: None, transport: None, @@ -231,6 +233,8 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("hello".to_string())), realtime_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 06620b95bd..0d2c91bfc2 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -374,6 +374,8 @@ impl RealtimeE2eHarness { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { @@ -432,6 +434,8 @@ impl RealtimeE2eHarness { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -466,6 +470,8 @@ impl RealtimeE2eHarness { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -484,6 +490,25 @@ impl RealtimeE2eHarness { read_notification(&mut self.mcp, method).await } + async fn complete_turn(&mut self, text: &str) -> Result<()> { + let request_id = self + .mcp + .send_turn_start_request(TurnStartParams { + thread_id: self.thread_id.clone(), + input: vec![V2UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _: TurnStartResponse = + timeout(DEFAULT_TIMEOUT, self.mcp.read_response(request_id)).await??; + self.read_notification::("turn/completed") + .await?; + Ok(()) + } + /// Returns the nth JSON message app-server wrote to the fake Realtime API /// sideband websocket. async fn sideband_outbound_request(&self, request_index: usize) -> Value { @@ -715,6 +740,8 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: None, realtime_session_id: None, transport: None, @@ -994,6 +1021,8 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: Some(false), initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: None, realtime_session_id: None, transport: None, @@ -1089,6 +1118,8 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript output_modality: RealtimeOutputModality::Text, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: None, realtime_session_id: None, transport: None, @@ -1265,6 +1296,8 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1300,6 +1333,114 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { Ok(()) } +#[tokio::test] +async fn realtime_mode_uses_client_instructions_on_entry_and_exit() -> Result<()> { + skip_if_no_network!(Ok(())); + + let start_instructions = "Use [analysis], [final], and ::realtime-inline for voice output."; + let end_instructions = "Voice has ended. Resume the normal text output protocol."; + let mut harness = RealtimeE2eHarness::new( + RealtimeTestVersion::V1, + main_loop_responses(vec![ + create_final_assistant_message_sse_response("first voice response")?, + create_final_assistant_message_sse_response("second voice response")?, + create_final_assistant_message_sse_response("text response after voice")?, + ]), + realtime_sideband(vec![open_realtime_sideband_connection(vec![ + vec![session_updated("sess_client_controlled_mode")], + vec![], + vec![], + ])]), + ) + .await?; + + let start_request_id = harness + .mcp + .send_thread_realtime_start_request(ThreadRealtimeStartParams { + thread_id: harness.thread_id.clone(), + client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, + codex_responses_as_items: None, + codex_response_item_prefix: None, + codex_response_handoff_mode: None, + codex_response_handoff_channel_prefixes: None, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: None, + initial_items: None, + realtime_start_instructions: Some(start_instructions.to_string()), + realtime_end_instructions: Some(end_instructions.to_string()), + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: None, + version: None, + voice: None, + }) + .await?; + let _: ThreadRealtimeStartResponse = + timeout(DEFAULT_TIMEOUT, harness.mcp.read_response(start_request_id)).await??; + harness + .read_notification::("thread/realtime/started") + .await?; + + for input in ["first voice turn", "second voice turn"] { + harness.complete_turn(input).await?; + } + + let stop_request_id = harness + .mcp + .send_thread_realtime_stop_request(ThreadRealtimeStopParams { + thread_id: harness.thread_id.clone(), + }) + .await?; + let _: ThreadRealtimeStopResponse = + timeout(DEFAULT_TIMEOUT, harness.mcp.read_response(stop_request_id)).await??; + harness + .read_notification::("thread/realtime/closed") + .await?; + + harness.complete_turn("continue in text").await?; + + let requests = harness.main_loop_responses_requests().await?; + assert_eq!(requests.len(), 3); + assert!(response_request_contains_text( + &requests[0], + start_instructions + )); + assert!(!response_request_contains_text( + &requests[0], + end_instructions + )); + assert!(!response_request_contains_text( + &requests[1], + end_instructions + )); + assert!(response_request_contains_text( + &requests[2], + end_instructions + )); + assert!(response_request_contains_text( + &requests[2], + &format!("\n{end_instructions}\n"), + )); + + let start_message_count = requests[1]["input"] + .as_array() + .context("second voice Responses request should contain input")? + .iter() + .filter(|item| { + item["role"] == "developer" && response_request_contains_text(item, start_instructions) + }) + .count(); + assert!( + start_message_count <= 1, + "realtime entry instructions should not be injected again on subsequent turns" + ); + + harness.shutdown().await; + Ok(()) +} + #[tokio::test] async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { skip_if_no_network!(Ok(())); @@ -1362,6 +1503,8 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { @@ -2997,6 +3140,8 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ThreadRealtimeStartTransport::Webrtc { @@ -3060,6 +3205,8 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: None, initial_items: None, + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, diff --git a/codex-rs/core/src/context/realtime_end_instructions.rs b/codex-rs/core/src/context/realtime_end_instructions.rs index 2db5947618..872e4dd90c 100644 --- a/codex-rs/core/src/context/realtime_end_instructions.rs +++ b/codex-rs/core/src/context/realtime_end_instructions.rs @@ -5,13 +5,17 @@ use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct RealtimeEndInstructions { - reason: String, + instructions: Option, } impl RealtimeEndInstructions { - pub(crate) fn new(reason: impl Into) -> Self { + pub(crate) fn new() -> Self { + Self { instructions: None } + } + + pub(crate) fn with_instructions(instructions: impl Into) -> Self { Self { - reason: reason.into(), + instructions: Some(instructions.into()), } } } @@ -32,11 +36,11 @@ impl ContextualUserFragment for RealtimeEndInstructions { ) } - fn matches_text(text: &str) -> bool { - text.contains(END_INSTRUCTIONS.trim()) - } - fn body(&self) -> String { - format!("\n{}\n\nReason: {}\n", END_INSTRUCTIONS.trim(), self.reason) + let instructions = self + .instructions + .as_deref() + .unwrap_or_else(|| END_INSTRUCTIONS.trim()); + format!("\n{instructions}\n") } } diff --git a/codex-rs/core/src/context/world_state/realtime.rs b/codex-rs/core/src/context/world_state/realtime.rs index 383bb4b2cd..7911854c08 100644 --- a/codex-rs/core/src/context/world_state/realtime.rs +++ b/codex-rs/core/src/context/world_state/realtime.rs @@ -12,6 +12,7 @@ use serde::Serialize; pub(crate) struct RealtimeState { snapshot: RealtimeSnapshot, start_instructions: Option, + end_instructions: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] @@ -20,10 +21,15 @@ pub(crate) struct RealtimeSnapshot { } impl RealtimeState { - pub(crate) fn new(active: bool, start_instructions: Option<&str>) -> Self { + pub(crate) fn new( + active: bool, + start_instructions: Option<&str>, + end_instructions: Option<&str>, + ) -> Self { Self { snapshot: RealtimeSnapshot { active }, start_instructions: start_instructions.map(str::to_string), + end_instructions: end_instructions.map(str::to_string), } } @@ -37,7 +43,12 @@ impl RealtimeState { fn render_transition(&self, previous_active: bool) -> Option> { match (previous_active, self.snapshot.active) { (false, true) => Some(self.render_start()), - (true, false) => Some(Box::new(RealtimeEndInstructions::new("inactive"))), + (true, false) => Some(match self.end_instructions.as_deref() { + Some(instructions) => { + Box::new(RealtimeEndInstructions::with_instructions(instructions)) + } + None => Box::new(RealtimeEndInstructions::new()), + }), (false, false) | (true, true) => None, } } @@ -52,9 +63,7 @@ impl WorldStateSection for RealtimeState { } fn matches_legacy_fragment(role: &str, text: &str) -> bool { - role == "developer" - && RealtimeStartInstructions::matches_text(text) - && !RealtimeEndInstructions::matches_text(text) + role == "developer" && RealtimeStartInstructions::matches_text(text) } fn has_retained_fragment_matcher() -> bool { diff --git a/codex-rs/core/src/context/world_state/realtime_tests.rs b/codex-rs/core/src/context/world_state/realtime_tests.rs index 06e3d8e4ce..f172e19bf2 100644 --- a/codex-rs/core/src/context/world_state/realtime_tests.rs +++ b/codex-rs/core/src/context/world_state/realtime_tests.rs @@ -2,7 +2,7 @@ use super::super::test_support::render_section_cases; use super::*; fn state(active: bool, start_instructions: Option<&str>) -> RealtimeState { - RealtimeState::new(active, start_instructions) + RealtimeState::new(active, start_instructions, /*end_instructions*/ None) } #[test] @@ -34,10 +34,10 @@ fn snapshots() { } #[test] -fn retained_fragment_matcher_only_matches_starts() { +fn retained_fragment_matcher_matches_realtime_fragments() { let start = RealtimeStartWithInstructions::new("custom instructions").render(); - let end = RealtimeEndInstructions::new("inactive").render(); + let end = RealtimeEndInstructions::new().render(); assert!(RealtimeState::matches_legacy_fragment("developer", &start)); - assert!(!RealtimeState::matches_legacy_fragment("developer", &end)); + assert!(RealtimeState::matches_legacy_fragment("developer", &end)); } diff --git a/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__realtime__tests__snapshots.snap b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__realtime__tests__snapshots.snap index f5f4345b9f..3507c2d020 100644 --- a/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__realtime__tests__snapshots.snap +++ b/codex-rs/core/src/context/world_state/snapshots/codex_core__context__world_state__realtime__tests__snapshots.snap @@ -50,8 +50,6 @@ None Realtime conversation ended. Subsequent user input will return to typed text rather than transcript-style text. Do not assume recognition errors or missing punctuation once realtime has ended. Resume normal chat behavior. - -Reason: inactive Unknown -> {"active":true} (role - developer) diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 740cd6f98c..b56da61bbe 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -92,6 +92,7 @@ const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_300; const REALTIME_ASSISTANT_OUTPUT_TOKEN_BUDGET: usize = 1_000; const REALTIME_INITIAL_ITEMS_MAX_COUNT: usize = 128; const REALTIME_INITIAL_ITEMS_MAX_TOKENS: usize = 8_192; +const REALTIME_MODE_INSTRUCTIONS_MAX_TOKENS: usize = 8_192; const HANDOFF_STREAM_FLUSH_INTERVAL: Duration = Duration::from_millis(200); const HANDOFF_STREAM_TRUNCATION_MARKER: &str = "\n…output truncated…\n"; const AGENT_FINAL_MESSAGE_PREFIX: &str = "\"Agent Final Message\":\n\n"; @@ -122,6 +123,13 @@ enum RealtimeFanoutTaskStop { pub(crate) struct RealtimeConversationManager { state: Mutex>, + mode_instructions: Mutex>, +} + +#[derive(Clone, Debug)] +pub(crate) struct RealtimeModeInstructions { + pub(crate) start: Option, + pub(crate) end: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -484,9 +492,14 @@ impl RealtimeConversationManager { pub(crate) fn new() -> Self { Self { state: Mutex::new(None), + mode_instructions: Mutex::new(None), } } + pub(crate) async fn mode_instructions(&self) -> Option { + self.mode_instructions.lock().await.clone() + } + pub(crate) async fn running_state(&self) -> Option<()> { let state = self.state.lock().await; state @@ -504,7 +517,11 @@ impl RealtimeConversationManager { ) } - async fn start(&self, start: RealtimeStart) -> CodexResult { + async fn start( + &self, + start: RealtimeStart, + mode_instructions: RealtimeModeInstructions, + ) -> CodexResult { let previous_state = { let mut guard = self.state.lock().await; guard.take() @@ -513,7 +530,9 @@ impl RealtimeConversationManager { stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await; } - self.start_inner(start).await + let output = self.start_inner(start).await?; + *self.mode_instructions.lock().await = Some(mode_instructions); + Ok(output) } async fn start_inner(&self, start: RealtimeStart) -> CodexResult { @@ -1104,6 +1123,8 @@ struct PreparedRealtimeConversationStart { codex_response_item_prefix: Option, codex_response_handoff_mode: CodexResponseHandoffMode, codex_response_handoff_channel_prefixes: Option>>, + realtime_start_instructions: Option, + realtime_end_instructions: Option, realtime_call_api_provider: Option, requested_realtime_session_id: Option, version: RealtimeWsVersion, @@ -1198,6 +1219,8 @@ async fn prepare_realtime_start( codex_response_item_prefix: params.codex_response_item_prefix, codex_response_handoff_mode: params.codex_response_handoff_mode, codex_response_handoff_channel_prefixes: params.codex_response_handoff_channel_prefixes, + realtime_start_instructions: params.realtime_start_instructions, + realtime_end_instructions: params.realtime_end_instructions, realtime_call_api_provider, requested_realtime_session_id, version, @@ -1229,6 +1252,25 @@ pub(crate) async fn build_realtime_session_config( version: RealtimeWsVersion, configured_voice: ConfiguredRealtimeVoice, ) -> CodexResult { + for (name, instructions) in [ + ( + "realtime start instructions", + params.realtime_start_instructions.as_deref(), + ), + ( + "realtime end instructions", + params.realtime_end_instructions.as_deref(), + ), + ] { + if instructions.is_some_and(|instructions| { + approx_token_count(instructions) > REALTIME_MODE_INSTRUCTIONS_MAX_TOKENS + }) { + return Err(CodexErr::InvalidRequest(format!( + "{name} must not exceed {REALTIME_MODE_INSTRUCTIONS_MAX_TOKENS} estimated tokens" + ))); + } + } + let config = sess.get_config().await; let prompt = prepare_realtime_backend_prompt( params.prompt.clone(), @@ -1398,6 +1440,8 @@ async fn handle_start_inner( codex_response_item_prefix, codex_response_handoff_mode, codex_response_handoff_channel_prefixes, + realtime_start_instructions, + realtime_end_instructions, realtime_call_api_provider, requested_realtime_session_id, version, @@ -1409,6 +1453,10 @@ async fn handle_start_inner( ConversationStartTransport::Websocket => None, ConversationStartTransport::Webrtc { sdp } => Some(sdp), }; + let mode_instructions = RealtimeModeInstructions { + start: realtime_start_instructions, + end: realtime_end_instructions, + }; let start = RealtimeStart { api_provider, realtime_sideband_base_url, @@ -1424,7 +1472,7 @@ async fn handle_start_inner( model_client: sess.services.model_client.clone(), sdp, }; - let start_output = sess.conversation.start(start).await?; + let start_output = sess.conversation.start(start, mode_instructions).await?; info!("realtime conversation started"); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 70ddf6e8f7..302e9bcc16 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -8808,7 +8808,7 @@ async fn record_context_updates_emits_realtime_end_when_session_stops_being_live assert!( developer_texts .iter() - .any(|text| text.contains("Reason: inactive")), + .any(|text| text.contains("")), "expected a realtime end update, got {developer_texts:?}" ); } diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 3686f778c3..c96c2d7cc9 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -96,12 +96,19 @@ impl Session { { world_state.add_section(ContextWindowGuidanceState::new(guidance)); } + let realtime_mode_instructions = self.conversation.mode_instructions().await; world_state.add_section(RealtimeState::new( turn_context.realtime_active, - turn_context - .config - .experimental_realtime_start_instructions - .as_deref(), + realtime_mode_instructions + .as_ref() + .and_then(|instructions| instructions.start.as_deref()) + .or(turn_context + .config + .experimental_realtime_start_instructions + .as_deref()), + realtime_mode_instructions + .as_ref() + .and_then(|instructions| instructions.end.as_deref()), )); world_state.add_section(AgentsMdState::new(step_context.loaded_agents_md.as_deref())); if turn_context.config.include_permissions_instructions { diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index d4ad3327d7..c9e64f78a8 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -253,6 +253,8 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result< output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -300,10 +302,6 @@ fn assert_request_contains_realtime_start(request: &responses::ResponsesRequest) body.contains(""), "expected request to restate realtime instructions" ); - assert!( - !body.contains("Reason: inactive"), - "expected request to use realtime start instructions" - ); } fn assert_request_contains_custom_realtime_start( @@ -332,7 +330,7 @@ fn assert_request_contains_realtime_end(request: &responses::ResponsesRequest) { "expected request to restate realtime instructions" ); assert!( - body.contains("Reason: inactive"), + body.contains("Realtime conversation ended."), "expected request to use realtime end instructions" ); } diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index bd37b35389..c9f991e63a 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -296,6 +296,8 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -446,6 +448,8 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -537,6 +541,8 @@ async fn conversation_webrtc_frameless_chatgpt_sends_codex_headers_to_backend() output_modality: RealtimeOutputModality::Audio, include_startup_context: false, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -661,6 +667,8 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -852,6 +860,8 @@ async fn conversation_webrtc_start_uses_avas_query() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -954,6 +964,8 @@ async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result< output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -1018,6 +1030,8 @@ async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<() output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -1092,6 +1106,8 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -1203,6 +1219,8 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join( output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -1307,6 +1325,8 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: Some(ConversationStartTransport::Webrtc { @@ -1404,6 +1424,8 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1499,6 +1521,8 @@ async fn assert_transport_close_tail_flush( output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1619,6 +1643,8 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1674,6 +1700,8 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1777,6 +1805,8 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("old".to_string())), realtime_session_id: Some("conv_old".to_string()), transport: None, @@ -1811,6 +1841,8 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("new".to_string())), realtime_session_id: Some("conv_new".to_string()), transport: None, @@ -1916,6 +1948,8 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -1989,6 +2023,8 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: None, realtime_session_id: None, transport: None, @@ -2070,6 +2106,8 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt, realtime_session_id: None, transport: None, @@ -2144,6 +2182,8 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2210,6 +2250,8 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2264,6 +2306,8 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2319,6 +2363,8 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("prompt from op".to_string())), realtime_session_id: None, transport: None, @@ -2400,6 +2446,8 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("prompt from op".to_string())), realtime_session_id: None, transport: None, @@ -2475,6 +2523,8 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("prompt from op".to_string())), realtime_session_id: None, transport: None, @@ -2543,6 +2593,8 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2666,6 +2718,8 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2782,6 +2836,8 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2850,6 +2906,8 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -2939,6 +2997,8 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3044,6 +3104,8 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3151,6 +3213,8 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3325,6 +3389,8 @@ async fn conversation_flushes_assistant_deltas_every_200ms_for_v3_handoff() -> R output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3495,6 +3561,8 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3655,6 +3723,8 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3773,6 +3843,8 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -3884,6 +3956,8 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -4014,6 +4088,8 @@ async fn conversation_close_routes_only_remaining_transcript_tail_once() -> Resu output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -4105,6 +4181,8 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -4236,6 +4314,8 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -4397,6 +4477,8 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -4547,6 +4629,8 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, @@ -4708,6 +4792,8 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() output_modality: RealtimeOutputModality::Audio, include_startup_context: true, initial_items: Vec::new(), + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, diff --git a/codex-rs/core/tests/suite/realtime_initial_items.rs b/codex-rs/core/tests/suite/realtime_initial_items.rs index 53f9ff7fc3..4fc037eae0 100644 --- a/codex-rs/core/tests/suite/realtime_initial_items.rs +++ b/codex-rs/core/tests/suite/realtime_initial_items.rs @@ -150,6 +150,32 @@ async fn initial_items_enforce_aggregate_token_limit() -> Result<()> { .await } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn realtime_start_instructions_enforce_token_limit() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut params = start_params(RealtimeConversationVersion::V3); + params.realtime_start_instructions = Some("x".repeat(8_192 * 4 + 1)); + assert_start_error( + params, + "realtime start instructions must not exceed 8192 estimated tokens", + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn realtime_end_instructions_enforce_token_limit() -> Result<()> { + skip_if_no_network!(Ok(())); + + let mut params = start_params(RealtimeConversationVersion::V3); + params.realtime_end_instructions = Some("x".repeat(8_192 * 4 + 1)); + assert_start_error( + params, + "realtime end instructions must not exceed 8192 estimated tokens", + ) + .await +} + async fn assert_start_error(params: ConversationStartParams, expected_error: &str) -> Result<()> { let api_server = start_mock_server().await; let test = test_codex().build_with_auto_env(&api_server).await?; @@ -195,6 +221,8 @@ fn start_params(version: RealtimeConversationVersion) -> ConversationStartParams role: ConversationTextRole::Assistant, }, ], + realtime_start_instructions: None, + realtime_end_instructions: None, prompt: Some(Some("backend prompt".to_string())), realtime_session_id: None, transport: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c17ada9342..724630c017 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -220,6 +220,10 @@ pub struct ConversationStartParams { pub include_startup_context: bool, /// Complete role-bearing text items to include in the initial realtime session history. pub initial_items: Vec, + /// Developer instructions given to Codex when this realtime session starts. + pub realtime_start_instructions: Option, + /// Developer instructions given to Codex when this realtime session ends. + pub realtime_end_instructions: Option, pub prompt: Option>, pub realtime_session_id: Option, pub transport: Option,