Compare commits

...

2 Commits

Author SHA1 Message Date
Andrey Mishchenko
bc0ed2bb71 Release 0.126.0-alpha.3 2026-04-25 22:09:36 -07:00
Andrey Mishchenko
355c40ad7e 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.
2026-04-25 21:57:42 -07:00
8 changed files with 45 additions and 8 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -2870,7 +2870,6 @@ dependencies = [
"codex-plugin",
"codex-protocol",
"codex-rmcp-client",
"codex-utils-absolute-path",
"codex-utils-plugins",
"futures",
"pretty_assertions",

View File

@@ -101,7 +101,7 @@ members = [
resolver = "2"
[workspace.package]
version = "0.0.0"
version = "0.126.0-alpha.3"
# Track the edition for all workspace crates in one place. Individual
# crates can still override this value, but keeping it here means new
# crates created with `cargo new -w ...` automatically inherit the 2024

View File

@@ -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"}})
);
}

View File

@@ -81,6 +81,9 @@ pub enum ResponseEvent {
Completed {
response_id: String,
token_usage: Option<TokenUsage>,
/// 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<bool>,
},
OutputTextDelta(String),
ToolCallInputDelta {

View File

@@ -123,6 +123,8 @@ struct ResponseCompleted {
id: String,
#[serde(default)]
usage: Option<ResponseCompletedUsage>,
#[serde(default)]
end_turn: Option<bool>,
}
#[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"
);
}

View File

@@ -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:?}"),
}

View File

@@ -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()

View File

@@ -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,