Add item streaming events (#5546)
Adds AgentMessageContentDelta, ReasoningContentDelta, ReasoningRawContentDelta item streaming events while maintaining compatibility for old events. --------- Co-authored-by: Owen Lin <owen@openai.com>
This commit is contained in:
@@ -171,19 +171,24 @@ async fn streams_text_without_reasoning() {
|
||||
);
|
||||
|
||||
let events = run_stream(sse).await;
|
||||
assert_eq!(events.len(), 3, "unexpected events: {events:?}");
|
||||
assert_eq!(events.len(), 4, "unexpected events: {events:?}");
|
||||
|
||||
match &events[0] {
|
||||
ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }) => {}
|
||||
other => panic!("expected initial assistant item, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "hi"),
|
||||
other => panic!("expected text delta, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
match &events[2] {
|
||||
ResponseEvent::OutputItemDone(item) => assert_message(item, "hi"),
|
||||
other => panic!("expected terminal message, got {other:?}"),
|
||||
}
|
||||
|
||||
assert_matches!(events[2], ResponseEvent::Completed { .. });
|
||||
assert_matches!(events[3], ResponseEvent::Completed { .. });
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -202,29 +207,39 @@ async fn streams_reasoning_from_string_delta() {
|
||||
);
|
||||
|
||||
let events = run_stream(sse).await;
|
||||
assert_eq!(events.len(), 5, "unexpected events: {events:?}");
|
||||
assert_eq!(events.len(), 7, "unexpected events: {events:?}");
|
||||
|
||||
match &events[0] {
|
||||
ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {}
|
||||
other => panic!("expected initial reasoning item, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "think1"),
|
||||
other => panic!("expected reasoning delta, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
match &events[2] {
|
||||
ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }) => {}
|
||||
other => panic!("expected initial message item, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[3] {
|
||||
ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "ok"),
|
||||
other => panic!("expected text delta, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[2] {
|
||||
match &events[4] {
|
||||
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "think1"),
|
||||
other => panic!("expected reasoning item, got {other:?}"),
|
||||
other => panic!("expected terminal reasoning, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[3] {
|
||||
match &events[5] {
|
||||
ResponseEvent::OutputItemDone(item) => assert_message(item, "ok"),
|
||||
other => panic!("expected message item, got {other:?}"),
|
||||
other => panic!("expected terminal message, got {other:?}"),
|
||||
}
|
||||
|
||||
assert_matches!(events[4], ResponseEvent::Completed { .. });
|
||||
assert_matches!(events[6], ResponseEvent::Completed { .. });
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -244,34 +259,44 @@ async fn streams_reasoning_from_object_delta() {
|
||||
);
|
||||
|
||||
let events = run_stream(sse).await;
|
||||
assert_eq!(events.len(), 6, "unexpected events: {events:?}");
|
||||
assert_eq!(events.len(), 8, "unexpected events: {events:?}");
|
||||
|
||||
match &events[0] {
|
||||
ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {}
|
||||
other => panic!("expected initial reasoning item, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "partA"),
|
||||
other => panic!("expected reasoning delta, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
match &events[2] {
|
||||
ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "partB"),
|
||||
other => panic!("expected reasoning delta, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[2] {
|
||||
match &events[3] {
|
||||
ResponseEvent::OutputItemAdded(ResponseItem::Message { .. }) => {}
|
||||
other => panic!("expected initial message item, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[4] {
|
||||
ResponseEvent::OutputTextDelta(text) => assert_eq!(text, "answer"),
|
||||
other => panic!("expected text delta, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[3] {
|
||||
match &events[5] {
|
||||
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "partApartB"),
|
||||
other => panic!("expected reasoning item, got {other:?}"),
|
||||
other => panic!("expected terminal reasoning, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[4] {
|
||||
match &events[6] {
|
||||
ResponseEvent::OutputItemDone(item) => assert_message(item, "answer"),
|
||||
other => panic!("expected message item, got {other:?}"),
|
||||
other => panic!("expected terminal message, got {other:?}"),
|
||||
}
|
||||
|
||||
assert_matches!(events[5], ResponseEvent::Completed { .. });
|
||||
assert_matches!(events[7], ResponseEvent::Completed { .. });
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -286,19 +311,24 @@ async fn streams_reasoning_from_final_message() {
|
||||
let sse = "data: {\"choices\":[{\"message\":{\"reasoning\":\"final-cot\"},\"finish_reason\":\"stop\"}]}\n\n";
|
||||
|
||||
let events = run_stream(sse).await;
|
||||
assert_eq!(events.len(), 3, "unexpected events: {events:?}");
|
||||
assert_eq!(events.len(), 4, "unexpected events: {events:?}");
|
||||
|
||||
match &events[0] {
|
||||
ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {}
|
||||
other => panic!("expected initial reasoning item, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "final-cot"),
|
||||
other => panic!("expected reasoning delta, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
match &events[2] {
|
||||
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "final-cot"),
|
||||
other => panic!("expected reasoning item, got {other:?}"),
|
||||
}
|
||||
|
||||
assert_matches!(events[2], ResponseEvent::Completed { .. });
|
||||
assert_matches!(events[3], ResponseEvent::Completed { .. });
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
@@ -316,19 +346,24 @@ async fn streams_reasoning_before_tool_call() {
|
||||
);
|
||||
|
||||
let events = run_stream(sse).await;
|
||||
assert_eq!(events.len(), 4, "unexpected events: {events:?}");
|
||||
assert_eq!(events.len(), 5, "unexpected events: {events:?}");
|
||||
|
||||
match &events[0] {
|
||||
ResponseEvent::OutputItemAdded(ResponseItem::Reasoning { .. }) => {}
|
||||
other => panic!("expected initial reasoning item, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
ResponseEvent::ReasoningContentDelta(text) => assert_eq!(text, "pre-tool"),
|
||||
other => panic!("expected reasoning delta, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[1] {
|
||||
match &events[2] {
|
||||
ResponseEvent::OutputItemDone(item) => assert_reasoning(item, "pre-tool"),
|
||||
other => panic!("expected reasoning item, got {other:?}"),
|
||||
}
|
||||
|
||||
match &events[2] {
|
||||
match &events[3] {
|
||||
ResponseEvent::OutputItemDone(ResponseItem::FunctionCall {
|
||||
name,
|
||||
arguments,
|
||||
@@ -342,7 +377,7 @@ async fn streams_reasoning_before_tool_call() {
|
||||
other => panic!("expected function call, got {other:?}"),
|
||||
}
|
||||
|
||||
assert_matches!(events[3], ResponseEvent::Completed { .. });
|
||||
assert_matches!(events[4], ResponseEvent::Completed { .. });
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -217,6 +217,25 @@ pub fn ev_assistant_message(id: &str, text: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_message_item_added(id: &str, text: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.output_item.added",
|
||||
"item": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": id,
|
||||
"content": [{"type": "output_text", "text": text}]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_output_text_delta(delta: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.output_text.delta",
|
||||
"delta": delta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_reasoning_item(id: &str, summary: &[&str], raw_content: &[&str]) -> Value {
|
||||
let summary_entries: Vec<Value> = summary
|
||||
.iter()
|
||||
@@ -243,6 +262,36 @@ pub fn ev_reasoning_item(id: &str, summary: &[&str], raw_content: &[&str]) -> Va
|
||||
event
|
||||
}
|
||||
|
||||
pub fn ev_reasoning_item_added(id: &str, summary: &[&str]) -> Value {
|
||||
let summary_entries: Vec<Value> = summary
|
||||
.iter()
|
||||
.map(|text| serde_json::json!({"type": "summary_text", "text": text}))
|
||||
.collect();
|
||||
|
||||
serde_json::json!({
|
||||
"type": "response.output_item.added",
|
||||
"item": {
|
||||
"type": "reasoning",
|
||||
"id": id,
|
||||
"summary": summary_entries,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_reasoning_summary_text_delta(delta: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.reasoning_summary_text.delta",
|
||||
"delta": delta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_reasoning_text_delta(delta: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.reasoning_text.delta",
|
||||
"delta": delta,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ev_web_search_call_added(id: &str, status: &str, query: &str) -> Value {
|
||||
serde_json::json!({
|
||||
"type": "response.output_item.added",
|
||||
|
||||
@@ -1262,6 +1262,10 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() {
|
||||
// Build a small SSE stream with deltas and a final assistant message.
|
||||
// We emit the same body for all 3 turns; ids vary but are unused by assertions.
|
||||
let sse_raw = r##"[
|
||||
{"type":"response.output_item.added", "item":{
|
||||
"type":"message", "role":"assistant",
|
||||
"content":[{"type":"output_text","text":""}]
|
||||
}},
|
||||
{"type":"response.output_text.delta", "delta":"Hey "},
|
||||
{"type":"response.output_text.delta", "delta":"there"},
|
||||
{"type":"response.output_text.delta", "delta":"!\n"},
|
||||
|
||||
@@ -9,7 +9,12 @@ use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_message_item_added;
|
||||
use core_test_support::responses::ev_output_text_delta;
|
||||
use core_test_support::responses::ev_reasoning_item;
|
||||
use core_test_support::responses::ev_reasoning_item_added;
|
||||
use core_test_support::responses::ev_reasoning_summary_text_delta;
|
||||
use core_test_support::responses::ev_reasoning_text_delta;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::ev_web_search_call_added;
|
||||
use core_test_support::responses::ev_web_search_call_done;
|
||||
@@ -234,3 +239,181 @@ async fn web_search_item_is_emitted() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn agent_message_content_delta_has_item_metadata() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let TestCodex {
|
||||
codex,
|
||||
session_configured,
|
||||
..
|
||||
} = test_codex().build(&server).await?;
|
||||
|
||||
let stream = sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_message_item_added("msg-1", ""),
|
||||
ev_output_text_delta("streamed response"),
|
||||
ev_assistant_message("msg-1", "streamed response"),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
mount_sse_once_match(&server, any(), stream).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "please stream text".into(),
|
||||
}],
|
||||
})
|
||||
.await?;
|
||||
|
||||
let (started_turn_id, started_item) = wait_for_event_match(&codex, |ev| match ev {
|
||||
EventMsg::ItemStarted(ItemStartedEvent {
|
||||
turn_id,
|
||||
item: TurnItem::AgentMessage(item),
|
||||
..
|
||||
}) => Some((turn_id.clone(), item.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let delta_event = wait_for_event_match(&codex, |ev| match ev {
|
||||
EventMsg::AgentMessageContentDelta(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
let legacy_delta = wait_for_event_match(&codex, |ev| match ev {
|
||||
EventMsg::AgentMessageDelta(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
let completed_item = wait_for_event_match(&codex, |ev| match ev {
|
||||
EventMsg::ItemCompleted(ItemCompletedEvent {
|
||||
item: TurnItem::AgentMessage(item),
|
||||
..
|
||||
}) => Some(item.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let session_id = session_configured.session_id.to_string();
|
||||
assert_eq!(delta_event.thread_id, session_id);
|
||||
assert_eq!(delta_event.turn_id, started_turn_id);
|
||||
assert_eq!(delta_event.item_id, started_item.id);
|
||||
assert_eq!(delta_event.delta, "streamed response");
|
||||
assert_eq!(legacy_delta.delta, "streamed response");
|
||||
assert_eq!(completed_item.id, started_item.id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex().build(&server).await?;
|
||||
|
||||
let stream = sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_reasoning_item_added("reasoning-1", &[""]),
|
||||
ev_reasoning_summary_text_delta("step one"),
|
||||
ev_reasoning_item("reasoning-1", &["step one"], &[]),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
mount_sse_once_match(&server, any(), stream).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "reason through it".into(),
|
||||
}],
|
||||
})
|
||||
.await?;
|
||||
|
||||
let reasoning_item = wait_for_event_match(&codex, |ev| match ev {
|
||||
EventMsg::ItemStarted(ItemStartedEvent {
|
||||
item: TurnItem::Reasoning(item),
|
||||
..
|
||||
}) => Some(item.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let delta_event = wait_for_event_match(&codex, |ev| match ev {
|
||||
EventMsg::ReasoningContentDelta(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
let legacy_delta = wait_for_event_match(&codex, |ev| match ev {
|
||||
EventMsg::AgentReasoningDelta(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(delta_event.item_id, reasoning_item.id);
|
||||
assert_eq!(delta_event.delta, "step one");
|
||||
assert_eq!(legacy_delta.delta, "step one");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
|
||||
let TestCodex { codex, .. } = test_codex()
|
||||
.with_config(|config| {
|
||||
config.show_raw_agent_reasoning = true;
|
||||
})
|
||||
.build(&server)
|
||||
.await?;
|
||||
|
||||
let stream = sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_reasoning_item_added("reasoning-raw", &[""]),
|
||||
ev_reasoning_text_delta("raw detail"),
|
||||
ev_reasoning_item("reasoning-raw", &["complete"], &["raw detail"]),
|
||||
ev_completed("resp-1"),
|
||||
]);
|
||||
mount_sse_once_match(&server, any(), stream).await;
|
||||
|
||||
codex
|
||||
.submit(Op::UserInput {
|
||||
items: vec![UserInput::Text {
|
||||
text: "show raw reasoning".into(),
|
||||
}],
|
||||
})
|
||||
.await?;
|
||||
|
||||
let reasoning_item = wait_for_event_match(&codex, |ev| match ev {
|
||||
EventMsg::ItemStarted(ItemStartedEvent {
|
||||
item: TurnItem::Reasoning(item),
|
||||
..
|
||||
}) => Some(item.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let delta_event = wait_for_event_match(&codex, |ev| match ev {
|
||||
EventMsg::ReasoningRawContentDelta(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
let legacy_delta = wait_for_event_match(&codex, |ev| match ev {
|
||||
EventMsg::AgentReasoningRawContentDelta(event) => Some(event.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(delta_event.item_id, reasoning_item.id);
|
||||
assert_eq!(delta_event.delta, "raw detail");
|
||||
assert_eq!(legacy_delta.delta, "raw detail");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user