Skip to content

Commit f14b5ad

Browse files
authored
Add SSE Response parser tests (#1541)
## Summary - add `tokio-test` dev dependency - implement response stream parsing unit tests ## Testing - `cargo clippy -p codex-core --tests -- -D warnings` - `cargo test -p codex-core -- --nocapture` ------ https://chatgpt.com/codex/tasks/task_i_687163f3b2208321a6ce2adbef3fbc06
1 parent 9c0b413 commit f14b5ad

File tree

3 files changed

+184
-11
lines changed

3 files changed

+184
-11
lines changed

codex-rs/Cargo.lock

Lines changed: 47 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

codex-rs/core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,5 @@ maplit = "1.0.2"
6464
predicates = "3"
6565
pretty_assertions = "1.4.1"
6666
tempfile = "3"
67+
tokio-test = "0.4"
6768
wiremock = "0.6"

codex-rs/core/src/client.rs

Lines changed: 136 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -395,9 +395,39 @@ async fn stream_from_fixture(path: impl AsRef<Path>) -> Result<ResponseStream> {
395395
#[cfg(test)]
396396
mod tests {
397397
#![allow(clippy::expect_used, clippy::unwrap_used)]
398+
398399
use super::*;
399400
use serde_json::json;
401+
use tokio::sync::mpsc;
402+
use tokio_test::io::Builder as IoBuilder;
403+
use tokio_util::io::ReaderStream;
404+
405+
// ────────────────────────────
406+
// Helpers
407+
// ────────────────────────────
408+
409+
/// Runs the SSE parser on pre-chunked byte slices and returns every event
410+
/// (including any final `Err` from a stream-closure check).
411+
async fn collect_events(chunks: &[&[u8]]) -> Vec<Result<ResponseEvent>> {
412+
let mut builder = IoBuilder::new();
413+
for chunk in chunks {
414+
builder.read(chunk);
415+
}
416+
417+
let reader = builder.build();
418+
let stream = ReaderStream::new(reader).map_err(CodexErr::Io);
419+
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(16);
420+
tokio::spawn(process_sse(stream, tx));
400421

422+
let mut events = Vec::new();
423+
while let Some(ev) = rx.recv().await {
424+
events.push(ev);
425+
}
426+
events
427+
}
428+
429+
/// Builds an in-memory SSE stream from JSON fixtures and returns only the
430+
/// successfully parsed events (panics on internal channel errors).
401431
async fn run_sse(events: Vec<serde_json::Value>) -> Vec<ResponseEvent> {
402432
let mut body = String::new();
403433
for e in events {
@@ -411,24 +441,116 @@ mod tests {
411441
body.push_str(&format!("event: {kind}\ndata: {e}\n\n"));
412442
}
413443
}
444+
414445
let (tx, mut rx) = mpsc::channel::<Result<ResponseEvent>>(8);
415446
let stream = ReaderStream::new(std::io::Cursor::new(body)).map_err(CodexErr::Io);
416447
tokio::spawn(process_sse(stream, tx));
448+
417449
let mut out = Vec::new();
418450
while let Some(ev) = rx.recv().await {
419451
out.push(ev.expect("channel closed"));
420452
}
421453
out
422454
}
423455

424-
/// Verifies that the SSE adapter emits the expected [`ResponseEvent`] for
425-
/// a variety of `type` values from the Responses API. The test is written
426-
/// table-driven style to keep additions for new event kinds trivial.
427-
///
428-
/// Each `Case` supplies an input event, a predicate that must match the
429-
/// *first* `ResponseEvent` produced by the adapter, and the total number
430-
/// of events expected after appending a synthetic `response.completed`
431-
/// marker that terminates the stream.
456+
// ────────────────────────────
457+
// Tests from `implement-test-for-responses-api-sse-parser`
458+
// ────────────────────────────
459+
460+
#[tokio::test]
461+
async fn parses_items_and_completed() {
462+
let item1 = json!({
463+
"type": "response.output_item.done",
464+
"item": {
465+
"type": "message",
466+
"role": "assistant",
467+
"content": [{"type": "output_text", "text": "Hello"}]
468+
}
469+
})
470+
.to_string();
471+
472+
let item2 = json!({
473+
"type": "response.output_item.done",
474+
"item": {
475+
"type": "message",
476+
"role": "assistant",
477+
"content": [{"type": "output_text", "text": "World"}]
478+
}
479+
})
480+
.to_string();
481+
482+
let completed = json!({
483+
"type": "response.completed",
484+
"response": { "id": "resp1" }
485+
})
486+
.to_string();
487+
488+
let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
489+
let sse2 = format!("event: response.output_item.done\ndata: {item2}\n\n");
490+
let sse3 = format!("event: response.completed\ndata: {completed}\n\n");
491+
492+
let events = collect_events(&[sse1.as_bytes(), sse2.as_bytes(), sse3.as_bytes()]).await;
493+
494+
assert_eq!(events.len(), 3);
495+
496+
matches!(
497+
&events[0],
498+
Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }))
499+
if role == "assistant"
500+
);
501+
502+
matches!(
503+
&events[1],
504+
Ok(ResponseEvent::OutputItemDone(ResponseItem::Message { role, .. }))
505+
if role == "assistant"
506+
);
507+
508+
match &events[2] {
509+
Ok(ResponseEvent::Completed {
510+
response_id,
511+
token_usage,
512+
}) => {
513+
assert_eq!(response_id, "resp1");
514+
assert!(token_usage.is_none());
515+
}
516+
other => panic!("unexpected third event: {other:?}"),
517+
}
518+
}
519+
520+
#[tokio::test]
521+
async fn error_when_missing_completed() {
522+
let item1 = json!({
523+
"type": "response.output_item.done",
524+
"item": {
525+
"type": "message",
526+
"role": "assistant",
527+
"content": [{"type": "output_text", "text": "Hello"}]
528+
}
529+
})
530+
.to_string();
531+
532+
let sse1 = format!("event: response.output_item.done\ndata: {item1}\n\n");
533+
534+
let events = collect_events(&[sse1.as_bytes()]).await;
535+
536+
assert_eq!(events.len(), 2);
537+
538+
matches!(events[0], Ok(ResponseEvent::OutputItemDone(_)));
539+
540+
match &events[1] {
541+
Err(CodexErr::Stream(msg)) => {
542+
assert_eq!(msg, "stream closed before response.completed")
543+
}
544+
other => panic!("unexpected second event: {other:?}"),
545+
}
546+
}
547+
548+
// ────────────────────────────
549+
// Table-driven test from `main`
550+
// ────────────────────────────
551+
552+
/// Verifies that the adapter produces the right `ResponseEvent` for a
553+
/// variety of incoming `type` values.
432554
#[tokio::test]
433555
async fn table_driven_event_kinds() {
434556
struct TestCase {
@@ -441,11 +563,9 @@ mod tests {
441563
fn is_created(ev: &ResponseEvent) -> bool {
442564
matches!(ev, ResponseEvent::Created)
443565
}
444-
445566
fn is_output(ev: &ResponseEvent) -> bool {
446567
matches!(ev, ResponseEvent::OutputItemDone(_))
447568
}
448-
449569
fn is_completed(ev: &ResponseEvent) -> bool {
450570
matches!(ev, ResponseEvent::Completed { .. })
451571
}
@@ -498,9 +618,14 @@ mod tests {
498618
for case in cases {
499619
let mut evs = vec![case.event];
500620
evs.push(completed.clone());
621+
501622
let out = run_sse(evs).await;
502623
assert_eq!(out.len(), case.expected_len, "case {}", case.name);
503-
assert!((case.expect_first)(&out[0]), "case {}", case.name);
624+
assert!(
625+
(case.expect_first)(&out[0]),
626+
"first event mismatch in case {}",
627+
case.name
628+
);
504629
}
505630
}
506631
}

0 commit comments

Comments
 (0)