mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
Mirror user text into realtime (#17520)
- Let typed user messages submit while realtime is active and mirror accepted text into the realtime text stream. - Add integration coverage and snapshot for outbound realtime text.
This commit is contained in:
@@ -2265,7 +2265,7 @@ impl Session {
|
||||
}
|
||||
|
||||
pub(crate) async fn route_realtime_text_input(self: &Arc<Self>, text: String) {
|
||||
handlers::user_input_or_turn(
|
||||
handlers::user_input_or_turn_inner(
|
||||
self,
|
||||
self.next_internal_sub_id(),
|
||||
Op::UserInput {
|
||||
@@ -2276,6 +2276,7 @@ impl Session {
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
},
|
||||
/*mirror_user_text_to_realtime*/ None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -4910,6 +4911,7 @@ mod handlers {
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::dynamic_tools::DynamicToolResponse;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_protocol::mcp::RequestId as ProtocolRequestId;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
@@ -4917,6 +4919,7 @@ mod handlers {
|
||||
use serde_json::Value;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::debug;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -4958,6 +4961,21 @@ mod handlers {
|
||||
}
|
||||
|
||||
pub async fn user_input_or_turn(sess: &Arc<Session>, sub_id: String, op: Op) {
|
||||
user_input_or_turn_inner(
|
||||
sess,
|
||||
sub_id,
|
||||
op,
|
||||
/*mirror_user_text_to_realtime*/ Some(()),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
pub(super) async fn user_input_or_turn_inner(
|
||||
sess: &Arc<Session>,
|
||||
sub_id: String,
|
||||
op: Op,
|
||||
mirror_user_text_to_realtime: Option<()>,
|
||||
) {
|
||||
let (items, updates, responsesapi_client_metadata) = match op {
|
||||
Op::UserTurn {
|
||||
cwd,
|
||||
@@ -5023,7 +5041,7 @@ mod handlers {
|
||||
};
|
||||
sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref())
|
||||
.await;
|
||||
match sess
|
||||
let accepted_items = match sess
|
||||
.steer_input(
|
||||
items.clone(),
|
||||
/*expected_turn_id*/ None,
|
||||
@@ -5031,7 +5049,10 @@ mod handlers {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => current_context.session_telemetry.user_prompt(&items),
|
||||
Ok(_) => {
|
||||
current_context.session_telemetry.user_prompt(&items);
|
||||
Some(items)
|
||||
}
|
||||
Err(SteerInputError::NoActiveTurn(items)) => {
|
||||
if let Some(responsesapi_client_metadata) = responsesapi_client_metadata {
|
||||
current_context
|
||||
@@ -5041,12 +5062,14 @@ mod handlers {
|
||||
current_context.session_telemetry.user_prompt(&items);
|
||||
sess.refresh_mcp_servers_if_requested(¤t_context)
|
||||
.await;
|
||||
let accepted_items = items.clone();
|
||||
sess.spawn_task(
|
||||
Arc::clone(¤t_context),
|
||||
items,
|
||||
crate::tasks::RegularTask::new(),
|
||||
)
|
||||
.await;
|
||||
Some(accepted_items)
|
||||
}
|
||||
Err(err) => {
|
||||
sess.send_event_raw(Event {
|
||||
@@ -5054,7 +5077,24 @@ mod handlers {
|
||||
msg: EventMsg::Error(err.to_error_event()),
|
||||
})
|
||||
.await;
|
||||
None
|
||||
}
|
||||
};
|
||||
if let (Some(items), Some(())) = (accepted_items, mirror_user_text_to_realtime) {
|
||||
self::mirror_user_text_to_realtime(sess, &items).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn mirror_user_text_to_realtime(sess: &Arc<Session>, items: &[UserInput]) {
|
||||
let text = UserMessageItem::new(items).message();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
if sess.conversation.running_state().await.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Err(err) = sess.conversation.text_in(text).await {
|
||||
debug!("failed to mirror user text to realtime conversation: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -945,8 +945,6 @@ fn spawn_realtime_input_task(input: RealtimeInputTask) -> JoinHandle<()> {
|
||||
user_text,
|
||||
&writer,
|
||||
&events_tx,
|
||||
session_kind,
|
||||
&mut response_create_queue,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -992,8 +990,6 @@ async fn handle_user_text_input(
|
||||
text: Result<String, RecvError>,
|
||||
writer: &RealtimeWebsocketWriter,
|
||||
events_tx: &Sender<RealtimeEvent>,
|
||||
session_kind: RealtimeSessionKind,
|
||||
response_create_queue: &mut RealtimeResponseCreateQueue,
|
||||
) -> anyhow::Result<()> {
|
||||
let text = text.context("user text input channel closed")?;
|
||||
|
||||
@@ -1005,14 +1001,6 @@ async fn handle_user_text_input(
|
||||
.await;
|
||||
return Err(mapped_error.into());
|
||||
}
|
||||
match session_kind {
|
||||
RealtimeSessionKind::V1 => {}
|
||||
RealtimeSessionKind::V2 => {
|
||||
response_create_queue
|
||||
.request_create(writer, events_tx, "text")
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1790,6 +1790,126 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn conversation_user_text_turn_is_sent_to_realtime_when_active() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let api_server = start_mock_server().await;
|
||||
let response_mock = responses::mount_sse_once(
|
||||
&api_server,
|
||||
responses::sse(vec![
|
||||
responses::ev_response_created("resp_user_text"),
|
||||
responses::ev_assistant_message("msg_user_text", "ack"),
|
||||
responses::ev_completed("resp_user_text"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
let realtime_server = start_websocket_server(vec![vec![
|
||||
vec![json!({
|
||||
"type": "session.updated",
|
||||
"session": { "id": "sess_user_text", "instructions": "backend prompt" }
|
||||
})],
|
||||
vec![],
|
||||
]])
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex().with_config({
|
||||
let realtime_base_url = realtime_server.uri().to_string();
|
||||
move |config| {
|
||||
config.experimental_realtime_ws_base_url = Some(realtime_base_url);
|
||||
config.experimental_realtime_ws_startup_context = Some(String::new());
|
||||
}
|
||||
});
|
||||
let test = builder.build(&api_server).await?;
|
||||
|
||||
test.codex
|
||||
.submit(Op::RealtimeConversationStart(ConversationStartParams {
|
||||
prompt: Some(Some("backend prompt".to_string())),
|
||||
session_id: None,
|
||||
transport: None,
|
||||
voice: None,
|
||||
}))
|
||||
.await?;
|
||||
|
||||
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
|
||||
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
|
||||
payload: RealtimeEvent::SessionUpdated { session_id, .. },
|
||||
}) => Some(session_id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(session_updated, "sess_user_text");
|
||||
|
||||
let user_text = "typed follow-up for realtime";
|
||||
test.codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: user_text.to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
responsesapi_client_metadata: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
wait_for_event(&test.codex, |event| {
|
||||
matches!(event, EventMsg::TurnComplete(_))
|
||||
})
|
||||
.await;
|
||||
|
||||
let realtime_text_request = wait_for_matching_websocket_request(
|
||||
&realtime_server,
|
||||
"normal user turn text mirrored to realtime",
|
||||
|request| websocket_request_text(request).as_deref() == Some(user_text),
|
||||
)
|
||||
.await;
|
||||
let model_user_texts = response_mock.single_request().message_input_texts("user");
|
||||
assert_eq!(
|
||||
(
|
||||
model_user_texts.iter().any(|text| text == user_text),
|
||||
websocket_request_text(&realtime_text_request),
|
||||
),
|
||||
(true, Some(user_text.to_string())),
|
||||
);
|
||||
let realtime_response_create = timeout(Duration::from_millis(200), async {
|
||||
wait_for_matching_websocket_request(
|
||||
&realtime_server,
|
||||
"unexpected realtime response request for mirrored user text",
|
||||
|request| request.body_json()["type"].as_str() == Some("response.create"),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
realtime_response_create.is_err(),
|
||||
"mirrored user text should not request a realtime response"
|
||||
);
|
||||
|
||||
let realtime_request_body = realtime_text_request.body_json();
|
||||
let content = &realtime_request_body["item"]["content"][0];
|
||||
let snapshot = format!(
|
||||
"type: {}\nitem.type: {}\nitem.role: {}\ncontent[0].type: {}\ncontent[0].text: {}\nresponse.create: {}",
|
||||
realtime_request_body["type"].as_str().unwrap_or_default(),
|
||||
realtime_request_body["item"]["type"]
|
||||
.as_str()
|
||||
.unwrap_or_default(),
|
||||
realtime_request_body["item"]["role"]
|
||||
.as_str()
|
||||
.unwrap_or_default(),
|
||||
content["type"].as_str().unwrap_or_default(),
|
||||
content["text"].as_str().unwrap_or_default(),
|
||||
realtime_response_create.is_ok(),
|
||||
);
|
||||
insta::assert_snapshot!(
|
||||
"conversation_user_text_turn_is_sent_to_realtime_when_active",
|
||||
snapshot
|
||||
);
|
||||
|
||||
realtime_server.shutdown().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
@@ -2767,6 +2887,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
|
||||
"type": "session.updated",
|
||||
"session": { "id": "sess_steer", "instructions": "backend prompt" }
|
||||
})],
|
||||
vec![],
|
||||
vec![
|
||||
json!({
|
||||
"type": "conversation.input_transcript.delta",
|
||||
@@ -2822,6 +2943,12 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> {
|
||||
matches!(event, EventMsg::AgentMessageContentDelta(_))
|
||||
})
|
||||
.await;
|
||||
let _ = wait_for_matching_websocket_request(
|
||||
&realtime_server,
|
||||
"first prompt mirrored to realtime",
|
||||
|request| websocket_request_text(request).as_deref() == Some("first prompt"),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.codex
|
||||
.submit(Op::RealtimeConversationAudio(ConversationAudioParams {
|
||||
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
source: core/tests/suite/realtime_conversation.rs
|
||||
expression: snapshot
|
||||
---
|
||||
type: conversation.item.create
|
||||
item.type: message
|
||||
item.role: user
|
||||
content[0].type: input_text
|
||||
content[0].text: typed follow-up for realtime
|
||||
response.create: false
|
||||
Reference in New Issue
Block a user