fix(codex-chat): attach reasoning forward for responses bridge (#5508)

* fix(codex-chat): attach reasoning forward for responses bridge

* fix(codex-chat): preserve tail reasoning after embedded reasoning

* docs(codex-chat): align trailing reasoning comments
This commit is contained in:
ka79376046
2026-07-19 11:39:29 +08:00
committed by GitHub
parent 997be22bfa
commit 613fef70bc
@@ -586,6 +586,15 @@ fn append_responses_input_as_chat_messages(
&mut pending_reasoning, &mut pending_reasoning,
&mut last_assistant_index, &mut last_assistant_index,
); );
// 整个 input 处理完毕后仍剩余的 pending reasoning 属于「真正的尾部」思考
// (其后已没有任何可前向附挂的 message / function_call),回溯附挂到最后一条
// assistant;目标已有 reasoning_content 时追加,以保留同一 turn 的 embedded
// reasoning 与 trailing reasoning。
attach_pending_reasoning_to_previous_assistant(
messages,
last_assistant_index,
&mut pending_reasoning,
);
backfill_tool_call_reasoning_placeholders(messages); backfill_tool_call_reasoning_placeholders(messages);
Ok(()) Ok(())
} }
@@ -650,12 +659,14 @@ fn append_responses_item_as_chat_message(
})); }));
} }
Some("reasoning") => { Some("reasoning") => {
let reasoning = responses_reasoning_item_text(item); // reasoning 一律先进入 pending_reasoning,前向附挂到其后的
let attached_to_previous = pending_tool_calls.is_empty() // message / function_call(后者经 flush_pending_tool_calls 消费)。
&& attach_reasoning_to_last_assistant(messages, *last_assistant_index, &reasoning); // 此前这里在 pending_tool_calls 为空时直接回溯附挂到上一条 assistant,
if !attached_to_previous { // 会把新一轮的思考错拼进旧消息,导致紧跟的纯文本 assistant 丢失
append_pending_reasoning(pending_reasoning, reasoning); // reasoning_content,思考型模型(kimi 等)多轮对话因此中途"断片"。
} // 真正的尾部剩余由 input 结束时的收尾逻辑、或回合边界消息(user 等)
// 到达时回溯附挂,见 attach_pending_reasoning_to_previous_assistant。
append_pending_reasoning(pending_reasoning, responses_reasoning_item_text(item));
} }
Some("input_text" | "input_image" | "input_file" | "input_audio") => { Some("input_text" | "input_image" | "input_file" | "input_audio") => {
flush_pending_tool_calls( flush_pending_tool_calls(
@@ -679,8 +690,16 @@ fn append_responses_item_as_chat_message(
update_last_assistant_index(messages, &message, last_assistant_index); update_last_assistant_index(messages, &message, last_assistant_index);
messages.push(message); messages.push(message);
return Ok(()); return Ok(());
} else if pending_reasoning.is_some() { } else {
pending_reasoning.take(); // 非 assistant 的回合边界消息(user 等):pending reasoning 不再直接
// 丢弃,优先回溯附挂到上一条 assistant;其已有 reasoning_content 时
// 追加尾部 reasoning。reasoning 不允许跨 user 回合泄漏到之后的
// assistant 消息;无上一条 assistant 可附挂时自然丢弃(等同原行为)。
attach_pending_reasoning_to_previous_assistant(
messages,
*last_assistant_index,
pending_reasoning,
);
} }
update_last_assistant_index(messages, &message, last_assistant_index); update_last_assistant_index(messages, &message, last_assistant_index);
messages.push(message); messages.push(message);
@@ -693,7 +712,12 @@ fn append_responses_item_as_chat_message(
last_assistant_index, last_assistant_index,
); );
if item.get("role").is_some() || item.get("content").is_some() { if item.get("role").is_some() || item.get("content").is_some() {
let message = responses_message_item_to_chat_message(item, pending_reasoning); let message = responses_message_item_to_chat_message(
item,
pending_reasoning,
messages,
*last_assistant_index,
);
update_last_assistant_index(messages, &message, last_assistant_index); update_last_assistant_index(messages, &message, last_assistant_index);
messages.push(message); messages.push(message);
} }
@@ -706,7 +730,12 @@ fn append_responses_item_as_chat_message(
last_assistant_index, last_assistant_index,
); );
if item.get("role").is_some() || item.get("content").is_some() { if item.get("role").is_some() || item.get("content").is_some() {
let message = responses_message_item_to_chat_message(item, pending_reasoning); let message = responses_message_item_to_chat_message(
item,
pending_reasoning,
messages,
*last_assistant_index,
);
update_last_assistant_index(messages, &message, last_assistant_index); update_last_assistant_index(messages, &message, last_assistant_index);
messages.push(message); messages.push(message);
} }
@@ -739,6 +768,8 @@ fn flush_pending_tool_calls(
fn responses_message_item_to_chat_message( fn responses_message_item_to_chat_message(
item: &Value, item: &Value,
pending_reasoning: &mut Option<String>, pending_reasoning: &mut Option<String>,
messages: &mut [Value],
last_assistant_index: Option<usize>,
) -> Value { ) -> Value {
let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user"); let role = item.get("role").and_then(|v| v.as_str()).unwrap_or("user");
let chat_role = responses_role_to_chat_role(role); let chat_role = responses_role_to_chat_role(role);
@@ -755,8 +786,15 @@ fn responses_message_item_to_chat_message(
if chat_role == "assistant" { if chat_role == "assistant" {
append_pending_reasoning(pending_reasoning, responses_message_reasoning_text(item)); append_pending_reasoning(pending_reasoning, responses_message_reasoning_text(item));
attach_pending_reasoning_to_assistant(&mut message, pending_reasoning); attach_pending_reasoning_to_assistant(&mut message, pending_reasoning);
} else if pending_reasoning.is_some() { } else {
pending_reasoning.take(); // 非 assistant 的回合边界消息(user 等):pending reasoning 不再直接丢弃,
// 回溯附挂到上一条 assistant;其已有 reasoning_content 时追加尾部
// reasoning,同时防止 reasoning 跨 user 回合泄漏到之后的 assistant 消息。
attach_pending_reasoning_to_previous_assistant(
messages,
last_assistant_index,
pending_reasoning,
);
} }
message message
@@ -850,8 +888,8 @@ fn attach_pending_reasoning_to_assistant(
/// 在所有 input 处理完毕后,对仍缺 `reasoning_content` 的 assistant tool-call 消息补占位。 /// 在所有 input 处理完毕后,对仍缺 `reasoning_content` 的 assistant tool-call 消息补占位。
/// 必须作为管线末端的最终兜底执行:真实 reasoning 可能以尾随 `reasoning` item 的形式经 /// 必须作为管线末端的最终兜底执行:真实 reasoning 可能以尾随 `reasoning` item 的形式经
/// `attach_reasoning_to_last_assistant` 回填,过早注入占位会被 `append_reasoning_content` /// `attach_pending_reasoning_to_previous_assistant` 回填,过早注入占位会被
/// 追加而污染真实思考。 /// `append_reasoning_content` 追加而污染真实思考。
fn backfill_tool_call_reasoning_placeholders(messages: &mut [Value]) { fn backfill_tool_call_reasoning_placeholders(messages: &mut [Value]) {
for message in messages.iter_mut() { for message in messages.iter_mut() {
let is_assistant_tool_call = message.get("role").and_then(|value| value.as_str()) let is_assistant_tool_call = message.get("role").and_then(|value| value.as_str())
@@ -887,34 +925,39 @@ fn ensure_tool_call_reasoning_content(message: &mut Value) {
} }
} }
fn attach_reasoning_to_last_assistant( /// 将仍未消费的 pending reasoning 回溯附挂到上一条 assistant 消息。
///
/// 只允许两种「真正的尾部」场景调用:
/// 1. 整个 input 处理完毕后 pending_reasoning 仍有剩余——其后已没有任何可
/// 前向附挂的 message / function_call
/// 2. user 等回合边界消息到达时 pending_reasoning 非空——reasoning 不允许
/// 跨 user 回合泄漏到之后的 assistant 消息,也不能直接丢弃可归属的思考。
///
/// 这里已经处于尾部/边界收尾点,不是普通 reasoning 的前向归属路径;
/// 若目标已有 reasoning_content,追加尾部 reasoning 以保留同一 assistant turn
/// 中同时出现的 embedded reasoning 与尾随 reasoning。无论是否附挂成功,
/// pending 都会被消费(拿走),绝不留到下一条 assistant。
fn attach_pending_reasoning_to_previous_assistant(
messages: &mut [Value], messages: &mut [Value],
last_assistant_index: Option<usize>, last_assistant_index: Option<usize>,
reasoning: &Option<String>, pending_reasoning: &mut Option<String>,
) -> bool { ) {
let Some(reasoning) = reasoning let Some(reasoning) = pending_reasoning.take() else {
.as_deref() return;
.map(str::trim)
.filter(|s| !s.is_empty())
else {
return true;
}; };
let Some(index) = last_assistant_index else { let reasoning = reasoning.trim();
return false; if reasoning.is_empty() {
}; return;
let Some(message) = messages.get_mut(index) else { }
return false; let Some(message) = last_assistant_index.and_then(|index| messages.get_mut(index)) else {
return;
}; };
if message.get("role").and_then(|v| v.as_str()) != Some("assistant") { if message.get("role").and_then(|v| v.as_str()) != Some("assistant") {
return false; return;
} }
if let Some(obj) = message.as_object_mut() { if let Some(obj) = message.as_object_mut() {
append_reasoning_content(obj, reasoning); append_reasoning_content(obj, reasoning);
return true;
} }
false
} }
fn responses_message_reasoning_text(item: &Value) -> Option<String> { fn responses_message_reasoning_text(item: &Value) -> Option<String> {
@@ -2520,6 +2563,44 @@ mod tests {
); );
} }
#[test]
fn responses_request_to_chat_preserves_trailing_reasoning_after_embedded_reasoning() {
let input = json!({
"model": "gpt-5.4",
"input": [
{
"type": "message",
"role": "assistant",
"reasoning_content": "Embedded thought.",
"content": "Done."
},
{
"type": "reasoning",
"summary": [
{"type": "summary_text", "text": "Trailing thought."}
]
},
{
"type": "message",
"role": "user",
"content": "Continue"
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["content"], "Done.");
assert_eq!(
messages[0]["reasoning_content"],
"Embedded thought.\n\nTrailing thought."
);
assert_eq!(messages[1]["role"], "user");
assert!(messages[1].get("reasoning_content").is_none());
}
#[test] #[test]
fn responses_request_to_chat_attaches_reasoning_to_tool_call_message() { fn responses_request_to_chat_attaches_reasoning_to_tool_call_message() {
let input = json!({ let input = json!({
@@ -2643,6 +2724,106 @@ mod tests {
assert_eq!(messages[1]["role"], "tool"); assert_eq!(messages[1]["role"], "tool");
} }
#[test]
fn responses_request_to_chat_attaches_reasoning_forward_to_following_assistant() {
// 回归:reasoning 必须前向附挂到其后的 assistant 消息,不得回溯拼进
// 上一条 assistant。此前多轮序列 [r1, m1, r2, m2] 中 r2 会被拼到 m1
// 尾部、 m2 丢失 reasoning_content,思考型模型(kimi 等)因此中途"断片"。
let input = json!({
"model": "kimi-k2-thinking",
"input": [
{
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "first thought"}]
},
{
"type": "message",
"role": "assistant",
"content": "First answer."
},
{
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "second thought"}]
},
{
"type": "message",
"role": "assistant",
"content": "Second answer."
},
{
"role": "user",
"content": "Continue"
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["content"], "First answer.");
assert_eq!(messages[0]["reasoning_content"], "first thought");
assert_eq!(messages[1]["role"], "assistant");
assert_eq!(messages[1]["content"], "Second answer.");
assert_eq!(messages[1]["reasoning_content"], "second thought");
assert_eq!(messages[2]["role"], "user");
assert!(messages[2].get("reasoning_content").is_none());
}
#[test]
fn responses_request_to_chat_keeps_reasoning_on_final_answer_after_tool_call() {
// 回归(Kimi 契约):[reasoning, function_call, output, reasoning, message]
// 最后一个纯文本 assistant 必须保留自己的 reasoning_content,且该 reasoning
// 不得被回溯拼进前面的 tool-call 消息(否则上游历史里 tool-call 消息的思考
// 被污染、最终答复消息反而没有 reasoning_content)。
let input = json!({
"model": "kimi-k2-thinking",
"input": [
{
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "need to read a file"}]
},
{
"type": "function_call",
"call_id": "call_1",
"name": "read_file",
"arguments": "{\"path\":\"README.md\"}"
},
{
"type": "function_call_output",
"call_id": "call_1",
"output": "Readme content"
},
{
"type": "reasoning",
"summary": [{"type": "summary_text", "text": "now I can answer"}]
},
{
"type": "message",
"role": "assistant",
"content": "The file says hello."
},
{
"role": "user",
"content": "Continue"
}
]
});
let result = responses_to_chat_completions(input).unwrap();
let messages = result["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "assistant");
assert_eq!(messages[0]["tool_calls"][0]["id"], "call_1");
assert_eq!(messages[0]["reasoning_content"], "need to read a file");
assert_eq!(messages[1]["role"], "tool");
assert_eq!(messages[2]["role"], "assistant");
assert_eq!(messages[2]["content"], "The file says hello.");
assert_eq!(messages[2]["reasoning_content"], "now I can answer");
assert_eq!(messages[3]["role"], "user");
assert!(messages[3].get("reasoning_content").is_none());
}
#[test] #[test]
fn responses_request_to_chat_keeps_multiple_tool_calls_adjacent_to_outputs() { fn responses_request_to_chat_keeps_multiple_tool_calls_adjacent_to_outputs() {
let input = json!({ let input = json!({