fix(responses): keep raw reasoning on content only

Raw chain-of-thought was written to both `content` (`reasoning_text`) and
`summary` (`summary_text`), and the stream emitter sent the same delta on
`response.reasoning_text.delta` *and* `response.reasoning_summary_text.delta`.

Clients that render both channels therefore printed every thinking chunk
twice — most visibly the Codex CLI, whose thinking panel repeated itself.

OpenAI keeps the two channels distinct: `content` carries the raw CoT while
`summary` is the summarised view. Emit the thinking on `content` only:

- `openai_responses_reasoning_text_fields` becomes
  `openai_responses_reasoning_text_parts`, returning just the `content` array;
  reasoning items keep `summary: []` (or a provider-supplied summary).
- The Responses stream emitter emits `response.reasoning_text.delta` /
  `.done` and no longer mirrors them onto the summary events. The reasoning
  `output_item.added` no longer announces a `reasoning_summary_part`.
- The provider-state reasoning reader accepts `content` (`reasoning_text`)
  first and falls back to `summary`, so it also understands items produced by
  older Aether versions; its state field is renamed accordingly.
- Non-streaming builders (Chat -> Responses, manual Responses response, Grok
  gateway) place the thinking on `content` and leave `summary` empty.

Tests cover the raw thinking appearing exactly once in the emitted stream.
This commit is contained in:
Kayphoon
2026-09-18 18:44:28 +00:00
parent fb25dde4c9
commit 166de33355
9 changed files with 193 additions and 180 deletions
@@ -3203,10 +3203,7 @@ fn openai_responses_body(
"id": openai_responses_synthetic_reasoning_item_id(&response_id, 0),
"type": "reasoning",
"status": "completed",
"summary": [{
"type": "summary_text",
"text": thinking,
}],
"summary": [],
"content": [{
"type": "reasoning_text",
"text": thinking,
@@ -4640,10 +4637,7 @@ mod tests {
body["output"][0]["content"][0]["text"],
serde_json::json!("short reasoning")
);
assert_eq!(
body["output"][0]["summary"][0]["text"],
serde_json::json!("short reasoning")
);
assert_eq!(body["output"][0]["summary"], serde_json::json!([]));
assert_eq!(body["output"][1]["type"], serde_json::json!("message"));
assert!(body["output"][1]["id"]
.as_str()
@@ -4827,7 +4821,12 @@ mod tests {
assert!(body.contains("event: response.created"));
assert!(body.contains("event: response.in_progress"));
assert!(body.contains("event: response.reasoning_summary_part.added"));
// Thinking must stay off the summary channel or clients that render
// both (Codex) print the raw chain-of-thought twice.
assert!(!body.contains("event: response.reasoning_summary_part.added"));
assert!(!body.contains("event: response.reasoning_summary_text.delta"));
assert!(!body.contains("event: response.reasoning_summary_text.done"));
assert!(body.contains("\"type\":\"reasoning_text\""));
assert!(body.contains("event: response.content_part.added"));
assert!(body.contains("event: response.output_text.done"));
assert!(body.contains("event: response.completed"));
@@ -11880,7 +11880,11 @@ mod tests {
.expect("response body should read");
let body = String::from_utf8(body.to_vec()).expect("response body should be utf8");
assert!(
body.contains("event: response.reasoning_summary_text.delta\n"),
body.contains("event: response.reasoning_text.delta\n"),
"{body}"
);
assert!(
!body.contains("event: response.reasoning_summary_text.delta\n"),
"{body}"
);
assert!(
@@ -9,7 +9,7 @@ use serde_json::{json, Value};
use crate::formats::{
context::FormatContext,
openai::responses::{
openai_responses_message_item_id, openai_responses_reasoning_text_fields,
openai_responses_message_item_id, openai_responses_reasoning_text_parts,
openai_responses_synthetic_reasoning_item_id,
response::ensure_modern_openai_responses_response_fields,
},
@@ -206,12 +206,12 @@ pub fn build_openai_responses_response_with_content(
if trimmed.is_empty() {
continue;
}
let (content, summary) = openai_responses_reasoning_text_fields(std::iter::once(trimmed));
let content = openai_responses_reasoning_text_parts(std::iter::once(trimmed));
output.push(json!({
"type": "reasoning",
"id": openai_responses_synthetic_reasoning_item_id(response_id, index),
"status": "completed",
"summary": summary,
"summary": [],
"content": content,
}));
}
@@ -310,8 +310,7 @@ mod tests {
"reasoning_text"
);
assert_eq!(response["output"][0]["content"][0]["text"], "raw thinking");
assert_eq!(response["output"][0]["summary"][0]["type"], "summary_text");
assert_eq!(response["output"][0]["summary"][0]["text"], "raw thinking");
assert_eq!(response["output"][0]["summary"], json!([]));
assert_eq!(response["output"][1]["content"][0]["text"], "answer");
}
@@ -357,8 +356,7 @@ mod tests {
assert_eq!(item["type"], "reasoning");
assert_eq!(item["content"][0]["type"], "reasoning_text");
assert_eq!(item["content"][0]["text"], "compare the decimals");
assert_eq!(item["summary"][0]["type"], "summary_text");
assert_eq!(item["summary"][0]["text"], "compare the decimals");
assert_eq!(item["summary"], json!([]));
assert!(!item.get("content").unwrap().is_null());
assert_eq!(converted["output"][1]["type"], "message");
assert_eq!(
@@ -7,7 +7,7 @@ use crate::formats::openai::chat::response::openai_chat_reasoning_texts;
use crate::formats::openai::namespace::NamespaceToolAliases;
use crate::formats::openai::responses::{
encode_gemini_tool_signature_carrier_with_direction, openai_responses_message_item_id,
openai_responses_reasoning_text_fields, openai_responses_synthetic_reasoning_item_id,
openai_responses_reasoning_text_parts, openai_responses_synthetic_reasoning_item_id,
response::{
ensure_modern_openai_responses_response_fields, openai_responses_current_timestamp,
},
@@ -1213,40 +1213,13 @@ impl OpenAIResponsesProviderState {
if item.get("type").and_then(Value::as_str) != Some("reasoning") {
return;
}
let completed_reasoning = reasoning_item_text(item);
if self.terminal_only {
if item
.get("summary")
.and_then(Value::as_array)
.is_some_and(|summary| {
summary.iter().any(|part| {
part.get("type").and_then(Value::as_str) == Some("summary_text")
&& part
.get("text")
.and_then(Value::as_str)
.is_some_and(|text| !text.is_empty())
})
})
{
if !completed_reasoning.is_empty() {
self.ensure_started(report_context, out);
}
return;
}
let mut completed_reasoning = String::new();
for raw_summary in item
.get("summary")
.and_then(Value::as_array)
.into_iter()
.flatten()
{
let Some(summary) = raw_summary.as_object() else {
continue;
};
if summary.get("type").and_then(Value::as_str) == Some("summary_text") {
if let Some(text) = summary.get("text").and_then(Value::as_str) {
completed_reasoning.push_str(text);
}
}
}
if !completed_reasoning.is_empty() {
self.emit_missing_reasoning(report_context, out, &completed_reasoning);
}
@@ -1663,7 +1636,8 @@ impl OpenAIResponsesProviderState {
.unwrap_or_default();
if !piece.is_empty() {
let summary_index = value
.get("summary_index")
.get("content_index")
.or_else(|| value.get("summary_index"))
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
@@ -1698,7 +1672,8 @@ impl OpenAIResponsesProviderState {
.unwrap_or_default();
if !text.is_empty() {
let summary_index = value
.get("summary_index")
.get("content_index")
.or_else(|| value.get("summary_index"))
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(0);
@@ -2095,6 +2070,29 @@ impl OpenAIResponsesProviderState {
}
}
/// Reads a Responses reasoning item's raw chain-of-thought.
///
/// Raw thinking lives on `content` (`reasoning_text` parts); `summary` is the
/// summarised view and is only consulted when `content` carries nothing, so
/// items produced by other Aether versions still yield their thinking.
fn reasoning_item_text(item: &Map<String, Value>) -> String {
let mut text = reasoning_item_parts_text(item.get("content"), "reasoning_text");
if text.is_empty() {
text = reasoning_item_parts_text(item.get("summary"), "summary_text");
}
text
}
fn reasoning_item_parts_text(raw: Option<&Value>, expected_type: &str) -> String {
raw.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_object)
.filter(|part| part.get("type").and_then(Value::as_str) == Some(expected_type))
.filter_map(|part| part.get("text").and_then(Value::as_str))
.collect::<String>()
}
#[derive(Default)]
pub struct OpenAIChatClientEmitter {
response_id: Option<String>,
@@ -2677,12 +2675,14 @@ impl OpenAIResponsesClientEmitter {
}
fn reasoning_item_value(&self) -> Value {
let (content, summary) = openai_responses_reasoning_text_fields(self.reasoning_texts());
let content = openai_responses_reasoning_text_parts(self.reasoning_texts());
json!({
"type": "reasoning",
"id": self.reasoning_item_id(),
"status": "completed",
"summary": summary,
// Raw thinking goes on `content` only. Mirroring it onto `summary`
// makes Codex (which renders both channels) print it twice.
"summary": [],
"content": content,
})
}
@@ -2694,29 +2694,17 @@ impl OpenAIResponsesClientEmitter {
let item_id = self.reasoning_item_id();
let output_index = self.reasoning_output_index.unwrap_or(0);
let part_index = self.current_reasoning_summary_index();
let mut out = self.encode_response_event(
self.encode_response_event(
"response.reasoning_text.delta",
json!({
"type": "response.reasoning_text.delta",
"response_id": self.response_id(),
"item_id": item_id.clone(),
"item_id": item_id,
"output_index": output_index,
"content_index": part_index,
"delta": text,
}),
)?;
out.extend(self.encode_response_event(
"response.reasoning_summary_text.delta",
json!({
"type": "response.reasoning_summary_text.delta",
"response_id": self.response_id(),
"item_id": item_id,
"output_index": output_index,
"summary_index": part_index,
"delta": text,
}),
)?);
Ok(out)
)
}
fn encode_reasoning_text_done_events(
@@ -2726,7 +2714,7 @@ impl OpenAIResponsesClientEmitter {
part_index: usize,
part_text: &str,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut out = self.encode_response_event(
self.encode_response_event(
"response.reasoning_text.done",
json!({
"type": "response.reasoning_text.done",
@@ -2736,33 +2724,7 @@ impl OpenAIResponsesClientEmitter {
"content_index": part_index,
"text": part_text,
}),
)?;
out.extend(self.encode_response_event(
"response.reasoning_summary_text.done",
json!({
"type": "response.reasoning_summary_text.done",
"response_id": self.response_id(),
"item_id": item_id,
"output_index": output_index,
"summary_index": part_index,
"text": part_text,
}),
)?);
out.extend(self.encode_response_event(
"response.reasoning_summary_part.done",
json!({
"type": "response.reasoning_summary_part.done",
"response_id": self.response_id(),
"item_id": item_id,
"output_index": output_index,
"summary_index": part_index,
"part": {
"type": "summary_text",
"text": part_text,
}
}),
)?);
Ok(out)
)
}
fn ensure_message_output_index(&mut self) -> usize {
@@ -2827,21 +2789,6 @@ impl OpenAIResponsesClientEmitter {
self.reasoning_item_started = true;
}
if !self.reasoning_part_started {
let summary_index = self.current_reasoning_summary_index();
out.extend(self.encode_response_event(
"response.reasoning_summary_part.added",
json!({
"type": "response.reasoning_summary_part.added",
"response_id": self.response_id(),
"item_id": item_id,
"output_index": output_index,
"summary_index": summary_index,
"part": {
"type": "summary_text",
"text": "",
}
}),
)?);
self.reasoning_part_started = true;
}
Ok(out)
@@ -4291,7 +4238,7 @@ mod tests {
data = Some(value);
}
}
if event_name != Some("response.reasoning_summary_text.done") {
if event_name != Some("response.reasoning_text.done") {
continue;
}
let Some(data) = data else {
@@ -4300,13 +4247,17 @@ mod tests {
let Ok(value) = serde_json::from_str::<Value>(data) else {
continue;
};
let Some(summary_index) = value.get("summary_index").and_then(Value::as_u64) else {
let Some(part_index) = value
.get("content_index")
.or_else(|| value.get("summary_index"))
.and_then(Value::as_u64)
else {
continue;
};
let Some(text) = value.get("text").and_then(Value::as_str) else {
continue;
};
parts.push((summary_index, text.to_string()));
parts.push((part_index, text.to_string()));
}
parts
}
@@ -4527,7 +4478,8 @@ mod tests {
}
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("event: response.reasoning_summary_text.delta\n"));
assert!(sse.contains("event: response.reasoning_text.delta\n"));
assert!(!sse.contains("event: response.reasoning_summary_text.delta\n"));
assert!(sse.contains("\"delta\":\"Let\""));
assert!(sse.contains("\"delta\":\" me think.\""));
}
@@ -6596,20 +6548,19 @@ mod tests {
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
assert!(sse.contains("event: response.reasoning_summary_part.added\n"));
assert!(sse.contains("event: response.reasoning_text.delta\n"));
assert!(sse.contains("event: response.reasoning_summary_text.delta\n"));
assert!(sse.contains("event: response.reasoning_text.done\n"));
assert!(sse.contains("event: response.reasoning_summary_text.done\n"));
assert!(sse.contains("event: response.reasoning_summary_part.done\n"));
assert!(sse.contains("\"type\":\"reasoning_text\""));
// Raw chain-of-thought must not be duplicated onto the summary channel:
// Codex renders both, so emitting both makes the thinking panel repeat.
assert!(!sse.contains("event: response.reasoning_summary_text.delta\n"));
assert!(!sse.contains("event: response.reasoning_summary_text.done\n"));
assert!(!sse.contains("event: response.reasoning_summary_part.added\n"));
assert!(!sse.contains("event: response.reasoning_summary_part.done\n"));
let reasoning_item_id = openai_responses_synthetic_reasoning_item_id("resp_456", 0);
assert!(sse.contains(&format!("\"item_id\":\"{reasoning_item_id}\"")));
assert!(sse.contains("\"type\":\"reasoning\""));
assert_eq!(
response_sequence_numbers(&sse),
(1..=11).collect::<Vec<_>>()
);
assert_eq!(response_sequence_numbers(&sse), (1..=7).collect::<Vec<_>>());
}
#[test]
@@ -6678,6 +6629,75 @@ mod tests {
);
}
/// Regression: raw thinking must reach the client exactly once.
///
/// Codex renders both the `content` (`reasoning_text`) and `summary`
/// (`summary_text`) channels, so emitting the same chain-of-thought on both
/// made its thinking panel print every line twice.
#[test]
fn openai_responses_client_emitter_sends_raw_thinking_once() {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut bytes = emitter
.emit(CanonicalStreamFrame {
id: "resp_once".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::Start,
})
.expect("start should encode");
for text in ["Let", " me", " think."] {
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_once".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::ReasoningDelta(text.to_string()),
})
.expect("reasoning delta should encode"),
);
}
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_once".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::ReasoningSummaryDone,
})
.expect("reasoning boundary should encode"),
);
bytes.extend(
emitter
.emit(CanonicalStreamFrame {
id: "resp_once".to_string(),
model: "gpt-5.4".to_string(),
event: CanonicalStreamEvent::Finish {
finish_reason: Some("stop".to_string()),
usage: None,
},
})
.expect("finish should encode"),
);
let sse = String::from_utf8(bytes).expect("sse should be utf8");
// Each thinking chunk is streamed on exactly one channel. The same delta
// used to be mirrored onto `reasoning_summary_text.delta`, so clients that
// render both channels (Codex) printed every chunk twice.
assert_eq!(
sse.matches("event: response.reasoning_text.delta\n")
.count(),
3,
"one delta event per thinking chunk: {sse}"
);
assert!(!sse.contains("event: response.reasoning_summary_text.delta\n"));
assert!(!sse.contains("event: response.reasoning_summary_text.done\n"));
assert!(!sse.contains("\"type\":\"summary_text\""));
// The completed item carries the thinking on `content`, not `summary`.
assert!(
sse.contains("\"content\":[{\"type\":\"reasoning_text\",\"text\":\"Let me think.\"}]"),
"{sse}"
);
assert!(sse.contains("\"summary\":[]"), "{sse}");
}
#[test]
fn openai_responses_client_emitter_emits_failed_event_with_sequence_number() {
let mut emitter = OpenAIResponsesClientEmitter::default();
@@ -126,47 +126,39 @@ pub fn openai_responses_message_item_id(response_id: &str, output_index: usize)
)
}
/// Builds Responses reasoning `content` / `summary` arrays from raw thinking text.
/// Builds the Responses reasoning `content` array from raw thinking text.
///
/// OpenAI Responses semantics:
/// - `content` holds raw chain-of-thought as `reasoning_text` parts. Desktop UIs
/// (for example Codex) hide the thinking panel when `content` is null.
/// - `summary` holds `summary_text` parts for skim / CLI clients. When the
/// upstream only exposes raw thinking (DeepSeek `reasoning_content`, Gemini
/// thoughts, Claude thinking), the same text is copied into both so neither
/// client family loses the panel.
pub(crate) fn openai_responses_reasoning_text_fields(
/// Raw chain-of-thought belongs in `content` as `reasoning_text` parts. It is
/// deliberately *not* mirrored into `summary`: OpenAI keeps the two channels
/// distinct, and clients such as Codex render both, so duplicating the same
/// text onto `summary` made the thinking panel print everything twice.
pub(crate) fn openai_responses_reasoning_text_parts(
texts: impl IntoIterator<Item = impl AsRef<str>>,
) -> (Value, Value) {
let texts: Vec<String> = texts
.into_iter()
.map(|text| text.as_ref().to_string())
.filter(|text| !text.trim().is_empty())
.collect();
let content = texts
.iter()
.map(|text| json!({ "type": "reasoning_text", "text": text }))
.collect::<Vec<_>>();
let summary = texts
.iter()
.map(|text| json!({ "type": "summary_text", "text": text }))
.collect::<Vec<_>>();
(Value::Array(content), Value::Array(summary))
) -> Value {
Value::Array(
texts
.into_iter()
.map(|text| text.as_ref().to_string())
.filter(|text| !text.trim().is_empty())
.map(|text| json!({ "type": "reasoning_text", "text": text }))
.collect(),
)
}
/// Writes raw thinking onto a Responses reasoning item without clobbering an
/// existing structured summary or provider-owned content.
/// existing provider-owned summary or content.
pub(crate) fn apply_openai_responses_reasoning_text(item: &mut Map<String, Value>, text: &str) {
if text.trim().is_empty() {
return;
}
let (content, summary) = openai_responses_reasoning_text_fields(std::iter::once(text));
if reasoning_item_field_is_empty(item.get("content")) {
let content = openai_responses_reasoning_text_parts(std::iter::once(text));
item.insert("content".to_string(), content);
}
if reasoning_item_field_is_empty(item.get("summary")) {
item.insert("summary".to_string(), summary);
}
// `summary` stays a valid (empty) array so the item keeps its documented
// shape; a provider-supplied summary is preserved as-is.
item.entry("summary".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
}
fn reasoning_item_field_is_empty(value: Option<&Value>) -> bool {
@@ -540,21 +532,18 @@ mod tests {
}
#[test]
fn reasoning_text_fields_put_raw_thinking_in_content_and_summary() {
let (content, summary) = super::openai_responses_reasoning_text_fields(["raw chain"]);
fn reasoning_text_parts_put_raw_thinking_in_content_only() {
let content = super::openai_responses_reasoning_text_parts(["raw chain"]);
assert_eq!(
content,
json!([{ "type": "reasoning_text", "text": "raw chain" }])
);
assert_eq!(
summary,
json!([{ "type": "summary_text", "text": "raw chain" }])
);
let mut item = serde_json::Map::new();
super::apply_openai_responses_reasoning_text(&mut item, "raw chain");
assert_eq!(item["content"], content);
assert_eq!(item["summary"], summary);
// Never mirrored onto `summary`: clients rendering both would repeat it.
assert_eq!(item["summary"], json!([]));
item.insert(
"summary".to_string(),
@@ -786,7 +786,7 @@ mod tests {
}
#[test]
fn responses_response_builder_puts_raw_thinking_in_content_and_summary() {
fn responses_response_builder_puts_raw_thinking_in_content_only() {
let response = CanonicalResponse {
id: "resp_think".to_string(),
model: "deepseek-reasoner".to_string(),
@@ -814,8 +814,7 @@ mod tests {
assert_eq!(item["type"], "reasoning");
assert_eq!(item["content"][0]["type"], "reasoning_text");
assert_eq!(item["content"][0]["text"], "first add one to one");
assert_eq!(item["summary"][0]["type"], "summary_text");
assert_eq!(item["summary"][0]["text"], "first add one to one");
assert_eq!(item["summary"], json!([]));
assert!(!item["content"].is_null());
assert_eq!(body["output"][1]["type"], "message");
assert_eq!(body["output"][1]["content"][0]["text"], "2");
@@ -1053,7 +1053,7 @@ mod tests {
"{sse}"
);
assert!(
sse.contains("event: response.reasoning_summary_text.delta\n"),
!sse.contains("event: response.reasoning_summary_text.delta\n"),
"{sse}"
);
assert!(sse.contains("\"delta\":\"checking\""), "{sse}");
@@ -9,7 +9,7 @@ use aether_ai_formats::formats::conversion::response::{
};
use aether_ai_formats::formats::openai::responses::response::ensure_modern_openai_responses_response_fields;
use aether_ai_formats::formats::openai::responses::{
openai_responses_message_item_id, openai_responses_reasoning_text_fields,
openai_responses_message_item_id, openai_responses_reasoning_text_parts,
openai_responses_synthetic_reasoning_item_id,
};
use aether_ai_formats::formats::registry::{convert_response, FormatContext, FormatError};
@@ -2471,7 +2471,7 @@ fn aggregate_openai_responses_stream_sync_response_from_validated_terminal(
reasoning_states
.entry(output_index)
.or_default()
.summary_text
.reasoning_text
.push_str(delta);
}
"response.reasoning_text.done" | "response.reasoning_summary_text.done" => {
@@ -2799,7 +2799,7 @@ struct OpenAIResponsesSyncMessageState {
#[derive(Default)]
struct OpenAIResponsesSyncReasoningState {
item: Map<String, Value>,
summary_text: String,
reasoning_text: String,
}
#[derive(Default)]
@@ -3113,8 +3113,8 @@ fn merge_openai_responses_reasoning_text(
if text.is_empty() {
return;
}
if state.summary_text.is_empty() || text.len() >= state.summary_text.len() {
state.summary_text = text.to_string();
if state.reasoning_text.is_empty() || text.len() >= state.reasoning_text.len() {
state.reasoning_text = text.to_string();
}
}
@@ -3297,15 +3297,16 @@ fn materialize_openai_responses_reasoning_item(
});
item.entry("status".to_string())
.or_insert_with(|| Value::String("completed".to_string()));
if !state.summary_text.is_empty() {
let (content, summary) = openai_responses_reasoning_text_fields([&state.summary_text]);
if reasoning_item_field_missing_or_empty(item.get("content")) {
item.insert("content".to_string(), content);
}
if reasoning_item_field_missing_or_empty(item.get("summary")) {
item.insert("summary".to_string(), summary);
}
if !state.reasoning_text.is_empty()
&& reasoning_item_field_missing_or_empty(item.get("content"))
{
let content = openai_responses_reasoning_text_parts([&state.reasoning_text]);
item.insert("content".to_string(), content);
}
// Raw chain-of-thought lives on `content` only; never mirror it onto
// `summary`, or clients that render both channels show it twice.
item.entry("summary".to_string())
.or_insert_with(|| Value::Array(Vec::new()));
Value::Object(item)
}
@@ -5728,7 +5729,9 @@ mod tests {
.expect("modern response.done stream should aggregate");
assert_eq!(result["output"][0]["type"], "reasoning");
assert_eq!(result["output"][0]["summary"][0]["text"], "Need care");
assert_eq!(result["output"][0]["summary"], json!([]));
assert_eq!(result["output"][0]["content"][0]["type"], "reasoning_text");
assert_eq!(result["output"][0]["content"][0]["text"], "Need care");
assert!(result["output"].as_array().is_some());
assert_eq!(result["output_text"], "");
assert!(result["completed_at"].as_i64().is_some());
@@ -5754,7 +5757,7 @@ mod tests {
.as_object()
.expect("reasoning item should be an object")
.clone(),
summary_text: "must not replace provider-owned state".to_string(),
reasoning_text: "must not replace provider-owned state".to_string(),
};
let materialized = materialize_openai_responses_reasoning_item("resp_opaque_123", state);
@@ -5795,13 +5798,13 @@ mod tests {
}
#[test]
fn synthesizes_wire_compatible_id_for_local_reasoning_summary() {
fn synthesizes_wire_compatible_id_for_local_reasoning_text() {
let state = OpenAIResponsesSyncReasoningState {
item: json!({"type": "reasoning"})
.as_object()
.expect("reasoning item should be an object")
.clone(),
summary_text: "Need care".to_string(),
reasoning_text: "Need care".to_string(),
};
let materialized = materialize_openai_responses_reasoning_item("resp_summary_123", state);
@@ -5810,7 +5813,7 @@ mod tests {
materialized["id"],
openai_responses_synthetic_reasoning_item_id("resp_summary_123", 0)
);
assert_eq!(materialized["summary"][0]["text"], "Need care");
assert_eq!(materialized["summary"], json!([]));
assert_eq!(materialized["content"][0]["type"], "reasoning_text");
assert_eq!(materialized["content"][0]["text"], "Need care");
}
@@ -8412,7 +8412,8 @@ mod tests {
let rebuilt = canonical_to_openai_responses_request(&canonical, "gpt-5-upstream", false)
.expect("openai responses request");
assert_eq!(rebuilt["input"][0]["type"], "reasoning");
assert_eq!(rebuilt["input"][0]["summary"][0]["text"], "think");
assert_eq!(rebuilt["input"][0]["content"][0]["type"], "reasoning_text");
assert_eq!(rebuilt["input"][0]["content"][0]["text"], "think");
assert_eq!(rebuilt["input"][0]["encrypted_content"], "enc_reasoning");
assert_eq!(rebuilt["input"][1]["type"], "message");
assert_eq!(rebuilt["input"][1]["content"][0]["text"], "done");