diff --git a/src/diagnostic_log.rs b/src/diagnostic_log.rs index 9a7fcd3..97d1657 100644 --- a/src/diagnostic_log.rs +++ b/src/diagnostic_log.rs @@ -34,6 +34,10 @@ pub mod events { pub const STREAM_OPEN: &str = "codex_layer.stream_open"; pub const STREAM_COMPLETED: &str = "codex_layer.stream_completed"; pub const STREAM_FAILED: &str = "codex_layer.stream_failed"; + /// 空终帧护栏:静默终帧被扣下,正在向同一上游重发一轮。 + pub const EMPTY_FRAME_RETRY: &str = "codex_layer.empty_frame_retry"; + /// 空终帧护栏:重发额度用尽或重发通道不可用,静默终帧被原样放行。 + pub const EMPTY_FRAME_EXHAUSTED: &str = "codex_layer.empty_frame_exhausted"; /// 本族登记表:消费侧对齐测试与验收判据的完整性检查用。 pub const ALL: &[&str] = &[ STARTED, @@ -48,6 +52,8 @@ pub mod events { STREAM_OPEN, STREAM_COMPLETED, STREAM_FAILED, + EMPTY_FRAME_RETRY, + EMPTY_FRAME_EXHAUSTED, ]; } diff --git a/src/main.rs b/src/main.rs index 73bb308..aa7c3d6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,7 @@ use codex_layer::protocol_proxy::{ open_models_proxy_request, open_responses_proxy_request_for_path_with_session_headers, responses_json_to_sse_events, responses_error_from_upstream, wrap_non_stream_response_as_compaction, upstream_conversion_error_response, + EMPTY_FRAME_MAX_RETRIES, with_empty_frame_nudge, }; use codex_layer::stability::{self, StabilityAggregateOptions}; use codex_layer::stability_ui; @@ -836,6 +837,50 @@ async fn responses_proxy( events::layer::STREAM_OPEN, json!({ "path": path, "wireApi": format!("{:?}", upstream.wire_api), "compaction": compaction_round || upstream.compaction, "requestId": request_id.clone() }), ); + // 空终帧护栏的重发通道:四个条件齐了才开——Chat 方言(终帧判定与重发 + // 在那里才有意义)、非压缩轮(含降级轮,压缩轮有自己的收口账)、客户端 + // 请求解析得动(重发要靠它重建请求体)。 + let retry_opener: Option = + if upstream.wire_api == UpstreamWireApi::ChatCompletions + && !compaction_round + && !upstream.compaction + && request_json.is_some() + { + let user_agent = user_agent.map(str::to_string); + let beta_features = beta_features.map(str::to_string); + let path = path.to_string(); + let session_id = session_headers.session_id.map(str::to_string); + let thread_id = session_headers.thread_id.map(str::to_string); + let opencode_session = session_headers.opencode_session.map(str::to_string); + Some(Box::new(move |body: Value| { + // 请求体由调用方每轮现给(同体或带推动文案的变体), + // 其余装配参数原样复制。 + let body = body.to_string(); + let user_agent = user_agent.clone(); + let beta_features = beta_features.clone(); + let path = path.clone(); + let session_id = session_id.clone(); + let thread_id = thread_id.clone(); + let opencode_session = opencode_session.clone(); + Box::pin(async move { + let session = ProxySessionHeaders { + session_id: session_id.as_deref(), + thread_id: thread_id.as_deref(), + opencode_session: opencode_session.as_deref(), + }; + open_responses_proxy_request_for_path_with_session_headers( + &body, + user_agent.as_deref(), + &path, + beta_features.as_deref(), + session, + ) + .await + }) + })) + } else { + None + }; return sse_response(stream_from_upstream( upstream, request_json, @@ -843,6 +888,7 @@ async fn responses_proxy( request_id, drain, compaction_round, + retry_opener, )); } @@ -1046,6 +1092,15 @@ fn non_stream_native_compaction_response( } } +/// 空终帧护栏的重发通道:拿本轮要发的请求体(同体重采样或带推动文案的 +/// 变体),用装配层原始参数重新发出一次同客户端轮的上游请求。future 抠成 +/// 'static 装箱,流泵才能就地持有、反复重开。 +type StreamRetryOpener = Box< + dyn Fn(Value) -> futures_util::future::BoxFuture<'static, anyhow::Result> + + Send + + Sync, +>; + /// 把上游回包变成一条 Responses 事件流:压缩轮、Responses 方言、Chat 方言三条分支。 fn stream_from_upstream( upstream: UpstreamProxyResponse, @@ -1054,6 +1109,7 @@ fn stream_from_upstream( request_id: String, drain: DrainState, compaction_round: bool, + retry_opener: Option, ) -> impl futures_util::Stream + Send + 'static { let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); let wire_api = upstream.wire_api; @@ -1238,7 +1294,17 @@ fn stream_from_upstream( .as_ref() .map(ChatSseToResponsesConverter::with_request) .unwrap_or_default(); + // 空终帧护栏:只在「自然收口」处多一个扣帧重发的决策点, + // 失败与排空两条路径与原实现逐字节一致。护栏开关只在重发通道 + // 存在时打开(装配层已验过四个条件)。 + converter.set_guard_enabled(retry_opener.is_some()); + let mut retries_used = 0usize; let mut drained = false; + // 排空截止用装箱 future:重试轮重开上游流后要换新的截止, + // 已命中的截止不可能被重试轮再踩一次。 + let mut drain_wait: std::pin::Pin< + std::boxed::Box + Send + '_>, + > = Box::pin(drain.wait_deadline()); loop { let next = tokio::select! { next = chunks.next() => next, @@ -1261,6 +1327,73 @@ fn stream_from_upstream( ); return; } + if converter.terminal_deferred() { + // [DONE] 把终帧顶了出来又被护栏扣下:走重试决策。重开 + // 成功就换新的上游流继续泵(第二次的条目续排同一条 + // 出流);额度用尽或重开失败则把扣下的帧原样放行后 + // 断泵,走既有的收口诊断。绝不造成「无 completed 也无 + // failed」的静默断流。 + if retries_used >= EMPTY_FRAME_MAX_RETRIES { + log_event( + events::layer::EMPTY_FRAME_EXHAUSTED, + json!({ "requestId": request_id, "attempts": retries_used }), + ); + let released = converter.release_deferred_terminal(); + if !released.is_empty() + && tx.send(Ok(Bytes::from(released))).is_err() + { + return; + } + break; + } + let opener = retry_opener + .as_ref() + .expect("护栏开关打开时重发通道必在"); + // 第一次重试同体重采样(前缀全命中,纯赌温度);第二次 + // 带推动文案。推动项拼不进 input(形态不认识)就原样 + // 重发,绝不自造请求结构。 + let same_body = request_json + .clone() + .expect("护栏开关打开时客户端请求必解析得动"); + let nudged = retries_used >= 1; + let retry_body = if nudged { + with_empty_frame_nudge(&same_body).unwrap_or(same_body) + } else { + same_body + }; + retries_used += 1; + log_event( + events::layer::EMPTY_FRAME_RETRY, + json!({ "requestId": request_id, "attempt": retries_used, "nudged": nudged }), + ); + match opener(retry_body).await { + Ok(opened) if opened.is_success() => { + converter.begin_retry_attempt(); + chunks = opened.response.bytes_stream(); + drain_wait = Box::pin(drain.wait_deadline()); + continue; + } + other => { + // 重发通道自己失败:扣下的帧放行、照常收口; + // 日志要能分清「重发没开门」还是「又静默」。 + let reason = match &other { + Ok(opened) => { + format!("重发上游返回状态码 {}", opened.status_code) + } + Err(error) => error.to_string(), + }; + log_event( + events::layer::EMPTY_FRAME_EXHAUSTED, + json!({ "requestId": request_id, "attempts": retries_used, "reason": reason }), + ); + let released = converter.release_deferred_terminal(); + if !released.is_empty() { + let _ = tx.send(Ok(Bytes::from(released))); + } + break; + } + } + } } Err(error) => { let failed = converter.fail( @@ -1276,29 +1409,36 @@ fn stream_from_upstream( } } } + // 排空截止点命中(issue #34):与「完成事件前终止」语义相同,措辞与 + // error_type 与上游断流区分开。护栏在这里不插嘴:这一轮反正按截断收口; + // 若还扣着终帧先放行——客户端至少先拿到完整的「上游说完了」,再收到 + // 「被截断」的显式信号。 + if drained { + if converter.terminal_deferred() { + let _ = tx.send(Ok(Bytes::from(converter.release_deferred_terminal()))); + } + let failed = converter.fail( + "服务器关停:排空截止点到达,上游响应流被截断".to_string(), + Some("drain_deadline".to_string()), + ); + let _ = tx.send(Ok(Bytes::from(failed))); + log_event( + events::layer::STREAM_FAILED, + json!({ "reason": "关停排空截止点到达,提前收口", "requestId": request_id }), + ); + return; + } // EOF 不是 Chat 协议的完成事件:已发出的文本和工具增量不能自动重放, // 必须显式补一个 failed 事件,否则客户端会永远等下去。 - // 排空截止点命中:语义相同(都是「完成事件前终止」),措辞与 - // error_type 区分开,现场要能分清是上游断的还是本机在关停。 if !converter.has_terminal_event() { - let (message, error_type, reason) = if drained { - ( - "服务器关停:排空截止点到达,上游响应流被截断", - "drain_deadline", - "关停排空截止点到达,提前收口", - ) - } else { - ( - "上游在完成事件前结束了响应流", - "stream_incomplete", - "上游缺少完成事件", - ) - }; - let failed = converter.fail(message.to_string(), Some(error_type.to_string())); + let failed = converter.fail( + "上游在完成事件前结束了响应流".to_string(), + Some("stream_incomplete".to_string()), + ); let _ = tx.send(Ok(Bytes::from(failed))); log_event( events::layer::STREAM_FAILED, - json!({ "reason": reason, "requestId": request_id }), + json!({ "reason": "上游缺少完成事件", "requestId": request_id }), ); return; } diff --git a/src/protocol_proxy.rs b/src/protocol_proxy.rs index f397fb6..1950f5e 100644 --- a/src/protocol_proxy.rs +++ b/src/protocol_proxy.rs @@ -1043,6 +1043,13 @@ pub struct ChatSseToResponsesConverter { utf8_remainder: Vec, state: ChatSseState, failed: bool, + /// 空终帧护栏开关。只有装配层能在「本轮还能向同一上游重发」时打开 + /// (Chat 方言 + 客户端原请求在手 + 重发通道已注入);裸 converter 恒关, + /// 既有行为一个字节不变。 + guard_enabled: bool, + /// 被护栏扣下的终帧(completed 事件与 `[DONE]`):本轮判成静默终帧且还允许 + /// 重试时先不下发,等装配层决定「重开一轮」还是「原样放行」。 + deferred_terminal: Option, } /// 以下是 Responses 原生压缩轮(`compaction_trigger`)的探针判据。 @@ -2641,6 +2648,8 @@ impl Default for ChatSseToResponsesConverter { utf8_remainder: Vec::new(), state: ChatSseState::default(), failed: false, + guard_enabled: false, + deferred_terminal: None, } } } @@ -2667,6 +2676,33 @@ impl ChatSseToResponsesConverter { } } + /// 开关空终帧护栏。判断「本轮能不能重发」是装配层的事(它才知道请求体、 + /// 重发通道与本轮是不是压缩轮),转换层只负责照开关行事。 + pub fn set_guard_enabled(&mut self, enabled: bool) { + self.guard_enabled = enabled; + } + + /// 护栏是否正扣着终帧。装配层据此判断「上游流已自然收口,但 completed 还没 + /// 出门」:true 就去走重试决策,false 就是正常完成。 + pub fn terminal_deferred(&self) -> bool { + self.deferred_terminal.is_some() + } + + /// 放弃重试:把扣下的终帧原样吐出(completed 事件与 `[DONE]` 一起), + /// 这一轮按原行为收口。扣帧只可能有一次,吐出即清空,绝不清空成「静默断流」。 + pub fn release_deferred_terminal(&mut self) -> Vec { + self.deferred_terminal.take().unwrap_or_default().into_bytes() + } + + /// 重开一轮重试:丢弃扣下的终帧,重置本轮的条目槽位(reasoning / 正文 / + /// 工具增量 / inline think / finish_reason / usage),但保留响应身份、 + /// output_index 计数与已定稿的 output_items——第二次的条目续排在同一个 + /// response 里,客户端看到的始终是一条连续、单一的 Responses 事件流。 + pub fn begin_retry_attempt(&mut self) { + self.deferred_terminal = None; + self.state.begin_retry_attempt(); + } + pub fn push_bytes(&mut self, bytes: &[u8]) -> Vec { append_utf8_safe(&mut self.buffer, &mut self.utf8_remainder, bytes); let mut output = String::new(); @@ -2691,7 +2727,7 @@ impl ChatSseToResponsesConverter { let mut output = String::new(); if !self.failed { - self.state.finalize_into(&mut output); + self.finalize_guarded(&mut output); } output.into_bytes() } @@ -2720,7 +2756,7 @@ impl ChatSseToResponsesConverter { } let data = data_parts.join("\n"); if data.trim() == "[DONE]" { - self.state.finalize_into(output); + self.finalize_guarded(output); return; } @@ -2735,6 +2771,95 @@ impl ChatSseToResponsesConverter { } self.state.handle_chat_chunk_into(&chunk, output); } + + /// finalize 的护栏版:先把终帧收进 scratch,再决定出门还是扣下。 + /// 只有「开关开 + 本轮判成静默终帧 + 此前没扣过帧」才扣;其余一律原样下发。 + /// `finalize_into` 自带 completed 幂等,扣帧后再次到达 [DONE]/EOF 不会 + /// 重复产出终帧(scratch 为空,直接透传空串)。 + fn finalize_guarded(&mut self, output: &mut String) { + let mut scratch = String::new(); + self.state.finalize_into(&mut scratch); + if scratch.is_empty() || !self.guard_enabled || self.deferred_terminal.is_some() { + output.push_str(&scratch); + return; + } + if self.state.empty_final_frame() { + self.deferred_terminal = Some(scratch); + } else { + output.push_str(&scratch); + } + } +} + +/// 空终帧护栏的单客户端轮重试上限:对同一次上游调用的最坏重发次数。 +/// 上限存在的意义是让「模型反复静默」的病态循环有代价上界,而不是无限重放。 +pub const EMPTY_FRAME_MAX_RETRIES: usize = 2; + +/// 第二次重试的推动文案:同体重采样(第一次重试)可能再次静默,第二次在 +/// `input` 尾部显式要求「可见正文或工具调用」。只拼进重发请求,客户端历史里 +/// 不会出现这条消息。 +pub const EMPTY_FRAME_NUDGE_TEXT: &str = "系统提示:你上一段输出只包含思考内容,既无正文回复也无工具调用,会话因此被自动结束。请现在立即行动:要么给出面向用户的正文,要么发起必要的工具调用;不允许再次只输出思考内容。"; + +/// 空终帧护栏的判据本体(纯函数):finalize 后的「completed」终帧是不是 +/// 静默帧——没有任何工具调用项、也没有非空可见正文,只有 reasoning(或什么都没有)。 +/// codex 客户端会把这种终帧判成「本轮无动作」并立即交回控制权,这正是 +/// 「会话自动结束」在协议层的投影。 +/// +/// 三条边界: +/// - `incomplete` 终态(finish_reason=length)不算静默:那是合法的截断信号, +/// 客户端自己看得见,轮不到护栏替它决定; +/// - 除 reasoning / message 外的任何条目形态(工具调用、原生搜索项等)一律 +/// 算有效输出:判不准就不重发,宁可不护不可误护; +/// - 空条目列表算静默:上游直接 [DONE] 什么也没交付,同样是静默收尾。 +pub fn silent_final_frame(items: &[Value], finish_reason: Option<&str>) -> bool { + if response_status(finish_reason) != "completed" { + return false; + } + items.iter().all(|item| { + match item.get("type").and_then(Value::as_str) { + // reasoning 不是可见输出,客户端不会为它续轮。 + Some("reasoning") => true, + Some("message") => !message_item_has_visible_text(item), + _ => false, + } + }) +} + +/// message 项是否携带非空可见正文。读不出 content 数组时按「无正文」处理: +/// 宁可多重试一次,也不能把读得出的静默帧漏判成有效输出。 +fn message_item_has_visible_text(item: &Value) -> bool { + item.get("content") + .and_then(Value::as_array) + .is_some_and(|parts| { + parts.iter().any(|part| { + part.get("type") + .and_then(Value::as_str) + == Some("output_text") + && part + .get("text") + .and_then(Value::as_str) + .is_some_and(|text| !text.trim().is_empty()) + }) + }) +} + +/// 第二次重试的推动请求体:克隆客户端原请求,往 `input` 尾部追加一条用户 +/// 消息(形态与既有压缩摘要指令同款)。`input` 不是数组时返回 None——没有 +/// 可追加的位置,调用方退化为同体重采样,绝不自造 `input` 结构。 +pub fn with_empty_frame_nudge(request_json: &Value) -> Option { + if !request_json.get("input").is_some_and(Value::is_array) { + return None; + } + let mut nudged = request_json.clone(); + nudged["input"] + .as_array_mut() + .expect("input 已判过是数组") + .push(json!({ + "type": "message", + "role": "user", + "content": [{ "type": "input_text", "text": EMPTY_FRAME_NUDGE_TEXT }], + })); + Some(nudged) } pub fn is_responses_proxy_path(path: &str) -> bool { @@ -4700,6 +4825,35 @@ impl ChatSseState { output_items.into_iter().map(|(_, item)| item).collect() } + /// finalize 之后的终帧是不是静默终帧(判据本体在纯函数 `silent_final_frame`)。 + /// failed 收口的终帧不属护栏管辖:那是显式终止信号,客户端看得见。 + fn empty_final_frame(&self) -> bool { + self.completed + && !self.terminal_failed + && silent_final_frame( + &self.completed_output_items(), + self.finish_reason.as_deref(), + ) + } + + /// 重开一轮重试的状态侧重置。保留与丢弃的字段同等重要: + /// - 保留 `response_started`:created/in_progress 已经出过门,重试轮的条目 + /// 必须续排在同一个 response 身份下; + /// - 保留 `next_output_index` 与 `output_items`:重试轮的新条目不能复用已 + /// 发出的 output_index,第一轮的 reasoning 项也要留在终帧 output 里; + /// - 丢弃 `latest_usage`:重试轮的 usage 才是交付物的账;被丢弃的静默轮 + /// 由诊断事件单独记账,两套账不混。 + fn begin_retry_attempt(&mut self) { + self.completed = false; + self.terminal_failed = false; + self.text = TextItemState::default(); + self.reasoning = ReasoningItemState::default(); + self.inline_think = InlineThinkState::default(); + self.tools.clear(); + self.finish_reason = None; + self.latest_usage = None; + } + fn base_response(&self, status: &str, output: Vec) -> Value { json!({ "id": self.response_id, @@ -10054,3 +10208,203 @@ mod proxy_port_env_tests { assert_eq!(detail.get("conflict"), Some(&json!(true))); } } + +#[cfg(test)] +mod empty_frame_guard_tests { + use super::*; + + fn chat_sse_bytes(chunks: &[&str]) -> Vec { + let mut raw = String::new(); + for chunk in chunks { + raw.push_str("data: "); + raw.push_str(chunk); + raw.push_str("\n\n"); + } + raw.into_bytes() + } + + fn reasoning_chunk() -> &'static str { + "{\"id\":\"c1\",\"model\":\"m\",\"choices\":[{\"delta\":{\"reasoning_content\":\"先想一段\"}}]}" + } + + fn done_with_stop() -> &'static str { + "{\"id\":\"c1\",\"choices\":[{\"delta\":{},\"finish_reason\":\"stop\"}]}" + } + + #[test] + fn silent_verdict_covers_reasoning_only_and_empty_but_not_real_output() { + // 判据本体的分界线:reasoning 与「什么都没有」算静默;可见正文、 + // 工具调用、未知条目形态、length 截断都不算。 + assert!(silent_final_frame(&[], None)); + assert!(silent_final_frame( + &[json!({ "id": "rs_1", "type": "reasoning" })], + Some("stop"), + )); + assert!(silent_final_frame( + &[json!({ + "id": "msg_1", "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": " " }], + })], + Some("stop"), + )); + assert!(!silent_final_frame( + &[json!({ + "id": "msg_1", "type": "message", "role": "assistant", + "content": [{ "type": "output_text", "text": "有正文" }], + })], + Some("stop"), + )); + assert!(!silent_final_frame( + &[json!({ "type": "function_call", "name": "shell" })], + Some("stop"), + )); + assert!(!silent_final_frame( + &[json!({ "type": "web_search_call" })], + Some("stop"), + )); + // length 截断是 incomplete 终态:合法截断,护栏不接管。 + assert!(!silent_final_frame(&[], Some("length"))); + } + + #[test] + fn nudge_appends_to_input_array_without_touching_original() { + let original = json!({ + "model": "m", + "input": [{ "type": "message", "role": "user", "content": [{ "type": "input_text", "text": "原话" }] }], + }); + let nudged = with_empty_frame_nudge(&original).expect("input 数组必须有可追加位置"); + assert_eq!(original["input"].as_array().unwrap().len(), 1); + let items = nudged["input"].as_array().unwrap(); + assert_eq!(items.len(), 2); + assert_eq!(items[1]["role"], json!("user")); + + // 推动项必须是转换层认得的 message/input_text 形态:端到端过一遍 + // Responses→Chat 转换,它得原样落成最后一条 user 消息。 + let converted = responses_to_chat_completions(nudged).unwrap(); + let messages = converted["messages"].as_array().unwrap(); + let last = messages.last().unwrap(); + assert_eq!(last["role"], json!("user")); + assert_eq!(last["content"].as_str().unwrap(), EMPTY_FRAME_NUDGE_TEXT); + } + + #[test] + fn nudge_gives_up_when_input_is_not_an_array() { + // 没有可追加的位置就绝不自造 input 结构,交由调用方退化为同体重发。 + assert!(with_empty_frame_nudge(&json!({ "input": "纯字符串" })).is_none()); + assert!(with_empty_frame_nudge(&json!({ "model": "m" })).is_none()); + } + + #[test] + fn guard_off_keeps_legacy_behavior_byte_for_byte() { + // 开关不开时,静默终帧也照常即刻下发——既有行为零变化是本护栏的第一条红线。 + let mut converter = ChatSseToResponsesConverter::default(); + let out = converter.push_bytes(&chat_sse_bytes(&[reasoning_chunk(), done_with_stop(), "[DONE]"])); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("response.completed")); + assert!(text.contains("data: [DONE]")); + assert!(!converter.terminal_deferred()); + } + + #[test] + fn guard_defers_silent_frame_and_release_restores_it() { + let mut converter = ChatSseToResponsesConverter::default(); + converter.set_guard_enabled(true); + let out = + converter.push_bytes(&chat_sse_bytes(&[reasoning_chunk(), done_with_stop(), "[DONE]"])); + let streamed = String::from_utf8(out).unwrap(); + // reasoning 增量照常出门,终帧被扣下。 + assert!(streamed.contains("response.created")); + assert!(!streamed.contains("response.completed")); + assert!(converter.terminal_deferred()); + // finish() 不得重复产帧(completed 幂等 + 已扣帧不二次扣)。 + assert!(converter.finish().is_empty()); + let released = String::from_utf8(converter.release_deferred_terminal()).unwrap(); + assert!(released.contains("response.completed")); + assert!(released.contains("data: [DONE]")); + assert!(!converter.terminal_deferred()); + } + + #[test] + fn guard_never_defers_non_silent_frames() { + // 正文帧:原样即刻下发,不扣。 + let mut converter = ChatSseToResponsesConverter::default(); + converter.set_guard_enabled(true); + let out = converter.push_bytes(&chat_sse_bytes(&[ + "{\"id\":\"c1\",\"choices\":[{\"delta\":{\"content\":\"交付正文\"}}]}", + done_with_stop(), + "[DONE]", + ])); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("response.completed")); + assert!(!converter.terminal_deferred()); + + // length 截断帧:incomplete 终态,同样不扣。 + let mut truncated = ChatSseToResponsesConverter::default(); + truncated.set_guard_enabled(true); + let out = truncated.push_bytes(&chat_sse_bytes(&[ + "{\"id\":\"c2\",\"choices\":[{\"delta\":{},\"finish_reason\":\"length\"}]}", + "[DONE]", + ])); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("response.completed")); + assert!(text.contains("incomplete_details")); + assert!(!truncated.terminal_deferred()); + } + + #[test] + fn retry_attempt_continues_the_same_response_stream() { + // 端到端三步:静默轮被扣 → 重开重试 → 第二次的条目续排同一条流, + // 终帧 output 同时带上第一轮的 reasoning 与第二轮的正文。 + let mut converter = ChatSseToResponsesConverter::default(); + converter.set_guard_enabled(true); + let _ = converter.push_bytes(&chat_sse_bytes(&[reasoning_chunk(), done_with_stop(), "[DONE]"])); + assert!(converter.terminal_deferred()); + + converter.begin_retry_attempt(); + assert!(!converter.terminal_deferred()); + let out = converter.push_bytes(&chat_sse_bytes(&[ + "{\"id\":\"c9\",\"model\":\"m\",\"choices\":[{\"delta\":{\"content\":\"这是重试后的正文\"}}]}", + done_with_stop(), + "[DONE]", + ])); + let text = String::from_utf8(out).unwrap(); + assert!(text.contains("response.completed")); + assert!(!text.contains("response.created"), "created 不得第二次出现"); + assert!(!converter.terminal_deferred()); + + // 终帧 output 的完整形态:两轮条目都在,且各占独立的 output_index。 + let completed = text + .lines() + .find(|line| line.contains("\"response.completed\"")) + .and_then(|line| line.strip_prefix("data: ")) + .unwrap(); + let payload: Value = serde_json::from_str(completed).unwrap(); + let output = payload["response"]["output"].as_array().unwrap(); + let kinds: Vec<&str> = output + .iter() + .map(|item| item["type"].as_str().unwrap()) + .collect(); + assert_eq!(kinds, vec!["reasoning", "message"]); + assert_eq!(output[1]["content"][0]["text"], json!("这是重试后的正文")); + let indices: Vec = output + .iter() + .map(|item| item["id"].as_str().unwrap().to_string()) + .enumerate() + .map(|(index, _)| index as u32) + .collect(); + assert_eq!(indices, vec![0, 1]); + } + + #[test] + fn exhausted_guard_releases_the_original_frame_untouched() { + // 重发窗口失败/额度用尽时走 release:放行的必须是扣下的那一帧, + // 客户端永远不会拿到「既无 completed 也无 failed」的静默断流。 + let mut converter = ChatSseToResponsesConverter::default(); + converter.set_guard_enabled(true); + let _ = converter.push_bytes(&chat_sse_bytes(&["[DONE]"])); + assert!(converter.terminal_deferred()); + let released = String::from_utf8(converter.release_deferred_terminal()).unwrap(); + assert!(released.contains("response.completed")); + assert!(released.contains("data: [DONE]")); + } +}