From 355c40ad7ed749194f17d6d33641efc4de0c3fd4 Mon Sep 17 00:00:00 2001 From: Andrey Mishchenko Date: Sat, 25 Apr 2026 21:57:42 -0700 Subject: [PATCH] Support end_turn in response.completed (#19610) Some providers of Responses API forward a model-defined `end_turn` boolean indicating explicitly the model's indication of whether it would like to end the turn or to be inferenced again. In this PR, we update the sampling loop to use this field correctly if it's set. If the field is not set by the provider, we fall back to the existing sampling logic. --- codex-rs/Cargo.lock | 1 - codex-rs/cli/src/responses_cmd.rs | 22 ++++++++++++++++++++-- codex-rs/codex-api/src/common.rs | 3 +++ codex-rs/codex-api/src/sse/responses.rs | 16 +++++++++++++--- codex-rs/codex-api/tests/sse_end_to_end.rs | 2 ++ codex-rs/core/src/client.rs | 2 ++ codex-rs/core/src/session/turn.rs | 5 ++++- 7 files changed, 44 insertions(+), 7 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2bd379252..69e8f66b5 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2870,7 +2870,6 @@ dependencies = [ "codex-plugin", "codex-protocol", "codex-rmcp-client", - "codex-utils-absolute-path", "codex-utils-plugins", "futures", "pretty_assertions", diff --git a/codex-rs/cli/src/responses_cmd.rs b/codex-rs/cli/src/responses_cmd.rs index 6974198ef..012c70d94 100644 --- a/codex-rs/cli/src/responses_cmd.rs +++ b/codex-rs/cli/src/responses_cmd.rs @@ -78,8 +78,9 @@ fn response_event_to_json(event: codex_api::ResponseEvent) -> serde_json::Value codex_api::ResponseEvent::Completed { response_id, token_usage, + end_turn, } => { - let response = match token_usage { + let mut response = match token_usage { Some(token_usage) => json!({ "id": response_id, "usage": { @@ -96,6 +97,9 @@ fn response_event_to_json(event: codex_api::ResponseEvent) -> serde_json::Value }), None => json!({ "id": response_id }), }; + if let Some(end_turn) = end_turn { + response["end_turn"] = json!(end_turn); + } json!({ "type": "response.completed", "response": response }) } codex_api::ResponseEvent::OutputTextDelta(delta) => { @@ -165,6 +169,7 @@ mod tests { reasoning_output_tokens: 3, total_tokens: 17, }), + end_turn: Some(true), }); assert_eq!( completed, @@ -183,6 +188,7 @@ mod tests { }, "total_tokens": 17, }, + "end_turn": true, }, }) ); @@ -190,10 +196,22 @@ mod tests { let completed_without_usage = response_event_to_json(codex_api::ResponseEvent::Completed { response_id: "resp-2".to_string(), token_usage: None, + end_turn: Some(false), }); assert_eq!( completed_without_usage, - json!({"type": "response.completed", "response": {"id": "resp-2"}}) + json!({"type": "response.completed", "response": {"id": "resp-2", "end_turn": false}}) + ); + + let completed_without_usage_or_end_turn = + response_event_to_json(codex_api::ResponseEvent::Completed { + response_id: "resp-3".to_string(), + token_usage: None, + end_turn: None, + }); + assert_eq!( + completed_without_usage_or_end_turn, + json!({"type": "response.completed", "response": {"id": "resp-3"}}) ); } diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 6f118d103..4b150b55f 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -81,6 +81,9 @@ pub enum ResponseEvent { Completed { response_id: String, token_usage: Option, + /// Did the model affirmatively end its turn? Some providers do not set this, + /// so we rely on fallback logic when this is `None`. + end_turn: Option, }, OutputTextDelta(String), ToolCallInputDelta { diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 7b4a4ceab..fb1742463 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -123,6 +123,8 @@ struct ResponseCompleted { id: String, #[serde(default)] usage: Option, + #[serde(default)] + end_turn: Option, } #[derive(Debug, Deserialize)] @@ -382,6 +384,7 @@ pub fn process_responses_event( return Ok(Some(ResponseEvent::Completed { response_id: resp.id, token_usage: resp.usage.map(Into::into), + end_turn: resp.end_turn, })); } Err(err) => { @@ -704,9 +707,11 @@ mod tests { Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, }) => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); + assert!(end_turn.is_none()); } other => panic!("unexpected third event: {other:?}"), } @@ -843,9 +848,11 @@ mod tests { Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, }) => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); + assert!(end_turn.is_none()); } other => panic!("unexpected event: {other:?}"), } @@ -1148,7 +1155,8 @@ mod tests { &events[1], ResponseEvent::Completed { response_id, - token_usage: None + token_usage: None, + end_turn: None, } if response_id == "resp-1" ); } @@ -1184,7 +1192,8 @@ mod tests { &events[2], ResponseEvent::Completed { response_id, - token_usage: None + token_usage: None, + end_turn: None, } if response_id == "resp-1" ); } @@ -1218,7 +1227,8 @@ mod tests { &events[1], ResponseEvent::Completed { response_id, - token_usage: None + token_usage: None, + end_turn: None, } if response_id == "resp-1" ); } diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs index 107c10172..bf880fefc 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -158,9 +158,11 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> ResponseEvent::Completed { response_id, token_usage, + end_turn, } => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); + assert!(end_turn.is_none()); } other => panic!("unexpected third event: {other:?}"), } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index cb63ca455..c49e28f20 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1655,6 +1655,7 @@ where Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, }) => { if let Some(usage) = &token_usage { session_telemetry.sse_event_completed( @@ -1680,6 +1681,7 @@ where .send(Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, })) .await .is_err() diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index fe9320b12..2577ec47d 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2132,6 +2132,7 @@ async fn try_run_sampling_request( ResponseEvent::Completed { response_id: _, token_usage, + end_turn, } => { flush_assistant_text_segments_all( &sess, @@ -2143,7 +2144,9 @@ async fn try_run_sampling_request( sess.update_token_usage_info(&turn_context, token_usage.as_ref()) .await; should_emit_turn_diff = true; - + if let Some(false) = end_turn { + needs_follow_up = true; + } break Ok(SamplingRequestResult { needs_follow_up, last_agent_message,