mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
Allow custom Codex instructions for realtime transitions (#36408)
## What changed - Add optional `realtimeStartInstructions` and `realtimeEndInstructions` fields to `thread/realtime/start`. - Apply the instructions when Codex enters and leaves realtime mode, while preserving the existing defaults when either field is omitted. - Limit each field to 8,192 estimated tokens. ## Testing - Cover custom entry and exit instructions across realtime state transitions. - Cover token-limit validation for both fields. GitOrigin-RevId: b9cfa1ff22e084d8a6b2ed56bd27d95ffdd6b6bd
This commit is contained in:
BIN
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -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<Vec<ThreadRealtimeInitialItem>>,
|
||||
/// Developer instructions given to the backing Codex model when this realtime session starts.
|
||||
#[ts(optional = nullable)]
|
||||
pub realtime_start_instructions: Option<String>,
|
||||
/// Developer instructions given to the backing Codex model when this realtime session ends.
|
||||
#[ts(optional = nullable)]
|
||||
pub realtime_end_instructions: Option<String>,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<TurnCompletedNotification>("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::<ThreadRealtimeStartedNotification>("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::<ThreadRealtimeClosedNotification>("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!("<realtime_conversation>\n{end_instructions}\n</realtime_conversation>"),
|
||||
));
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl RealtimeEndInstructions {
|
||||
pub(crate) fn new(reason: impl Into<String>) -> Self {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self { instructions: None }
|
||||
}
|
||||
|
||||
pub(crate) fn with_instructions(instructions: impl Into<String>) -> 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ use serde::Serialize;
|
||||
pub(crate) struct RealtimeState {
|
||||
snapshot: RealtimeSnapshot,
|
||||
start_instructions: Option<String>,
|
||||
end_instructions: Option<String>,
|
||||
}
|
||||
|
||||
#[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<Box<dyn ContextualUserFragment>> {
|
||||
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 {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
-2
@@ -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
|
||||
</realtime_conversation>
|
||||
|
||||
Unknown -> {"active":true} (role - developer)
|
||||
|
||||
@@ -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<Option<ConversationState>>,
|
||||
mode_instructions: Mutex<Option<RealtimeModeInstructions>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct RealtimeModeInstructions {
|
||||
pub(crate) start: Option<String>,
|
||||
pub(crate) end: Option<String>,
|
||||
}
|
||||
|
||||
#[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<RealtimeModeInstructions> {
|
||||
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<RealtimeStartOutput> {
|
||||
async fn start(
|
||||
&self,
|
||||
start: RealtimeStart,
|
||||
mode_instructions: RealtimeModeInstructions,
|
||||
) -> CodexResult<RealtimeStartOutput> {
|
||||
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<RealtimeStartOutput> {
|
||||
@@ -1104,6 +1123,8 @@ struct PreparedRealtimeConversationStart {
|
||||
codex_response_item_prefix: Option<String>,
|
||||
codex_response_handoff_mode: CodexResponseHandoffMode,
|
||||
codex_response_handoff_channel_prefixes: Option<BTreeMap<String, Vec<String>>>,
|
||||
realtime_start_instructions: Option<String>,
|
||||
realtime_end_instructions: Option<String>,
|
||||
realtime_call_api_provider: Option<ApiProvider>,
|
||||
requested_realtime_session_id: Option<String>,
|
||||
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<RealtimeSessionConfig> {
|
||||
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");
|
||||
|
||||
|
||||
@@ -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("<realtime_conversation>")),
|
||||
"expected a realtime end update, got {developer_texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("<realtime_conversation>"),
|
||||
"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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ConversationTextParams>,
|
||||
/// Developer instructions given to Codex when this realtime session starts.
|
||||
pub realtime_start_instructions: Option<String>,
|
||||
/// Developer instructions given to Codex when this realtime session ends.
|
||||
pub realtime_end_instructions: Option<String>,
|
||||
pub prompt: Option<Option<String>>,
|
||||
pub realtime_session_id: Option<String>,
|
||||
pub transport: Option<ConversationStartTransport>,
|
||||
|
||||
Reference in New Issue
Block a user