From 166de33355188e7a81e52305c5fbe1475d9ff538 Mon Sep 17 00:00:00 2001 From: Kayphoon <109347466+Kayphoon@users.noreply.github.com> Date: Fri, 18 Sep 2026 18:44:28 +0000 Subject: [PATCH] fix(responses): keep raw reasoning on content only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/execution_runtime/grok.rs | 17 +- .../src/execution_runtime/stream/execution.rs | 6 +- .../src/formats/conversion/response.rs | 12 +- .../formats/src/formats/openai/chat/stream.rs | 228 ++++++++++-------- .../src/formats/openai/responses/mod.rs | 61 ++--- .../src/formats/openai/responses/response.rs | 5 +- .../shared/stream_core/format_matrix.rs | 2 +- .../src/formats/shared/sync_products.rs | 39 +-- .../formats/src/protocol/canonical.rs | 3 +- 9 files changed, 193 insertions(+), 180 deletions(-) diff --git a/apps/aether-gateway/src/execution_runtime/grok.rs b/apps/aether-gateway/src/execution_runtime/grok.rs index 340a5a5f3..53bb26ff4 100644 --- a/apps/aether-gateway/src/execution_runtime/grok.rs +++ b/apps/aether-gateway/src/execution_runtime/grok.rs @@ -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")); diff --git a/apps/aether-gateway/src/execution_runtime/stream/execution.rs b/apps/aether-gateway/src/execution_runtime/stream/execution.rs index d9d30a4c6..8b7948968 100644 --- a/apps/aether-gateway/src/execution_runtime/stream/execution.rs +++ b/apps/aether-gateway/src/execution_runtime/stream/execution.rs @@ -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!( diff --git a/crates/aether-ai/formats/src/formats/conversion/response.rs b/crates/aether-ai/formats/src/formats/conversion/response.rs index a10863e6b..41b7492d4 100644 --- a/crates/aether-ai/formats/src/formats/conversion/response.rs +++ b/crates/aether-ai/formats/src/formats/conversion/response.rs @@ -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!( diff --git a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs index 1261921ed..2d12729e2 100644 --- a/crates/aether-ai/formats/src/formats/openai/chat/stream.rs +++ b/crates/aether-ai/formats/src/formats/openai/chat/stream.rs @@ -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 { + 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::() +} + #[derive(Default)] pub struct OpenAIChatClientEmitter { response_id: Option, @@ -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, 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::(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::>() - ); + assert_eq!(response_sequence_numbers(&sse), (1..=7).collect::>()); } #[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(); diff --git a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs index 7e55a848a..e963df4b6 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/mod.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/mod.rs @@ -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>, -) -> (Value, Value) { - let texts: Vec = 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::>(); - let summary = texts - .iter() - .map(|text| json!({ "type": "summary_text", "text": text })) - .collect::>(); - (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, 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(), diff --git a/crates/aether-ai/formats/src/formats/openai/responses/response.rs b/crates/aether-ai/formats/src/formats/openai/responses/response.rs index 0d25d4c82..2b0f85163 100644 --- a/crates/aether-ai/formats/src/formats/openai/responses/response.rs +++ b/crates/aether-ai/formats/src/formats/openai/responses/response.rs @@ -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"); diff --git a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs index 680de2b08..4b05215a4 100644 --- a/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs +++ b/crates/aether-ai/formats/src/formats/shared/stream_core/format_matrix.rs @@ -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}"); diff --git a/crates/aether-ai/formats/src/formats/shared/sync_products.rs b/crates/aether-ai/formats/src/formats/shared/sync_products.rs index 8ebfeb2b3..0bc9b58a3 100644 --- a/crates/aether-ai/formats/src/formats/shared/sync_products.rs +++ b/crates/aether-ai/formats/src/formats/shared/sync_products.rs @@ -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, - 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"); } diff --git a/crates/aether-ai/formats/src/protocol/canonical.rs b/crates/aether-ai/formats/src/protocol/canonical.rs index 02de35d91..8a1abc49d 100644 --- a/crates/aether-ai/formats/src/protocol/canonical.rs +++ b/crates/aether-ai/formats/src/protocol/canonical.rs @@ -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");