mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Add realtime speech append control (#27917)
## Why Realtime voice harness tuning needs app-side control over what backend Codex text is spoken. Backend orchestrator text is written for a reading UI, so automatically speaking every preamble, progress update, or final assistant message can make the realtime voice model too chatty. For experimentation, clients need two simple controls: keep app/client text-item injection on the existing item-create path, and add an explicit speakable path that app code can call only when it wants realtime to speak. Automatic Codex output also needs an opt-in way to switch from the protocol's default speakable path to regular realtime items, with a caller-provided prefix so prompt wording can be tuned outside core. The default remains unchanged: if a client omits the new start fields and never calls `appendSpeech`, automatic backend output continues down the existing speakable path for the selected realtime protocol. ## What Changed - Adds experimental `thread/realtime/appendSpeech` for app-provided speakable text. - Keeps existing `thread/realtime/appendText` as the item-create API for app-provided realtime text items. - Adds `codexResponsesAsItems` / `codex_responses_as_items` on `thread/realtime/start` to send automatic Codex responses with `conversation.item.create` instead of the protocol's default speakable output path. - Adds `codexResponseItemPrefix` / `codex_response_item_prefix` so clients can prepend experiment instructions to those automatic Codex response items. - Keeps literal `conversation.handoff.append` routing scoped to the v1 speakable path; v2 default speech uses its item/function-output plus `response.create` behavior. - Removes the earlier public silent-context API and hardcoded silent-context prefix. - Updates realtime tests to cover default automatic speakable behavior, opt-in automatic item-create behavior, and explicit `appendSpeech` behavior. ## Validation - `cargo check -p codex-core -p codex-app-server -p codex-api` - `just test -p codex-app-server realtime_conversation` - `just test -p codex-core realtime_conversation` (50/51 passed in the filtered parallel run; the lone failure passed when rerun in isolation) - `just test -p codex-core conversation_mirrors_assistant_message_text_to_realtime_handoff` - `just test -p codex-api e2e_connect_and_exchange_events_against_mock_ws_server` - `just fix -p codex-core` - `just fix -p codex-app-server` - `cargo build -p codex-cli`
This commit is contained in:
committed by
GitHub
Unverified
parent
9728992fab
commit
1d8ff89aa3
@@ -165,9 +165,10 @@ 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, and optionally pass `model` and `version` to override configured realtime selection for this session only. Returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`.
|
||||
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); pass `outputModality: "text"` or `outputModality: "audio"` to choose model output, and optionally pass `model` and `version` to override configured realtime selection for this session only. By default, automatic Codex text follows the protocol's speakable output path. 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 WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`.
|
||||
- `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` or `developer` (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 `{}`.
|
||||
- `thread/realtime/stop` — stop the active realtime session for the thread (experimental); returns `{}`.
|
||||
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
|
||||
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
|
||||
@@ -878,6 +879,15 @@ Omit `prompt` to use Codex's default realtime backend prompt. Send `prompt: null
|
||||
`prompt: ""` when the session should start without that default backend prompt.
|
||||
Clients may also pass `model` and `version` on `thread/realtime/start` to select a
|
||||
different realtime session configuration without changing thread or user config.
|
||||
Pass `codexResponsesAsItems: true` to inject automatic Codex responses with
|
||||
`conversation.item.create` instead of the protocol's default speakable output
|
||||
path. When using that mode, `codexResponseItemPrefix` can prepend short
|
||||
experiment instructions to each automatic Codex response item. Omit
|
||||
`codexResponsesAsItems`, or pass `false`, to preserve the default speakable
|
||||
behavior. Call
|
||||
`thread/realtime/appendText` to append app-provided realtime text items, or
|
||||
`thread/realtime/appendSpeech` when the app decides a realtime update should be
|
||||
spoken.
|
||||
|
||||
```javascript
|
||||
await pc.setRemoteDescription({
|
||||
|
||||
@@ -1317,6 +1317,11 @@ impl MessageProcessor {
|
||||
.thread_realtime_append_text(&request_id, params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::ThreadRealtimeAppendSpeech { params, .. } => {
|
||||
self.turn_processor
|
||||
.thread_realtime_append_speech(&request_id, params)
|
||||
.await
|
||||
}
|
||||
ClientRequest::ThreadRealtimeStop { params, .. } => {
|
||||
self.turn_processor
|
||||
.thread_realtime_stop(&request_id, params)
|
||||
|
||||
@@ -225,6 +225,8 @@ use codex_app_server_protocol::ThreadReadParams;
|
||||
use codex_app_server_protocol::ThreadReadResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendSpeechParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendSpeechResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendTextParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendTextResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeListVoicesResponse;
|
||||
@@ -397,6 +399,7 @@ use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::protocol::AgentStatus;
|
||||
use codex_protocol::protocol::ConversationAudioParams;
|
||||
use codex_protocol::protocol::ConversationSpeechParams;
|
||||
use codex_protocol::protocol::ConversationStartParams;
|
||||
use codex_protocol::protocol::ConversationStartTransport;
|
||||
use codex_protocol::protocol::ConversationTextParams;
|
||||
|
||||
@@ -182,6 +182,16 @@ impl TurnRequestProcessor {
|
||||
.map(|response| response.map(Into::into))
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_realtime_append_speech(
|
||||
&self,
|
||||
request_id: &ConnectionRequestId,
|
||||
params: ThreadRealtimeAppendSpeechParams,
|
||||
) -> Result<Option<ClientResponsePayload>, JSONRPCErrorError> {
|
||||
self.thread_realtime_append_speech_inner(request_id, params)
|
||||
.await
|
||||
.map(|response| response.map(Into::into))
|
||||
}
|
||||
|
||||
pub(crate) async fn thread_realtime_stop(
|
||||
&self,
|
||||
request_id: &ConnectionRequestId,
|
||||
@@ -942,6 +952,8 @@ impl TurnRequestProcessor {
|
||||
thread.as_ref(),
|
||||
Op::RealtimeConversationStart(ConversationStartParams {
|
||||
architecture: params.architecture,
|
||||
codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false),
|
||||
codex_response_item_prefix: params.codex_response_item_prefix,
|
||||
model: params.model,
|
||||
output_modality: params.output_modality,
|
||||
prompt: params.prompt,
|
||||
@@ -1018,6 +1030,31 @@ impl TurnRequestProcessor {
|
||||
Ok(Some(ThreadRealtimeAppendTextResponse::default()))
|
||||
}
|
||||
|
||||
async fn thread_realtime_append_speech_inner(
|
||||
&self,
|
||||
request_id: &ConnectionRequestId,
|
||||
params: ThreadRealtimeAppendSpeechParams,
|
||||
) -> Result<Option<ThreadRealtimeAppendSpeechResponse>, JSONRPCErrorError> {
|
||||
let Some((_, thread)) = self
|
||||
.prepare_realtime_conversation_thread(request_id, ¶ms.thread_id)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.submit_core_op(
|
||||
request_id,
|
||||
thread.as_ref(),
|
||||
Op::RealtimeConversationSpeech(ConversationSpeechParams { text: params.text }),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
internal_error(format!(
|
||||
"failed to append realtime conversation speech: {err}"
|
||||
))
|
||||
})?;
|
||||
Ok(Some(ThreadRealtimeAppendSpeechResponse::default()))
|
||||
}
|
||||
|
||||
async fn thread_realtime_stop_inner(
|
||||
&self,
|
||||
request_id: &ConnectionRequestId,
|
||||
|
||||
@@ -90,6 +90,7 @@ use codex_app_server_protocol::ThreadMemoryModeSetParams;
|
||||
use codex_app_server_protocol::ThreadMetadataUpdateParams;
|
||||
use codex_app_server_protocol::ThreadReadParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendSpeechParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendTextParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeListVoicesParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeStartParams;
|
||||
@@ -1036,6 +1037,16 @@ impl TestAppServer {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a `thread/realtime/appendSpeech` JSON-RPC request (v2).
|
||||
pub async fn send_thread_realtime_append_speech_request(
|
||||
&mut self,
|
||||
params: ThreadRealtimeAppendSpeechParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("thread/realtime/appendSpeech", params)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send a `thread/realtime/stop` JSON-RPC request (v2).
|
||||
pub async fn send_thread_realtime_stop_request(
|
||||
&mut self,
|
||||
|
||||
@@ -80,6 +80,8 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
|
||||
let request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
architecture: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
thread_id: "thr_123".to_string(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
@@ -189,6 +191,8 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<
|
||||
let request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
architecture: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
thread_id: "thr_123".to_string(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
|
||||
@@ -15,6 +15,8 @@ use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendSpeechParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendSpeechResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendTextParams;
|
||||
use codex_app_server_protocol::ThreadRealtimeAppendTextResponse;
|
||||
use codex_app_server_protocol::ThreadRealtimeAudioChunk;
|
||||
@@ -82,6 +84,8 @@ const V2_STEERING_ACKNOWLEDGEMENT: &str =
|
||||
"This was sent to steer the previous background agent task.";
|
||||
const V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT: &str =
|
||||
"Background agent finished. Use the preceding [BACKEND] messages as the result.";
|
||||
const RESPONSE_ITEM_PREFIX: &str =
|
||||
"Use the following context to inform future responses, but do not speak it to the user.";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum StartupContextConfig<'a> {
|
||||
@@ -309,6 +313,28 @@ impl RealtimeE2eHarness {
|
||||
}
|
||||
|
||||
async fn start_webrtc_realtime(&mut self, offer_sdp: &str) -> Result<StartedWebrtcRealtime> {
|
||||
self.start_webrtc_realtime_with_codex_responses_as_items(
|
||||
offer_sdp, /*codex_responses_as_items*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn start_webrtc_realtime_with_codex_response_items(
|
||||
&mut self,
|
||||
offer_sdp: &str,
|
||||
) -> Result<StartedWebrtcRealtime> {
|
||||
self.start_webrtc_realtime_with_codex_responses_as_items(
|
||||
offer_sdp,
|
||||
/*codex_responses_as_items*/ Some(true),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn start_webrtc_realtime_with_codex_responses_as_items(
|
||||
&mut self,
|
||||
offer_sdp: &str,
|
||||
codex_responses_as_items: Option<bool>,
|
||||
) -> Result<StartedWebrtcRealtime> {
|
||||
// Starts realtime through the public JSON-RPC method, then waits for the same client-visible
|
||||
// notifications a desktop app needs: started first, SDP answer second.
|
||||
let start_request_id = self
|
||||
@@ -316,6 +342,10 @@ impl RealtimeE2eHarness {
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
architecture: None,
|
||||
thread_id: self.thread_id.clone(),
|
||||
codex_response_item_prefix: codex_responses_as_items
|
||||
.unwrap_or(false)
|
||||
.then(|| RESPONSE_ITEM_PREFIX.to_string()),
|
||||
codex_responses_as_items,
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
@@ -407,6 +437,24 @@ impl RealtimeE2eHarness {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn append_speech(&mut self, thread_id: String, text: &str) -> Result<()> {
|
||||
let request_id = self
|
||||
.mcp
|
||||
.send_thread_realtime_append_speech_request(ThreadRealtimeAppendSpeechParams {
|
||||
thread_id,
|
||||
text: text.to_string(),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
self.mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: ThreadRealtimeAppendSpeechResponse = to_response(response)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn main_loop_responses_requests(&self) -> Result<Vec<Value>> {
|
||||
responses_requests(&self.main_loop_responses_server).await
|
||||
}
|
||||
@@ -564,6 +612,8 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
architecture: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
thread_id: thread_start.thread.id.clone(),
|
||||
model: Some("realtime-treatment-model".to_string()),
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
@@ -840,6 +890,8 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
architecture: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
thread_id: thread_start.thread.id.clone(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Text,
|
||||
@@ -1017,6 +1069,8 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
architecture: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
thread_id: thread_start.thread.id.clone(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
@@ -1117,6 +1171,8 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
architecture: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
thread_id: thread_id.clone(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
@@ -1291,7 +1347,64 @@ async fn webrtc_v1_start_posts_offer_returns_sdp_and_joins_sideband() -> Result<
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()> {
|
||||
async fn webrtc_v1_default_automatic_output_uses_handoff_append() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let mut harness = RealtimeE2eHarness::new(
|
||||
RealtimeTestVersion::V1,
|
||||
main_loop_responses(vec![create_final_assistant_message_sse_response(
|
||||
"legacy automatic speech",
|
||||
)?]),
|
||||
realtime_sideband(vec![realtime_sideband_connection(vec![
|
||||
vec![session_updated("sess_v1_default_handoff")],
|
||||
vec![],
|
||||
vec![],
|
||||
])]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let started = harness.start_webrtc_realtime("v=offer\r\n").await?;
|
||||
assert_eq!(started.started.version, RealtimeConversationVersion::V1);
|
||||
assert_v1_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?;
|
||||
|
||||
let turn_request_id = harness
|
||||
.mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: harness.thread_id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "say the default output".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
harness
|
||||
.mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: TurnStartResponse = to_response(turn_response)?;
|
||||
let _ = harness
|
||||
.read_notification::<TurnCompletedNotification>("turn/completed")
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
harness.sideband_outbound_request(/*request_index*/ 1).await,
|
||||
json!({
|
||||
"type": "conversation.handoff.append",
|
||||
"handoff_id": "codex",
|
||||
"output_text": "legacy automatic speech",
|
||||
})
|
||||
);
|
||||
|
||||
harness.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webrtc_v1_handoff_request_delegates_context_and_manual_append_speaks() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
// Phase 1: script one v1 handoff request on the sideband and one delegated Responses turn.
|
||||
@@ -1323,11 +1436,14 @@ async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()>
|
||||
}),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
])]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let started = harness.start_webrtc_realtime("v=offer\r\n").await?;
|
||||
let started = harness
|
||||
.start_webrtc_realtime_with_codex_response_items("v=offer\r\n")
|
||||
.await?;
|
||||
assert_eq!(started.started.version, RealtimeConversationVersion::V1);
|
||||
assert_call_create_multipart(
|
||||
harness.call_capture.single_request(),
|
||||
@@ -1346,8 +1462,8 @@ async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()>
|
||||
.await?;
|
||||
assert_eq!(turn_completed.thread_id, harness.thread_id);
|
||||
|
||||
// Phase 3: assert the delegated prompt went to Responses, then the v1 handoff append went back
|
||||
// over the existing sideband connection.
|
||||
// Phase 3: assert the delegated prompt went to Responses, then the automatic v1 output went
|
||||
// back over the existing sideband connection as a conversation item.
|
||||
let requests = harness.main_loop_responses_requests().await?;
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(
|
||||
@@ -1358,13 +1474,32 @@ async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()>
|
||||
"delegated Responses request should contain realtime delegation envelope: {}",
|
||||
requests[0]
|
||||
);
|
||||
let handoff_append = harness.sideband_outbound_request(/*request_index*/ 1).await;
|
||||
let context_update = harness.sideband_outbound_request(/*request_index*/ 1).await;
|
||||
assert_eq!(
|
||||
handoff_append,
|
||||
context_update,
|
||||
json!({
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": format!("{RESPONSE_ITEM_PREFIX}\n\ndelegated from v1")
|
||||
}]
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
harness
|
||||
.append_speech(harness.thread_id.clone(), "manual spoken v1 update")
|
||||
.await?;
|
||||
let spoken_append = harness.sideband_outbound_request(/*request_index*/ 2).await;
|
||||
assert_eq!(
|
||||
spoken_append,
|
||||
json!({
|
||||
"type": "conversation.handoff.append",
|
||||
"handoff_id": "handoff_v1",
|
||||
"output_text": "\"Agent Final Message\":\n\ndelegated from v1",
|
||||
"handoff_id": "codex",
|
||||
"output_text": "manual spoken v1 update",
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1373,131 +1508,234 @@ async fn webrtc_v1_handoff_request_delegates_and_appends_result() -> Result<()>
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webrtc_assistant_output_without_handoff_reaches_realtime() -> Result<()> {
|
||||
async fn realtime_automatic_standalone_output_is_item_and_append_speaks() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let mut harness = RealtimeE2eHarness::new(
|
||||
RealtimeTestVersion::V2,
|
||||
main_loop_responses(vec![create_final_assistant_message_sse_response(
|
||||
"automatic output",
|
||||
)?]),
|
||||
realtime_sideband(vec![realtime_sideband_connection(vec![
|
||||
vec![session_updated("sess_manual_handoff")],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
])]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let started = harness
|
||||
.start_webrtc_realtime_with_codex_response_items("v=offer\r\n")
|
||||
.await?;
|
||||
assert_eq!(started.started.version, RealtimeConversationVersion::V2);
|
||||
assert_eq!(
|
||||
harness.sideband_outbound_request(/*request_index*/ 0).await["type"].as_str(),
|
||||
Some("session.update")
|
||||
);
|
||||
|
||||
let turn_request_id = harness
|
||||
.mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: harness.thread_id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "do something quietly".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let turn_response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
harness
|
||||
.mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: TurnStartResponse = to_response(turn_response)?;
|
||||
let _ = harness
|
||||
.read_notification::<TurnCompletedNotification>("turn/completed")
|
||||
.await?;
|
||||
|
||||
assert_v2_backend_item_update(
|
||||
&harness.sideband_outbound_request(/*request_index*/ 1).await,
|
||||
"automatic output",
|
||||
);
|
||||
let automatic_response_create = timeout(
|
||||
Duration::from_millis(200),
|
||||
harness
|
||||
.realtime_server
|
||||
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 2),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
automatic_response_create.is_err(),
|
||||
"automatic item should not request a realtime response"
|
||||
);
|
||||
|
||||
harness
|
||||
.append_speech(harness.thread_id.clone(), "manual voice update")
|
||||
.await?;
|
||||
assert_v2_progress_update(
|
||||
&harness.sideband_outbound_request(/*request_index*/ 2).await,
|
||||
"manual voice update",
|
||||
);
|
||||
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 3).await);
|
||||
|
||||
harness.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn realtime_automatic_handoff_output_is_item_and_append_speaks() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let mut harness = RealtimeE2eHarness::new(
|
||||
RealtimeTestVersion::V2,
|
||||
main_loop_responses(vec![create_final_assistant_message_sse_response(
|
||||
"automatic final response",
|
||||
)?]),
|
||||
realtime_sideband(vec![realtime_sideband_connection(vec![
|
||||
vec![
|
||||
session_updated("sess_manual_update"),
|
||||
v2_background_agent_tool_call("call_quiet", "delegate quietly"),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
])]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let started = harness
|
||||
.start_webrtc_realtime_with_codex_response_items("v=offer\r\n")
|
||||
.await?;
|
||||
assert_eq!(started.started.version, RealtimeConversationVersion::V2);
|
||||
assert_eq!(
|
||||
harness.sideband_outbound_request(/*request_index*/ 0).await["type"].as_str(),
|
||||
Some("session.update")
|
||||
);
|
||||
|
||||
let turn_started = harness
|
||||
.read_notification::<TurnStartedNotification>("turn/started")
|
||||
.await?;
|
||||
assert_eq!(turn_started.thread_id, harness.thread_id);
|
||||
let turn_completed = harness
|
||||
.read_notification::<TurnCompletedNotification>("turn/completed")
|
||||
.await?;
|
||||
assert_eq!(turn_completed.thread_id, harness.thread_id);
|
||||
|
||||
assert_v2_backend_item_update(
|
||||
&harness.sideband_outbound_request(/*request_index*/ 1).await,
|
||||
"automatic final response",
|
||||
);
|
||||
assert_v2_function_call_output(
|
||||
&harness.sideband_outbound_request(/*request_index*/ 2).await,
|
||||
"call_quiet",
|
||||
"",
|
||||
);
|
||||
let automatic_response_create = timeout(
|
||||
Duration::from_millis(200),
|
||||
harness
|
||||
.realtime_server
|
||||
.wait_for_request(/*connection_index*/ 0, /*request_index*/ 3),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
automatic_response_create.is_err(),
|
||||
"automatic handoff item should not request a realtime response"
|
||||
);
|
||||
|
||||
harness
|
||||
.append_speech(harness.thread_id.clone(), "manual spoken update")
|
||||
.await?;
|
||||
assert_v2_progress_update(
|
||||
&harness.sideband_outbound_request(/*request_index*/ 3).await,
|
||||
"manual spoken update",
|
||||
);
|
||||
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 4).await);
|
||||
|
||||
harness.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn webrtc_v2_assistant_output_without_handoff_reaches_realtime_context() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let final_answer = "long output ".repeat(1_000);
|
||||
for (version, expected_version, preamble) in [
|
||||
(
|
||||
RealtimeTestVersion::V1,
|
||||
RealtimeConversationVersion::V1,
|
||||
"direct preamble from v1",
|
||||
),
|
||||
(
|
||||
RealtimeTestVersion::V2,
|
||||
RealtimeConversationVersion::V2,
|
||||
"direct preamble from v2",
|
||||
),
|
||||
] {
|
||||
let mut harness = RealtimeE2eHarness::new(
|
||||
version,
|
||||
main_loop_responses(vec![responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": "msg-preamble",
|
||||
"phase": "commentary",
|
||||
"content": [{"type": "output_text", "text": preamble}]
|
||||
}
|
||||
}),
|
||||
responses::ev_assistant_message("msg-final", &final_answer),
|
||||
responses::ev_completed("resp-1"),
|
||||
])]),
|
||||
realtime_sideband(vec![realtime_sideband_connection(vec![
|
||||
vec![session_updated("sess_standalone_output")],
|
||||
vec![],
|
||||
match version {
|
||||
RealtimeTestVersion::V1 => vec![],
|
||||
RealtimeTestVersion::V2 => vec![
|
||||
json!({
|
||||
"type": "response.created",
|
||||
"response": { "id": "resp_preamble" }
|
||||
}),
|
||||
json!({
|
||||
"type": "response.done",
|
||||
"response": { "id": "resp_preamble" }
|
||||
}),
|
||||
],
|
||||
},
|
||||
vec![],
|
||||
vec![],
|
||||
])]),
|
||||
)
|
||||
let preamble = "direct preamble from v2";
|
||||
let mut harness = RealtimeE2eHarness::new(
|
||||
RealtimeTestVersion::V2,
|
||||
main_loop_responses(vec![responses::sse(vec![
|
||||
responses::ev_response_created("resp-1"),
|
||||
json!({
|
||||
"type": "response.output_item.done",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": "msg-preamble",
|
||||
"phase": "commentary",
|
||||
"content": [{"type": "output_text", "text": preamble}]
|
||||
}
|
||||
}),
|
||||
responses::ev_assistant_message("msg-final", &final_answer),
|
||||
responses::ev_completed("resp-1"),
|
||||
])]),
|
||||
realtime_sideband(vec![realtime_sideband_connection(vec![
|
||||
vec![session_updated("sess_standalone_output")],
|
||||
vec![],
|
||||
vec![],
|
||||
])]),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let started = harness
|
||||
.start_webrtc_realtime_with_codex_response_items("v=offer\r\n")
|
||||
.await?;
|
||||
assert_eq!(started.started.version, RealtimeConversationVersion::V2);
|
||||
|
||||
let request_id = harness
|
||||
.mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: harness.thread_id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "direct text turn".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
harness
|
||||
.mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: TurnStartResponse = to_response(response)?;
|
||||
let _ = harness
|
||||
.read_notification::<TurnCompletedNotification>("turn/completed")
|
||||
.await?;
|
||||
|
||||
let started = harness.start_webrtc_realtime("v=offer\r\n").await?;
|
||||
assert_eq!(started.started.version, expected_version);
|
||||
assert_v2_backend_item_update(
|
||||
&harness.sideband_outbound_request(/*request_index*/ 1).await,
|
||||
preamble,
|
||||
);
|
||||
let final_request = harness.sideband_outbound_request(/*request_index*/ 2).await;
|
||||
assert_eq!(final_request["type"], "conversation.item.create");
|
||||
assert_eq!(final_request["item"]["type"], "message");
|
||||
assert_eq!(final_request["item"]["role"], "developer");
|
||||
assert_eq!(final_request["item"]["content"][0]["type"], "input_text");
|
||||
let output_text = final_request["item"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("output text");
|
||||
assert!(output_text.starts_with(&format!("{RESPONSE_ITEM_PREFIX}\n\n[BACKEND] ")));
|
||||
assert!(output_text.contains("tokens truncated"));
|
||||
assert!(output_text.len() <= 4_000);
|
||||
|
||||
let request_id = harness
|
||||
.mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: harness.thread_id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "direct text turn".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
harness
|
||||
.mcp
|
||||
.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: TurnStartResponse = to_response(response)?;
|
||||
let _ = harness
|
||||
.read_notification::<TurnCompletedNotification>("turn/completed")
|
||||
.await?;
|
||||
|
||||
let preamble_request = harness.sideband_outbound_request(/*request_index*/ 1).await;
|
||||
let output_text = match version {
|
||||
RealtimeTestVersion::V1 => {
|
||||
let final_request = harness.sideband_outbound_request(/*request_index*/ 2).await;
|
||||
assert_eq!(
|
||||
preamble_request,
|
||||
json!({
|
||||
"type": "conversation.handoff.append",
|
||||
"handoff_id": "codex",
|
||||
"output_text": preamble,
|
||||
})
|
||||
);
|
||||
assert_eq!(final_request["type"], "conversation.handoff.append");
|
||||
assert_eq!(final_request["handoff_id"], "codex");
|
||||
final_request["output_text"]
|
||||
.as_str()
|
||||
.expect("output text")
|
||||
.to_string()
|
||||
}
|
||||
RealtimeTestVersion::V2 => {
|
||||
assert_v2_progress_update(&preamble_request, preamble);
|
||||
assert_v2_response_create(
|
||||
&harness.sideband_outbound_request(/*request_index*/ 2).await,
|
||||
);
|
||||
let final_request = harness.sideband_outbound_request(/*request_index*/ 3).await;
|
||||
assert_eq!(final_request["type"], "conversation.item.create");
|
||||
assert_eq!(final_request["item"]["type"], "message");
|
||||
assert_eq!(final_request["item"]["role"], "user");
|
||||
assert_eq!(final_request["item"]["content"][0]["type"], "input_text");
|
||||
let output_text = final_request["item"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("output text");
|
||||
assert!(output_text.starts_with("[BACKEND] "));
|
||||
assert_v2_response_create(
|
||||
&harness.sideband_outbound_request(/*request_index*/ 4).await,
|
||||
);
|
||||
output_text.to_string()
|
||||
}
|
||||
};
|
||||
assert!(output_text.contains("tokens truncated"));
|
||||
assert!(output_text.len() <= 4_000);
|
||||
|
||||
harness.shutdown().await;
|
||||
}
|
||||
harness.shutdown().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1807,14 +2045,6 @@ async fn webrtc_v2_background_agent_tool_call_delegates_and_returns_function_out
|
||||
|
||||
let tool_output = harness.sideband_outbound_request(/*request_index*/ 2).await;
|
||||
assert_v2_function_call_output(&tool_output, "call_v2", V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT);
|
||||
assert_eq!(
|
||||
function_call_output_sideband_requests(&harness.realtime_server).len(),
|
||||
1
|
||||
);
|
||||
|
||||
// Phase 4: after the final function-call output, realtime needs an explicit
|
||||
// `response.create` to produce the next user-visible response.
|
||||
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 3).await);
|
||||
|
||||
harness.shutdown().await;
|
||||
Ok(())
|
||||
@@ -2036,10 +2266,6 @@ async fn webrtc_v2_tool_call_delegated_turn_can_execute_shell_tool() -> Result<(
|
||||
"call_shell",
|
||||
V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT,
|
||||
);
|
||||
assert_eq!(
|
||||
function_call_output_sideband_requests(&harness.realtime_server).len(),
|
||||
1
|
||||
);
|
||||
|
||||
harness.shutdown().await;
|
||||
Ok(())
|
||||
@@ -2165,6 +2391,8 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
architecture: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
thread_id: thread_start.thread.id,
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
@@ -2227,6 +2455,8 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> {
|
||||
let start_request_id = mcp
|
||||
.send_thread_realtime_start_request(ThreadRealtimeStartParams {
|
||||
architecture: None,
|
||||
codex_responses_as_items: None,
|
||||
codex_response_item_prefix: None,
|
||||
thread_id: thread_start.thread.id.clone(),
|
||||
model: None,
|
||||
output_modality: RealtimeOutputModality::Audio,
|
||||
@@ -2350,18 +2580,6 @@ fn realtime_tool_ok_command() -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn function_call_output_sideband_requests(server: &WebSocketTestServer) -> Vec<Value> {
|
||||
server
|
||||
.single_connection()
|
||||
.iter()
|
||||
.map(WebSocketRequest::body_json)
|
||||
.filter(|request| {
|
||||
request["type"] == "conversation.item.create"
|
||||
&& request["item"]["type"] == "function_call_output"
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn assert_v2_function_call_output(request: &Value, call_id: &str, expected_output: &str) {
|
||||
assert_eq!(
|
||||
request,
|
||||
@@ -2393,6 +2611,27 @@ fn assert_v2_progress_update(request: &Value, expected_text: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_v2_backend_item_update(request: &Value, expected_text: &str) {
|
||||
assert_v2_items_update(request, &format!("[BACKEND] {expected_text}"));
|
||||
}
|
||||
|
||||
fn assert_v2_items_update(request: &Value, expected_text: &str) {
|
||||
assert_eq!(
|
||||
request,
|
||||
&json!({
|
||||
"type": "conversation.item.create",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": format!("{RESPONSE_ITEM_PREFIX}\n\n{expected_text}")
|
||||
}]
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_v2_user_text_item(request: &Value, expected_text: &str) {
|
||||
assert_eq!(
|
||||
request,
|
||||
|
||||
Reference in New Issue
Block a user