From 4b0f870fba1443560e5444ab6f16451b3c2a6151 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Wed, 7 Oct 2026 23:26:24 +0800 Subject: [PATCH 01/12] fix(tui): preserve thinking across turns Preserve provider reasoning ("thinking") across turns per each supported provider's replay contract, verified against the API docs as of 2026-10-07. Anthropic Messages: - capture and replay redacted_thinking byte-exact (stream variant, non-stream decode shim, ContentBlock::Thinking.redacted_data). The stream start event used to fail SSE decode and was dropped with a warning - the turn survived without the block, but replaying the bare tool_use then failed with an unretryable 400 on every retry; the non-stream decode failed wholesale; and the block was never replayed - persist multi-thinking-block turns (adaptive/interleaved thinking on the newer Claude models) in wire order instead of collapsing to the last block - persist signature-only blocks (empty text + signature, the display:"omitted" shape models emit for summarized thinking) - strip thinking from the final tool turn only when the wire carries thinking disabled (explicit disabled, omission on models where omission means off, unformable budget); withhold redacted payloads from non-native Anthropic hosts - a conservative call that also withholds from forwarding proxies, which would have handled the block OpenAI Responses: - capture encrypted reasoning on every Responses route that requests include (Codex, OpenCode Zen, custom Responses gateways; the DeepSeek and Concentrate routes request neither store:false nor include), not just Codex; warn when an item completes without it - replay pairing integrity: chain-drop tool calls whose reasoning is unreplayable (different model or provider, older builds) together with their outputs; drop orphaned reasoning items with no following output item Gemini (OpenAI-compat route): - scope fail-closed thought-signature validation to the current turn and the first tool call of each step, matching Google's own rule; valid parallel-call turns are no longer blocked - capture thought signatures from continuation chunks and the chunk-level delta.extra_content form; stream reasoning_content as thinking on the exact official route 20 new tests plus 5 updated pins; lib 11942 passed (2 environmental child-process failures under NO_NEW_PRIVS=1 and a known parallel-load flake, unchanged by this commit) and integration 287 passed. Signed-off-by: asto18089 --- crates/core/src/request.rs | 11 + crates/tui/src/client.rs | 46 +- crates/tui/src/client/anthropic.rs | 546 +++++++++++++++++- crates/tui/src/client/chat.rs | 495 +++++++++++++++- crates/tui/src/client/responses.rs | 181 +++++- crates/tui/src/client/responses/tests.rs | 444 +++++++++++--- .../tui/src/commands/groups/session/export.rs | 1 + .../src/commands/groups/session/structcopy.rs | 1 + crates/tui/src/compaction.rs | 1 + crates/tui/src/core/engine/preview/tests.rs | 3 + crates/tui/src/core/engine/tests.rs | 253 ++++++++ crates/tui/src/core/engine/turn_loop.rs | 121 +++- crates/tui/src/models.rs | 33 ++ crates/tui/src/purge.rs | 1 + crates/tui/src/rlm/turn.rs | 1 + crates/tui/src/runtime_api/sessions.rs | 1 + crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/runtime_threads/tests.rs | 2 + crates/tui/src/tui/auto_router.rs | 1 + crates/tui/src/tui/history/tests.rs | 2 + crates/tui/src/tui/setup/fleet_draft.rs | 1 + crates/tui/src/tui/setup/model_draft.rs | 1 + crates/tui/src/tui/ui/dispatch.rs | 1 + crates/tui/src/tui/ui/tests.rs | 1 + crates/tui/src/utils.rs | 14 +- .../tests/integration/integration_mock_llm.rs | 1 + ...soning_content_replayed_after_tool_call.rs | 1 + 27 files changed, 2038 insertions(+), 127 deletions(-) diff --git a/crates/core/src/request.rs b/crates/core/src/request.rs index 0c75f75a0f..1c0f5b4d36 100644 --- a/crates/core/src/request.rs +++ b/crates/core/src/request.rs @@ -175,6 +175,16 @@ pub enum ContentBlock { /// readable `thinking` text or carry it across a route/model switch. #[serde(skip_serializing_if = "Option::is_none", default)] state: Option, + /// Anthropic `redacted_thinking` payload (`data`): the encrypted + /// replacement for a whole thinking block that the provider withheld + /// for safety reasons. It must be replayed byte-exact — the API + /// decrypts it to restore the turn's reasoning — and is mutually + /// exclusive with readable `thinking` text and with `signature`. + /// Stored on the same variant (instead of a sibling one) so existing + /// sessions and matches keep compiling; the serde field is absent + /// for every non-redacted block. + #[serde(skip_serializing_if = "Option::is_none", default)] + redacted_data: Option, }, #[serde(rename = "tool_use")] ToolUse { @@ -225,6 +235,7 @@ impl ContentBlock { thinking: thinking.into(), signature: None, state: None, + redacted_data: None, } } } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 96c366d5e5..e491a1de3b 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -5936,6 +5936,7 @@ mod tests { thinking: "Inspect the saved tool state".to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::ToolUse { id: "call-k3-replay".to_string(), @@ -7162,13 +7163,34 @@ mod tests { messages: vec![ Message { role: Role::Assistant, - content: vec![ContentBlock::ToolUse { - id: "call-secret-test".to_string(), - name: "read_file".to_string(), - input: json!({"path": "config.toml"}), - caller: None, - thought_signature: None, - }], + content: vec![ + // A reasoning-capable model's bare function_call is + // chain-dropped on the Responses route; pair the call + // with replayable state (the test helper builds the + // Responses body as OpenaiCodex) so the tool result — + // and its redaction marker — reaches all three + // protocol bodies. + ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: Some(crate::models::OpaqueReasoningState { + provider: "openai-codex".to_string(), + api: "openai-responses".to_string(), + model: "glm-5.2".to_string(), + id: None, + encrypted_content: "enc-secret-fixture".to_string(), + endpoint: Some("fp-codex-endpoint".to_string()), + }), + redacted_data: None, + }, + ContentBlock::ToolUse { + id: "call-secret-test".to_string(), + name: "read_file".to_string(), + input: json!({"path": "config.toml"}), + caller: None, + thought_signature: None, + }, + ], }, Message { role: Role::User, @@ -8934,6 +8956,7 @@ mod tests { signature: None, state: None, thinking: "plan".to_string(), + redacted_data: None, }, ContentBlock::Text { text: "done".to_string(), @@ -8974,6 +8997,7 @@ mod tests { signature: None, state: None, thinking: "plan".to_string(), + redacted_data: None, }, ContentBlock::Text { text: "done".to_string(), @@ -9025,6 +9049,7 @@ mod tests { signature: None, state: None, thinking: "Need to call a tool".to_string(), + redacted_data: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), @@ -9079,6 +9104,7 @@ mod tests { signature: None, state: None, thinking: "Need to call a tool".to_string(), + redacted_data: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), @@ -9152,6 +9178,7 @@ mod tests { signature: None, state: None, thinking: "Internal explanation plan".to_string(), + redacted_data: None, }, ContentBlock::Text { text: "Final answer".to_string(), @@ -9197,6 +9224,7 @@ mod tests { signature: None, state: None, thinking: "I should explain step by step.".to_string(), + redacted_data: None, }, ContentBlock::Text { text: "Here is the explanation.".to_string(), @@ -10354,6 +10382,7 @@ mod tests { signature: None, state: None, thinking: "plan".to_string(), + redacted_data: None, }], }; let out = build_chat_messages(None, &[message], "some-non-deepseek-model"); @@ -10503,6 +10532,7 @@ mod tests { signature: None, state: None, thinking: "Need to inspect the directory".to_string(), + redacted_data: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), @@ -10546,6 +10576,7 @@ mod tests { signature: None, state: None, thinking: "Need to search".to_string(), + redacted_data: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), @@ -10639,6 +10670,7 @@ mod tests { signature: None, state: None, thinking: "Need to list files".to_string(), + redacted_data: None, }, ContentBlock::ToolUse { id: "tool-ok".to_string(), diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 9380bd749e..64ab704550 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -157,6 +157,12 @@ impl DeepSeekClient { .reasoning_effort .as_deref() .map(|raw| raw.trim().to_ascii_lowercase()); + // True when the wire carries thinking disabled (explicit + // `{"type":"disabled"}`, or omitted on a model that treats omission + // as off). Anthropic rejects thinking content in the current + // tool-use turn in that state, so the final tool turn gets its + // thinking blocks stripped below. + let mut thinking_disabled_on_wire = false; match effort.as_deref() { _ if is_minimax_provider && !is_minimax => {} Some("off" | "disabled" | "none" | "false") @@ -170,6 +176,7 @@ impl DeepSeekClient { // — pinned by modelstudio_messages_body_requests_thinking_ // with_budget. Re-checked 2026-08-04. body["thinking"] = json!({ "type": "disabled" }); + thinking_disabled_on_wire = true; } Some("off" | "disabled" | "none" | "false") => {} Some(level) if thinking_capable && supports_adaptive => { @@ -195,6 +202,38 @@ impl DeepSeekClient { } _ => {} } + // Every arm that leaves the request without a `thinking` field means + // thinking is off on the wire — explicit off above, a budget that + // could not be formed (max_tokens too small), or a non-thinking + // model. On models where omission means adaptive-on the blocks stay; + // everywhere else the current tool turn must lose them or the API + // rejects the request outright. + if body.get("thinking").is_none() + && !anthropic_omitted_thinking_defaults_to_adaptive(&model) + { + thinking_disabled_on_wire = true; + } + if thinking_disabled_on_wire + && let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) + { + strip_thinking_from_final_tool_turn(messages); + } + // Redacted thinking payloads can only be decrypted by the platform + // that minted them (first-party API, Bedrock, Vertex). Anthropic- + // dialect gateways that merely speak the protocol would reject the + // unknown block type, so those routes drop the block instead of + // shipping a request they cannot validate. + if !is_native_anthropic_base_url(&self.base_url) + && let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) + { + for message in messages.iter_mut() { + if let Some(blocks) = message.get_mut("content").and_then(Value::as_array_mut) { + blocks.retain(|block| { + block.get("type").and_then(Value::as_str) != Some("redacted_thinking") + }); + } + } + } // Sampling parameters: Claude 4.7+ rejects temperature/top_p // entirely; earlier models reject the two together. Send at most one @@ -441,10 +480,88 @@ impl DeepSeekClient { if let Some(usage) = value.get_mut("usage") { *usage = json!(parse_anthropic_usage(usage)); } + normalize_redacted_thinking_blocks(&mut value); serde_json::from_value(value).context("Failed to decode Anthropic Messages response") } } +/// Rewrite non-stream `redacted_thinking` content blocks into the +/// provider-neutral shape (`Thinking` with `redacted_data`) before decoding. +/// +/// The provider-neutral content model has no `redacted_thinking` tag, so an +/// unknown block type would fail the whole response decode and lose the turn +/// — including the tool calls. Streaming responses carry the same blocks as +/// typed `ContentBlockStart::RedactedThinking` events and need no shim. +fn normalize_redacted_thinking_blocks(value: &mut Value) { + let Some(content) = value.get_mut("content").and_then(Value::as_array_mut) else { + return; + }; + for block in content.iter_mut() { + if block.get("type").and_then(Value::as_str) == Some("redacted_thinking") + && let Some(data) = block.get("data").and_then(Value::as_str) + { + *block = json!({ + "type": "thinking", + "thinking": "", + "redacted_data": data, + }); + } + } +} + +/// Models that run adaptive thinking when the `thinking` field is omitted. +/// +/// The 2026 thinking docs are explicit that for the Claude 5 generation and +/// the Fable/Mythos lines, omitting `thinking` is NOT "disabled" — the model +/// runs adaptive thinking — so replayed signed thinking blocks stay valid. +/// For every older model an omitted field means thinking is off, and the API +/// rejects thinking content in the current tool-use turn once thinking is +/// disabled, so those turns need their thinking blocks stripped. +fn anthropic_omitted_thinking_defaults_to_adaptive(model: &str) -> bool { + let model = model.to_ascii_lowercase(); + model.contains("fable") + || model.contains("mythos") + || model.contains("opus-4-7") + || model.contains("opus-4-8") + || ["opus-5", "sonnet-5", "haiku-5"] + .iter() + .any(|family| model.contains(family)) +} + +/// Remove thinking blocks from the final assistant message of a tool turn. +/// +/// Anthropic fails the request when thinking content appears in the current +/// tool-use turn while thinking is disabled; in every other position +/// replayed thinking content is simply ignored, so only the tool turn is +/// rewritten and cache-relevant history bytes stay untouched. +fn strip_thinking_from_final_tool_turn(messages: &mut [Value]) { + let Some(last_assistant) = messages + .iter_mut() + .rev() + .find(|message| message.get("role").and_then(Value::as_str) == Some("assistant")) + else { + return; + }; + let Some(blocks) = last_assistant + .get_mut("content") + .and_then(Value::as_array_mut) + else { + return; + }; + let has_tool_use = blocks + .iter() + .any(|block| block.get("type").and_then(Value::as_str) == Some("tool_use")); + if !has_tool_use { + return; + } + blocks.retain(|block| { + !matches!( + block.get("type").and_then(Value::as_str), + Some("thinking") | Some("redacted_thinking") + ) + }); +} + /// Build the `/v1/messages` endpoint URL, tolerating base URLs that already /// carry a `/v1` suffix. pub(super) fn anthropic_messages_url(base_url: &str) -> String { @@ -699,12 +816,26 @@ fn content_block_to_anthropic(block: &ContentBlock) -> Option { ContentBlock::Thinking { thinking, signature, + redacted_data, .. } => { + // A redacted thinking block is provider-encrypted content with no + // readable text: replay it byte-exact so the API can decrypt it, + // exactly like a signed block. Anthropic requires the complete + // unmodified block in tool loops and decrypts it to restore the + // turn's reasoning. + if let Some(data) = redacted_data { + return Some(json!({ + "type": "redacted_thinking", + "data": data, + })); + } // Anthropic rejects unsigned thinking blocks on replay (and the // DeepSeek-era "(reasoning omitted)" placeholders mean nothing to // it), so only signed blocks are replayed — verbatim, signature - // included. + // included. An empty text with a signature is real content: + // newer Claude models ship `display: "omitted"` thinking, where + // the signature carries the encrypted full thinking. signature.as_ref().map(|signature| { json!({ "type": "thinking", @@ -1556,11 +1687,13 @@ mod tests { thinking: "signed reasoning".to_string(), signature: Some("sig-abc".to_string()), state: None, + redacted_data: None, }, ContentBlock::Thinking { thinking: "(reasoning omitted)".to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::ToolUse { id: "toolu_1".to_string(), @@ -1601,6 +1734,417 @@ mod tests { ); } + #[test] + fn body_replays_redacted_thinking_blocks_byte_exact() { + let client = test_client(); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.messages = vec![ + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "do the thing".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: None, + redacted_data: Some("ENC_REDACTED_PAYLOAD".to_string()), + }, + ContentBlock::ToolUse { + id: "toolu_1".to_string(), + name: "read_file".to_string(), + input: json!({"path": "a.txt"}), + caller: None, + thought_signature: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "toolu_1".to_string(), + content: "contents".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ]; + + let body = client.build_anthropic_body(&request, true); + let assistant = &body["messages"][1]["content"]; + assert_eq!(assistant.as_array().map(Vec::len), Some(2)); + assert_eq!(assistant[0]["type"].as_str(), Some("redacted_thinking")); + assert_eq!(assistant[0]["data"].as_str(), Some("ENC_REDACTED_PAYLOAD")); + assert_eq!(assistant[1]["type"].as_str(), Some("tool_use")); + } + + #[test] + fn body_replays_signature_only_omitted_display_thinking() { + // Claude 4.6+ ships `display: "omitted"` thinking: empty text with + // the encrypted signature. Dropping it would strip the block + // Anthropic requires back in tool loops. + let client = test_client(); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.messages = vec![ + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "do the thing".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: String::new(), + signature: Some("sig-omitted".to_string()), + state: None, + redacted_data: None, + }, + ContentBlock::ToolUse { + id: "toolu_1".to_string(), + name: "read_file".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "toolu_1".to_string(), + content: "contents".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ]; + + let body = client.build_anthropic_body(&request, true); + let assistant = &body["messages"][1]["content"]; + assert_eq!(assistant.as_array().map(Vec::len), Some(2)); + assert_eq!(assistant[0]["type"].as_str(), Some("thinking")); + assert_eq!(assistant[0]["thinking"].as_str(), Some("")); + assert_eq!(assistant[0]["signature"].as_str(), Some("sig-omitted")); + } + + #[test] + fn body_preserves_multi_block_thinking_sequence_in_wire_order() { + // Adaptive/interleaved thinking emits several thinking blocks per + // assistant turn; the API requires the entire sequence replayed + // unmodified and in order. + let client = test_client(); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.messages = vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "first block".to_string(), + signature: Some("sig-1".to_string()), + state: None, + redacted_data: None, + }, + ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: None, + redacted_data: Some("ENC_SECOND".to_string()), + }, + ContentBlock::Thinking { + thinking: "third block".to_string(), + signature: Some("sig-3".to_string()), + state: None, + redacted_data: None, + }, + ContentBlock::ToolUse { + id: "toolu_1".to_string(), + name: "read_file".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + ], + }]; + + let body = client.build_anthropic_body(&request, true); + let assistant = &body["messages"][0]["content"]; + let types: Vec<&str> = assistant + .as_array() + .expect("content array") + .iter() + .map(|block| block["type"].as_str().expect("block type")) + .collect(); + assert_eq!( + types, + vec!["thinking", "redacted_thinking", "thinking", "tool_use"], + "wire order must match the captured sequence: {assistant}" + ); + assert_eq!(assistant[0]["signature"].as_str(), Some("sig-1")); + assert_eq!(assistant[1]["data"].as_str(), Some("ENC_SECOND")); + assert_eq!(assistant[2]["signature"].as_str(), Some("sig-3")); + } + + #[test] + fn disabled_thinking_strips_thinking_from_the_final_tool_turn() { + let client = test_client(); + let mut request = request_with("claude-sonnet-4-5", Some("off"), None, None); + request.messages = vec![ + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "older turn reasoning".to_string(), + signature: Some("sig-old".to_string()), + state: None, + redacted_data: None, + }, + ContentBlock::Text { + text: "older answer".to_string(), + cache_control: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "continue with tools".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "current tool turn reasoning".to_string(), + signature: Some("sig-current".to_string()), + state: None, + redacted_data: None, + }, + ContentBlock::ToolUse { + id: "toolu_1".to_string(), + name: "read_file".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "toolu_1".to_string(), + content: "contents".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ]; + + let body = client.build_anthropic_body(&request, true); + // Thinking is disabled on the wire (omitted param on a pre-5 model): + // the current tool-use turn must lose its thinking block, older + // positions are server-ignored and stay untouched. + assert!(body.get("thinking").is_none(), "{body}"); + let older = &body["messages"][0]["content"]; + assert_eq!(older[0]["type"].as_str(), Some("thinking")); + let current = &body["messages"][2]["content"]; + assert_eq!(current.as_array().map(Vec::len), Some(1)); + assert_eq!(current[0]["type"].as_str(), Some("tool_use")); + } + + #[test] + fn disabled_thinking_keeps_blocks_on_adaptive_by_default_models() { + // Claude 5 generation runs adaptive thinking when the `thinking` + // field is omitted, so replayed thinking stays valid and must not be + // stripped from the final tool turn. + let client = test_client(); + let mut request = request_with("claude-sonnet-5", Some("off"), None, None); + request.messages = vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "current tool turn reasoning".to_string(), + signature: Some("sig-current".to_string()), + state: None, + redacted_data: None, + }, + ContentBlock::ToolUse { + id: "toolu_1".to_string(), + name: "read_file".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + ], + }]; + + let body = client.build_anthropic_body(&request, true); + let assistant = &body["messages"][0]["content"]; + assert_eq!(assistant.as_array().map(Vec::len), Some(2)); + assert_eq!(assistant[0]["type"].as_str(), Some("thinking")); + } + + #[test] + fn enabled_thinking_never_strips_the_final_tool_turn() { + let client = test_client(); + let mut request = request_with("claude-sonnet-4-6", Some("high"), None, None); + request.max_tokens = 8_192; + request.messages = vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "current tool turn reasoning".to_string(), + signature: Some("sig-current".to_string()), + state: None, + redacted_data: None, + }, + ContentBlock::ToolUse { + id: "toolu_1".to_string(), + name: "read_file".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + ], + }]; + + let body = client.build_anthropic_body(&request, true); + assert_eq!( + body.pointer("/thinking/type").and_then(Value::as_str), + Some("adaptive"), + "native routes run thinking on for a real effort tier: {body}" + ); + let assistant = &body["messages"][0]["content"]; + assert_eq!(assistant.as_array().map(Vec::len), Some(2)); + assert_eq!(assistant[0]["type"].as_str(), Some("thinking")); + } + + #[test] + fn unformable_thinking_budget_counts_as_disabled_and_strips() { + // On a gateway without adaptive support, a max_tokens too small for + // the minimum budget leaves the request without a `thinking` field — + // thinking is off on the wire, so the current tool turn must lose + // its thinking block exactly like an explicit off. + let client = anthropic_test_client(Some("https://gateway.example.com/anthropic")); + let mut request = request_with("claude-sonnet-4-6", Some("high"), None, None); + request.max_tokens = 1_024; + request.messages = vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "current tool turn reasoning".to_string(), + signature: Some("sig-current".to_string()), + state: None, + redacted_data: None, + }, + ContentBlock::ToolUse { + id: "toolu_1".to_string(), + name: "read_file".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + ], + }]; + + let body = client.build_anthropic_body(&request, true); + assert!(body.get("thinking").is_none(), "{body}"); + let assistant = &body["messages"][0]["content"]; + assert_eq!(assistant.as_array().map(Vec::len), Some(1)); + assert_eq!(assistant[0]["type"].as_str(), Some("tool_use")); + } + + #[test] + fn non_native_gateways_never_receive_redacted_thinking_blocks() { + // Redacted payloads can only be decrypted by the platform that minted + // them; a Messages-compatible gateway would reject the unknown type. + let client = anthropic_test_client(Some("https://gateway.example.com/anthropic")); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.messages = vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: None, + redacted_data: Some("ENC_REDACTED".to_string()), + }, + ContentBlock::ToolUse { + id: "toolu_1".to_string(), + name: "read_file".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + ], + }]; + + let body = client.build_anthropic_body(&request, true); + let wire = body.to_string(); + assert!( + !wire.contains("redacted_thinking") && !wire.contains("ENC_REDACTED"), + "non-native gateway must not receive redacted blocks: {body}" + ); + let assistant = &body["messages"][0]["content"]; + assert_eq!(assistant.as_array().map(Vec::len), Some(1)); + + // First-party Anthropic keeps the block: it can decrypt it. + let native = test_client().build_anthropic_body(&request, true); + assert!( + native.to_string().contains("ENC_REDACTED"), + "first-party route must replay redacted blocks: {native}" + ); + } + + #[test] + fn non_stream_redacted_thinking_blocks_decode_into_redacted_data() { + let payload = json!({ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [ + { "type": "redacted_thinking", "data": "ENC_FROM_API" }, + { "type": "text", "text": "answer" } + ], + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 1, + "output_tokens": 1 + } + }); + + // An unknown block tag used to fail the whole response decode; the + // shim must rewrite it into the provider-neutral shape instead. + let mut value = payload; + if let Some(usage) = value.get_mut("usage") { + *usage = json!(parse_anthropic_usage(usage)); + } + normalize_redacted_thinking_blocks(&mut value); + let response: MessageResponse = serde_json::from_value(value) + .expect("redacted_thinking must not fail the response decode"); + assert_eq!(response.content.len(), 2); + match &response.content[0] { + ContentBlock::Thinking { + thinking, + redacted_data, + .. + } => { + assert_eq!(redacted_data.as_deref(), Some("ENC_FROM_API")); + assert!(thinking.is_empty()); + } + other => panic!("expected thinking block, got {other:?}"), + } + } + #[test] fn breakpoints_are_capped_at_four_dropping_earliest() { let client = test_client(); diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index c7d8bee7bd..8a65a8ca58 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -680,6 +680,14 @@ fn apply_google_thinking_level( /// tool calls without the thought signatures Google's thinking models /// require. The error names the model and tells the operator how to /// recover instead of letting Google reject or corrupt the tool loop. +/// +/// Scope mirrors Google's own validation: only the current turn (messages +/// after the last user message) is checked, and within it only the first +/// tool call of each assistant step must carry a signature — Gemini 3 signs +/// only the first function-call part of a step, so demanding signatures on +/// parallel calls 2..n would block valid turns. Older turns are not +/// validated: Google accepts them without signatures, and compaction or +/// pre-capture sessions must not brick new requests. fn validate_google_thought_signature_replay( provider: ApiProvider, base_url: &str, @@ -691,24 +699,29 @@ fn validate_google_thought_signature_replay( { return Ok(()); } - for message in messages { + let current_turn_start = messages + .iter() + .rposition(|message| message.get("role").and_then(Value::as_str) == Some("user")) + .map_or(0, |index| index + 1); + for message in &messages[current_turn_start..] { let Some(tool_calls) = message.get("tool_calls").and_then(Value::as_array) else { continue; }; - for call in tool_calls { - let missing = call - .pointer("/extra_content/google/thought_signature") - .and_then(Value::as_str) - .is_none(); - if missing { - let id = call.get("id").and_then(Value::as_str).unwrap_or("?"); - anyhow::bail!( - "Gemini model `{model}` requires a thought signature to replay tool call \ - `{id}`, but none was captured (the turn predates signature capture, or \ - the provider omitted it). Start a new session before using tools on \ - this route." - ); - } + let Some(first_call) = tool_calls.first() else { + continue; + }; + let missing = first_call + .pointer("/extra_content/google/thought_signature") + .and_then(Value::as_str) + .is_none(); + if missing { + let id = first_call.get("id").and_then(Value::as_str).unwrap_or("?"); + anyhow::bail!( + "Gemini model `{model}` requires a thought signature to replay tool call \ + `{id}`, but none was captured (the turn predates signature capture, or \ + the provider omitted it). Start a new session before using tools on \ + this route." + ); } } Ok(()) @@ -736,6 +749,21 @@ fn strip_google_tool_call_extra_content(messages: &mut [Value]) { } } +/// Thought signature for one streaming tool-call entry: the per-call +/// `tool_calls[].extra_content.google.thought_signature`, falling back to the +/// chunk-level `delta.extra_content.google.thought_signature` (the official +/// compat route signs the pre-tool text part there). +fn tool_call_thought_signature<'a>(tool_call: &'a Value, delta: &'a Value) -> Option<&'a str> { + tool_call + .pointer("/extra_content/google/thought_signature") + .and_then(Value::as_str) + .or_else(|| { + delta + .pointer("/extra_content/google/thought_signature") + .and_then(Value::as_str) + }) +} + fn mistral_model_has_adjustable_reasoning(model: &str) -> bool { let model = model.trim().to_ascii_lowercase(); model.starts_with("mistral-medium") || model.starts_with("mistral-small") @@ -3521,6 +3549,14 @@ fn reasoning_stream_style_for_route( "Ignoring unrecognized reasoning_stream_style `{configured}`; expected separate_field, inline_tags, or none" )); } + // Google's official OpenAI-compat route returns reasoning summaries in + // `delta.reasoning_content` (the non-streaming decoder already promotes + // the same field to a Thinking block), so stream parity applies here. + // Scoping to the exact official host keeps the generic-proxy fallback — + // render reasoning_content as answer text — for unknown gateways. + if is_exact_google_chat_route(provider, base_url) { + return ReasoningStreamStyle::SeparateField; + } if is_reasoning_model_for_stream_on_route(provider, base_url, model) { ReasoningStreamStyle::SeparateField } else { @@ -3690,6 +3726,7 @@ fn parse_chat_message_for_route( content_blocks.push(ContentBlock::Thinking { signature: None, state: None, + redacted_data: None, thinking: reasoning.to_string(), }); } @@ -3702,6 +3739,7 @@ fn parse_chat_message_for_route( content_blocks.push(ContentBlock::Thinking { signature: None, state: None, + redacted_data: None, thinking, }); } @@ -4174,7 +4212,27 @@ fn parse_sse_chunk_with_reasoning_style( for tc in tool_calls { let tc_index = tc.get("index").and_then(Value::as_u64).unwrap_or(0) as u32; let tool_block_index = match tool_indices.entry(tc_index) { - std::collections::hash_map::Entry::Occupied(entry) => *entry.get(), + std::collections::hash_map::Entry::Occupied(entry) => { + let block_index = *entry.get(); + // A thought signature can arrive on a continuation + // chunk of the same tool call (some gateways split + // the signed chunk from the one that opens the + // call); capture it instead of dropping it with + // the rest of the non-start fields. The official + // compat route also signs the chunk-level + // `delta.extra_content` on the pre-tool text part. + if let Some(signature) = tool_call_thought_signature(tc, delta) + .filter(|value| !value.is_empty()) + { + events.push(StreamEvent::ContentBlockDelta { + index: block_index, + delta: Delta::ToolThoughtSignatureDelta { + signature: signature.to_string(), + }, + }); + } + block_index + } std::collections::hash_map::Entry::Vacant(entry) => { // Close text block if transitioning to tool use if *text_started { @@ -4224,10 +4282,8 @@ fn parse_sse_chunk_with_reasoning_style( }) }); - let thought_signature = tc - .pointer("/extra_content/google/thought_signature") - .and_then(Value::as_str) - .map(str::to_string); + let thought_signature = + tool_call_thought_signature(tc, delta).map(str::to_string); events.push(StreamEvent::ContentBlockStart { index: block_index, content_block: ContentBlockStart::ToolUse { @@ -4610,6 +4666,7 @@ mod minimax_reasoning_replay_tests { thinking: "Inspect tool state".to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::Text { text: "Done.".to_string(), @@ -4709,6 +4766,7 @@ mod minimax_reasoning_replay_tests { thinking: "stale thinking from the prior turn".to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::Text { text: "I'll read the widget first.".to_string(), @@ -7539,6 +7597,7 @@ mod mistral_reasoning_tests { .to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::Text { text: "I will inspect it now.".to_string(), @@ -8119,6 +8178,179 @@ mod google_thought_signature_tests { ); } + #[test] + fn google_parallel_calls_require_only_the_first_signature() { + // Gemini 3 signs only the first function-call part of a step, so a + // valid parallel-call turn must not be blocked for missing + // signatures on calls 2..n. + let request = MessageRequest { + model: "gemini-3.1-pro-preview".to_string(), + messages: vec![ + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "Read both.".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::ToolUse { + id: "call-g-1".to_string(), + name: "read".to_string(), + input: json!({"path": "a.toml"}), + caller: None, + thought_signature: Some("SIG-first".to_string()), + }, + ContentBlock::ToolUse { + id: "call-g-2".to_string(), + name: "read".to_string(), + input: json!({"path": "b.toml"}), + caller: None, + thought_signature: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ + ContentBlock::ToolResult { + tool_use_id: "call-g-1".to_string(), + content: "a".to_string(), + is_error: None, + content_blocks: None, + }, + ContentBlock::ToolResult { + tool_use_id: "call-g-2".to_string(), + content: "b".to_string(), + is_error: None, + content_blocks: None, + }, + ], + }, + ], + max_tokens: 64, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("high".to_string()), + stream: None, + temperature: None, + top_p: None, + }; + + let body = build_chat_wire_body( + &request, + ApiProvider::Google, + DEFAULT_GOOGLE_BASE_URL, + false, + ) + .expect("parallel calls with only the first signed must pass validation"); + let assistant = body.body["messages"] + .as_array() + .expect("messages") + .iter() + .find(|m| m.get("role") == Some(&json!("assistant"))) + .expect("assistant message"); + let calls = assistant["tool_calls"].as_array().expect("tool calls"); + assert_eq!(calls.len(), 2); + assert_eq!( + calls[0] + .pointer("/extra_content/google/thought_signature") + .and_then(Value::as_str), + Some("SIG-first") + ); + assert!( + calls[1].get("extra_content").is_none(), + "unsigned parallel call must not gain invented metadata: {calls:?}" + ); + } + + #[test] + fn google_older_turns_without_signatures_are_not_validated() { + // Google enforces signatures only on the current turn; a session + // that predates signature capture (or lost them to compaction) must + // not be bricked for old turns once the fresh turn is signed. + let request = MessageRequest { + model: "gemini-3.1-pro-preview".to_string(), + messages: vec![ + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "first request".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "call-g-old".to_string(), + name: "read".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-g-old".to_string(), + content: "old output".to_string(), + is_error: None, + content_blocks: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "next request".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "call-g-new".to_string(), + name: "read".to_string(), + input: json!({}), + caller: None, + thought_signature: Some("SIG-new".to_string()), + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-g-new".to_string(), + content: "new output".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ], + max_tokens: 64, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("high".to_string()), + stream: None, + temperature: None, + top_p: None, + }; + + build_chat_wire_body( + &request, + ApiProvider::Google, + DEFAULT_GOOGLE_BASE_URL, + false, + ) + .expect("signed current turn must pass even with unsigned older turns"); + } + #[test] fn google_signature_captured_from_non_streaming_tool_call() { let payload = json!({ @@ -8201,4 +8433,227 @@ mod google_thought_signature_tests { }); assert_eq!(signature.as_deref(), Some("SIG-delta")); } + + #[test] + fn google_signature_captured_from_continuation_chunk() { + // Some gateways split the signed chunk from the one that opens the + // tool call; the signature must attach to the already-started block + // instead of being dropped with the rest of the non-start fields. + let opening_chunk = json!({ + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "id": "call-g-8", + "type": "function", + "function": { "name": "read", "arguments": "{" } + }] + } + }] + }); + let continuation_chunk = json!({ + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "function": { "arguments": "}" }, + "extra_content": { + "google": { "thought_signature": "SIG-late" } + } + }] + } + }] + }); + let mut content_index = 0u32; + let mut text_started = false; + let mut thinking_started = false; + let mut tool_indices = std::collections::HashMap::new(); + let mut reasoning_buffers = std::collections::HashMap::new(); + let mut inline_tags = InlineReasoningTagState::default(); + let style = ReasoningStreamStyle::None; + let _ = parse_sse_chunk_with_reasoning_style( + &opening_chunk, + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + &mut reasoning_buffers, + &mut inline_tags, + style, + ); + let events = parse_sse_chunk_with_reasoning_style( + &continuation_chunk, + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + &mut reasoning_buffers, + &mut inline_tags, + style, + ); + let signature = events.iter().find_map(|event| match event { + StreamEvent::ContentBlockDelta { + delta: Delta::ToolThoughtSignatureDelta { signature }, + .. + } => Some(signature.clone()), + _ => None, + }); + assert_eq!( + signature.as_deref(), + Some("SIG-late"), + "continuation-chunk signature must be captured: {events:?}" + ); + } + + #[test] + fn google_chunk_level_extra_content_signature_is_captured() { + // The official compat route sometimes signs the chunk itself + // (`delta.extra_content`) rather than the tool_calls entry; that form + // must reach the tool call too, or the current-turn validator would + // fail a turn Google actually signed. + let opening_chunk = json!({ + "choices": [{ + "index": 0, + "delta": { + "extra_content": { + "google": { "thought_signature": "SIG-chunk-level" } + }, + "tool_calls": [{ + "index": 0, + "id": "call-g-9", + "type": "function", + "function": { "name": "read", "arguments": "{}" } + }] + } + }] + }); + let mut content_index = 0u32; + let mut text_started = false; + let mut thinking_started = false; + let mut tool_indices = std::collections::HashMap::new(); + let mut reasoning_buffers = std::collections::HashMap::new(); + let mut inline_tags = InlineReasoningTagState::default(); + let events = parse_sse_chunk_with_reasoning_style( + &opening_chunk, + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + &mut reasoning_buffers, + &mut inline_tags, + ReasoningStreamStyle::None, + ); + let signature = events.iter().find_map(|event| match event { + StreamEvent::ContentBlockStart { + content_block: + ContentBlockStart::ToolUse { + thought_signature, .. + }, + .. + } => thought_signature.clone(), + _ => None, + }); + assert_eq!( + signature.as_deref(), + Some("SIG-chunk-level"), + "chunk-level extra_content signature must be captured: {events:?}" + ); + } + + #[test] + fn google_route_defaults_to_separate_field_stream_style_and_config_still_wins() { + // Stream parity with the non-streaming decoder: on the exact official + // route, `delta.reasoning_content` is a Thinking block, not answer + // text. An explicit reasoning_stream_style must still override it, + // and unknown gateways keep the render-as-text fallback. + assert_eq!( + reasoning_stream_style_for_route( + ApiProvider::Google, + DEFAULT_GOOGLE_BASE_URL, + "gemini-3.1-pro-preview", + None, + ), + ReasoningStreamStyle::SeparateField, + "exact Google route must default to SeparateField" + ); + assert_eq!( + reasoning_stream_style_for_route( + ApiProvider::Google, + DEFAULT_GOOGLE_BASE_URL, + "gemini-3.1-pro-preview", + Some("none"), + ), + ReasoningStreamStyle::None, + "explicit configuration must override the Google default" + ); + assert_eq!( + reasoning_stream_style_for_route( + ApiProvider::Google, + "https://gateway.example.com/v1", + "gemini-3.1-pro-preview", + None, + ), + ReasoningStreamStyle::None, + "unknown gateways keep the generic fallback" + ); + } + + #[test] + fn google_stream_reasoning_content_becomes_thinking_not_answer_text() { + let chunk = json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_content": "weighing the two files", + "content": "The answer is" + } + }] + }); + let mut content_index = 0u32; + let mut text_started = false; + let mut thinking_started = false; + let mut tool_indices = std::collections::HashMap::new(); + let mut reasoning_buffers = std::collections::HashMap::new(); + let mut inline_tags = InlineReasoningTagState::default(); + let events = parse_sse_chunk_with_reasoning_style( + &chunk, + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + &mut reasoning_buffers, + &mut inline_tags, + reasoning_stream_style_for_route( + ApiProvider::Google, + DEFAULT_GOOGLE_BASE_URL, + "gemini-3.1-pro-preview", + None, + ), + ); + let thinking = events.iter().any(|event| { + matches!( + event, + StreamEvent::ContentBlockDelta { + delta: Delta::ThinkingDelta { .. }, + .. + } + ) + }); + let leaked_to_text = events.iter().any(|event| { + matches!( + event, + StreamEvent::ContentBlockDelta { + delta: Delta::TextDelta { text }, + .. + } if text.contains("weighing the two files") + ) + }); + assert!(thinking, "reasoning_content must stream as thinking"); + assert!( + !leaked_to_text, + "reasoning_content must not leak into answer text: {events:?}" + ); + } } diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 3672dc8ad7..bcf3c2492d 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -11,6 +11,7 @@ use anyhow::{Context, Result}; use codewhale_config::provider::WireFormat; use codewhale_config::provider_base_url_is_official; use serde_json::{Value, json}; +use std::collections::HashSet; use crate::config::ApiProvider; use crate::llm_client::StreamEventBox; @@ -569,27 +570,41 @@ impl DeepSeekClient { (reasoning_origin.as_ref(), event.get("item")) && item.get("type").and_then(Value::as_str) == Some("reasoning") - && let Some(encrypted_content) = item + { + match item .get("encrypted_content") .and_then(Value::as_str) .filter(|value| !value.is_empty()) - { - yield Ok(StreamEvent::ContentBlockDelta { - index: idx, - delta: Delta::ReasoningStateDelta { - state: OpaqueReasoningState { - provider: provider.clone(), - api: "openai-responses".to_string(), - model: model.clone(), - id: item - .get("id") - .and_then(Value::as_str) - .map(str::to_string), - encrypted_content: encrypted_content.to_string(), - endpoint: Some(endpoint.clone()), - }, - }, - }); + { + Some(encrypted_content) => { + yield Ok(StreamEvent::ContentBlockDelta { + index: idx, + delta: Delta::ReasoningStateDelta { + state: OpaqueReasoningState { + provider: provider.clone(), + api: "openai-responses".to_string(), + model: model.clone(), + id: item + .get("id") + .and_then(Value::as_str) + .map(str::to_string), + encrypted_content: + encrypted_content.to_string(), + endpoint: Some(endpoint.clone()), + }, + }, + }); + } + None => { + // With `store:false` this item + // cannot be replayed; its + // paired tool calls would be + // rejected on the next request. + logging::warn( + "Responses reasoning item completed without encrypted_content; replay will omit it and its paired tool calls may be rejected by the provider", + ); + } + } } yield Ok(StreamEvent::ContentBlockStop { index: idx }); current_block_index = None; @@ -687,7 +702,15 @@ impl DeepSeekClient { thinking, signature: None, state: None, + redacted_data: None, }, + // Redacted thinking does not occur on Responses + // routes (encrypted reasoning rides the reasoning + // item's `encrypted_content` instead); keep the + // match total without inventing a block shape. + ContentBlockStart::RedactedThinking { .. } => { + continue; + } ContentBlockStart::ToolUse { id, name, @@ -735,6 +758,10 @@ impl DeepSeekClient { // Anthropic-native signature deltas never occur on // the Responses bridge (#3014). } + Delta::ToolThoughtSignatureDelta { .. } => { + // Google compat-route signatures never occur on + // the Responses bridge. + } Delta::ReasoningStateDelta { state } => { if let Some(ContentBlock::Thinking { state: existing, .. @@ -791,10 +818,46 @@ pub(super) fn responses_tool_output(content: &str, content_blocks: Option<&[Valu json!(output) } +/// Whether an assistant message carries tool calls. +fn message_has_tool_calls(msg: &crate::models::Message) -> bool { + msg.content + .iter() + .any(|block| matches!(block, ContentBlock::ToolUse { .. })) +} + +/// Whether any thinking block of this message replays its encrypted +/// reasoning on this route: same provider, same Responses API, same model. +fn message_has_replayable_reasoning( + msg: &crate::models::Message, + provider: &ApiProvider, + model: &str, +) -> bool { + msg.content.iter().any(|block| { + matches!( + block, + ContentBlock::Thinking { + state: Some(state), + redacted_data: None, + .. + } if state.provider == provider.as_str() + && state.api == "openai-responses" + && state.model == model + ) + }) +} + /// Convert Codewhale messages to Responses API input items. /// /// `reasoning_provider_tag` scopes opaque-reasoning replay to the endpoint /// that minted the state; see [`build_responses_body_for_provider`]. +/// +/// Pairing integrity: with `store:false`, every `function_call` item must be +/// immediately preceded by the encrypted `reasoning` item that produced it, +/// and every `reasoning` item must be immediately followed by its produced +/// item. When a message's reasoning is unusable on this route (captured on a +/// different model or provider, or by a build that did not capture it), its +/// whole tool chain — reasoning, function calls, and their outputs — is +/// dropped instead of shipping a request the API rejects outright. pub(super) fn convert_messages_to_responses_input( request: &MessageRequest, provider: ApiProvider, @@ -820,6 +883,7 @@ pub(super) fn convert_messages_to_responses_input( // cannot drift from the include gate. let replays_encrypted_reasoning = responses_route_sends_encrypted_reasoning_include(provider); let mut items = Vec::new(); + let mut orphaned_call_ids: HashSet = HashSet::new(); for msg in &request.messages { // Channel selection lives in the shared placement table; this adapter @@ -857,6 +921,9 @@ pub(super) fn convert_messages_to_responses_input( content_items = Vec::new(); } let (call_id, _item_id) = parse_tool_use_id(tool_use_id); + if orphaned_call_ids.contains(&call_id) { + continue; + } items.push(json!({ "type": "function_call_output", "call_id": call_id, @@ -875,6 +942,30 @@ pub(super) fn convert_messages_to_responses_input( } } RolePlacement::Assistant | RolePlacement::InterruptedAssistant => { + // Tool calls whose producing reasoning is not replayable on + // this route would ship as orphaned `function_call` items — + // an automatic 400 on reasoning models. Drop the whole chain + // instead; the tool outputs are filtered out of the following + // user/tool messages via `orphaned_call_ids`. Models that + // never reason legitimately emit bare calls, so the gate only + // applies to reasoning-capable request models. + let mut chain_dropped = false; + if !is_deepseek + && crate::models::model_supports_reasoning(&request.model) + && message_has_tool_calls(msg) + && !message_has_replayable_reasoning(msg, &provider, &request.model) + { + chain_dropped = true; + for block in &msg.content { + if let ContentBlock::ToolUse { id, .. } = block { + let (call_id, _item_id) = parse_tool_use_id(id); + orphaned_call_ids.insert(call_id); + } + } + logging::warn( + "Responses replay dropped an assistant tool chain whose encrypted reasoning is unavailable for this route/model (captured on a different model or provider, or by an older build); its tool calls and outputs are omitted to keep the request valid", + ); + } for block in &msg.content { match block { ContentBlock::Text { text, .. } => { @@ -899,6 +990,9 @@ pub(super) fn convert_messages_to_responses_input( ContentBlock::ToolUse { id, name, input, .. } => { + if chain_dropped { + continue; + } let (call_id, _item_id) = parse_tool_use_id(id); items.push(json!({ "type": "function_call", @@ -908,8 +1002,17 @@ pub(super) fn convert_messages_to_responses_input( })); } ContentBlock::Thinking { - thinking, state, .. + thinking, + state, + redacted_data, + .. } => { + if chain_dropped || redacted_data.is_some() { + // Redacted Anthropic payloads have no + // Responses representation; skip rather than + // emit an empty reasoning item. + continue; + } if let Some(state) = state { // Endpoint-scoped replay: the tag must match // the endpoint this request is bound to @@ -980,6 +1083,12 @@ pub(super) fn convert_messages_to_responses_input( } = block { let (call_id, _item_id) = parse_tool_use_id(tool_use_id); + if orphaned_call_ids.contains(&call_id) { + // The call was chain-dropped with its + // reasoning; shipping the bare output is + // another pairing 400. + continue; + } items.push(json!({ "type": "function_call_output", "call_id": call_id, @@ -1024,6 +1133,40 @@ pub(super) fn convert_messages_to_responses_input( } } + if !is_deepseek { + // A `reasoning` item must be immediately followed by the item it + // produced (its function_call, or the assistant message for text + // output). An orphaned reasoning item — in practice a turn that was + // interrupted before producing any output — makes the whole request + // fail with "was provided without its required following item", so + // it is dropped here. + let mut keep = vec![true; items.len()]; + for (index, item) in items.iter().enumerate() { + if item.get("type").and_then(Value::as_str) != Some("reasoning") { + continue; + } + let followed_by_produced_item = items.get(index + 1).is_some_and(|next| { + next.get("type").and_then(Value::as_str) == Some("function_call") + || (next.get("type").and_then(Value::as_str) == Some("message") + && next.get("role").and_then(Value::as_str) == Some("assistant")) + }); + if !followed_by_produced_item { + keep[index] = false; + } + } + let mut paired = Vec::with_capacity(items.len()); + for (index, item) in items.into_iter().enumerate() { + if keep[index] { + paired.push(item); + } else { + logging::warn( + "Responses replay dropped an orphaned reasoning item with no following output item", + ); + } + } + items = paired; + } + items } diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index 548851cc89..4a07d64e1f 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -1,5 +1,6 @@ use super::*; +use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -59,6 +60,26 @@ impl Respond for AlwaysError { } } +/// A thinking block carrying encrypted reasoning that is replayable on the +/// Codex route for `model`. Required ahead of any `ToolUse` block in a test +/// history: a reasoning-capable model's bare function_call is chain-dropped +/// by the pairing guard. +fn codex_reasoning_block(model: &str) -> ContentBlock { + ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: Some(OpaqueReasoningState { + provider: ApiProvider::OpenaiCodex.as_str().to_string(), + api: "openai-responses".to_string(), + model: model.to_string(), + id: None, + encrypted_content: "enc_test_payload".to_string(), + endpoint: Some("fp-codex-endpoint".to_string()), + }), + redacted_data: None, + } +} + fn minimal_responses_request() -> MessageRequest { MessageRequest { model: "gpt-5.5".to_string(), @@ -661,6 +682,7 @@ fn deepseek_flash_responses_body_uses_stateless_0731_contract() { thinking: "preserve this tool-loop reasoning".to_string(), signature: None, state: None, + redacted_data: None, }], }, ); @@ -747,11 +769,20 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { 0, Message { role: Role::Assistant, - content: vec![ContentBlock::Thinking { - thinking: SENTINEL.to_string(), - signature: None, - state: Some(state), - }], + content: vec![ + ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(state), + redacted_data: None, + }, + // Text pairs the reasoning item with its produced message so + // the sequence satisfies the immediate-following-item rule. + ContentBlock::Text { + text: "done".to_string(), + cache_control: None, + }, + ], }, ); @@ -771,7 +802,14 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { exact.pointer("/input/0/encrypted_content"), Some(&json!("enc_opaque_payload")) ); + assert_eq!(exact.pointer("/input/1/type"), Some(&json!("message"))); + assert_eq!(exact.pointer("/input/1/role"), Some(&json!("assistant"))); + // A mid-session model switch invalidates the encrypted state. The + // reasoning item is dropped — and a tool call that was to follow it + // would be chain-dropped with it, because shipping the bare + // function_call is an automatic 400 ("was provided without its required + // 'reasoning' item"). request.model = "gpt-5.6".to_string(); let switched_model = build_responses_body_for_provider( &request, @@ -804,6 +842,113 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { ); } +/// A tool chain whose reasoning was captured on another model must lose the +/// reasoning item, the function calls, AND their outputs together — replaying +/// the calls alone is the "function_call provided without its required +/// 'reasoning' item" 400 that previously bricked cross-model resumes. +#[test] +fn codex_chain_drops_tool_calls_whose_reasoning_is_not_replayable() { + let mut request = minimal_responses_request(); + request.messages = vec![ + Message { + role: Role::Assistant, + content: vec![ + // Captured on gpt-5.5 but replayed on gpt-5.6: unusable. + codex_reasoning_block("gpt-5.5"), + ContentBlock::ToolUse { + id: "call_orphan|fc_old".to_string(), + name: "read".to_string(), + input: json!({"path": "x"}), + caller: None, + thought_signature: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call_orphan|fc_old".to_string(), + content: "stale output".to_string(), + is_error: None, + content_blocks: None, + }], + }, + // The same orphaned call can also surface as a `tool`-role history + // message (imported/legacy transcript shape); its output must be + // filtered too. + Message { + role: Role::Unrecognized("tool".to_string()), + content: vec![ContentBlock::ToolResult { + tool_use_id: "call_orphan|fc_old".to_string(), + content: "stale output via tool role".to_string(), + is_error: None, + content_blocks: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "continue".to_string(), + cache_control: None, + }], + }, + ]; + request.model = "gpt-5.6".to_string(); + + let body = build_responses_body_for_provider( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + "fp-codex-endpoint", + BODY_TEST_BASE_URL, + ); + let input = body["input"].as_array().expect("input items"); + assert!( + input.iter().all(|item| { + !matches!( + item["type"].as_str(), + Some("reasoning") | Some("function_call") | Some("function_call_output") + ) + }), + "orphaned chain must be dropped whole: {body}" + ); + assert!( + input + .iter() + .any(|item| item["type"] == "message" && item["role"] == "user"), + "the plain user turn must survive: {body}" + ); +} + +/// Reasoning items with no following output (an interrupted reasoning-only +/// turn, or reasoning chased away from its produced item) are rejected with +/// "was provided without its required following item" and must be dropped. +#[test] +fn codex_drops_orphaned_reasoning_items_without_a_following_output() { + let mut request = minimal_responses_request(); + request.messages.insert( + 0, + Message { + role: Role::Assistant, + // Reasoning-only turn: interrupted before any output. + content: vec![codex_reasoning_block("gpt-5.5")], + }, + ); + + let body = build_responses_body_for_provider( + &request, + ApiProvider::OpenaiCodex, + ApiProvider::OpenaiCodex.as_str(), + "fp-codex-endpoint", + BODY_TEST_BASE_URL, + ); + let input = body["input"].as_array().expect("input items"); + assert!( + input.iter().all(|item| item["type"] != "reasoning"), + "trailing bare reasoning must be dropped: {body}" + ); +} + #[tokio::test] async fn codex_stream_captures_encrypted_reasoning_as_opaque_state() { let server = MockServer::start().await; @@ -864,6 +1009,79 @@ async fn codex_stream_captures_encrypted_reasoning_as_opaque_state() { ); } +/// Every Responses route requests `include: ["reasoning.encrypted_content"]`, +/// so every one of them must capture the encrypted item — not just Codex. +/// A non-Codex route that dropped the item shipped bare function calls that +/// OpenAI rejects with "provided without its required 'reasoning' item". +#[tokio::test] +async fn non_codex_responses_route_captures_encrypted_reasoning() { + let server = MockServer::start().await; + let sse_body = concat!( + "data: {\"type\":\"response.output_item.added\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_9\"}}\n\n", + "data: {\"type\":\"response.reasoning_summary_text.delta\",\"delta\":\"visible summary\"}\n\n", + "data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"reasoning\",\"id\":\"rs_9\",\"summary\":[],\"encrypted_content\":\"enc_custom\"}}\n\n", + "data: [DONE]\n\n", + ); + Mock::given(method("POST")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("Content-Type", "text/event-stream") + .set_body_string(sse_body), + ) + .mount(&server) + .await; + + let config = Config { + provider: Some("custom-a".to_string()), + providers: Some(ProvidersConfig { + custom: HashMap::from([( + "custom-a".to_string(), + ProviderConfig { + kind: Some("openai-compatible".to_string()), + base_url: Some(format!("{}/v1", server.uri())), + model: Some("gpt-5.5".to_string()), + api_key: Some("custom-test-key".to_string()), + wire: Some("responses".to_string()), + ..ProviderConfig::default() + }, + )]), + ..ProvidersConfig::default() + }), + ..Config::default() + }; + let client = { + let _env_lock = crate::test_support::lock_test_env(); + DeepSeekClient::new(&config).unwrap() + }; + let mut stream = client + .handle_responses_stream( + &client + .prepare_outbound_request(minimal_responses_request(), true) + .expect("responses request prepares"), + ) + .await + .unwrap(); + let mut captured = None; + while let Some(event) = stream.next().await { + if let StreamEvent::ContentBlockDelta { + delta: Delta::ReasoningStateDelta { state }, + .. + } = event.unwrap() + { + captured = Some(state); + } + } + + let state = captured + .expect("non-Codex Responses routes must capture encrypted reasoning for replay pairing"); + // The endpoint-scoped Custom tag (not the generic kind) pairs the + // captured payload with the gateway that minted it. + assert_eq!(state.provider, "custom/custom-a"); + assert_ne!(state.provider, client.api_provider.as_str()); + assert_eq!(state.model, "gpt-5.5"); + assert_eq!(state.encrypted_content, "enc_custom"); +} + #[test] fn deepseek_responses_reasoning_effort_uses_documented_labels() { assert_eq!(responses_reasoning_effort("low", true), Some("low")); @@ -1126,13 +1344,19 @@ fn responses_input_includes_user_role_tool_results() { messages: vec![ Message { role: Role::Assistant, - content: vec![ContentBlock::ToolUse { - id: "call_abc|fc_123".to_string(), - name: "checklist_write".to_string(), - input: json!({"items": []}), - caller: None, - thought_signature: None, - }], + content: vec![ + // The reasoning item must pair with the tool call: a + // reasoning-capable model's bare function_call is + // rejected by the API. + codex_reasoning_block("gpt-5.5"), + ContentBlock::ToolUse { + id: "call_abc|fc_123".to_string(), + name: "checklist_write".to_string(), + input: json!({"items": []}), + caller: None, + thought_signature: None, + }, + ], }, Message { role: Role::User, @@ -1164,12 +1388,13 @@ fn responses_input_includes_user_role_tool_results() { OFFICIAL_CODEX_BASE_URL, ); - assert_eq!(input[0]["type"], "function_call"); - assert_eq!(input[0]["call_id"], "call_abc"); - assert_eq!(input[0]["name"], "checklist_write"); - assert_eq!(input[1]["type"], "function_call_output"); + assert_eq!(input[0]["type"], "reasoning"); + assert_eq!(input[1]["type"], "function_call"); assert_eq!(input[1]["call_id"], "call_abc"); - assert_eq!(input[1]["output"], "<6 items>"); + assert_eq!(input[1]["name"], "checklist_write"); + assert_eq!(input[2]["type"], "function_call_output"); + assert_eq!(input[2]["call_id"], "call_abc"); + assert_eq!(input[2]["output"], "<6 items>"); } #[test] @@ -1178,13 +1403,16 @@ fn responses_input_encodes_tool_call_names() { model: "gpt-5.5".to_string(), messages: vec![Message { role: Role::Assistant, - content: vec![ContentBlock::ToolUse { - id: "call_abc|fc_123".to_string(), - name: "web.run".to_string(), - input: json!({}), - caller: None, - thought_signature: None, - }], + content: vec![ + codex_reasoning_block("gpt-5.5"), + ContentBlock::ToolUse { + id: "call_abc|fc_123".to_string(), + name: "web.run".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + ], }], max_tokens: 128, system: None, @@ -1206,8 +1434,9 @@ fn responses_input_encodes_tool_call_names() { OFFICIAL_CODEX_BASE_URL, ); - assert_eq!(input[0]["type"], "function_call"); - assert_eq!(input[0]["name"], to_api_tool_name("web.run")); + assert_eq!(input[0]["type"], "reasoning"); + assert_eq!(input[1]["type"], "function_call"); + assert_eq!(input[1]["name"], to_api_tool_name("web.run")); } #[test] @@ -1366,13 +1595,16 @@ fn tool_result_image_becomes_native_function_output_content() { request.messages = vec![ Message { role: Role::Assistant, - content: vec![ContentBlock::ToolUse { - id: "call_image_1".to_string(), - name: "read".to_string(), - input: serde_json::json!({"path": "shot.png"}), - caller: None, - thought_signature: None, - }], + content: vec![ + codex_reasoning_block("gpt-5.5"), + ContentBlock::ToolUse { + id: "call_image_1".to_string(), + name: "read".to_string(), + input: serde_json::json!({"path": "shot.png"}), + caller: None, + thought_signature: None, + }, + ], }, Message { role: Role::User, @@ -1690,11 +1922,20 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() 0, Message { role: Role::Assistant, - content: vec![ContentBlock::Thinking { - thinking: SENTINEL.to_string(), - signature: None, - state: Some(state), - }], + content: vec![ + ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(state), + redacted_data: None, + }, + // Text pairs the reasoning item with its produced message + // so the sequence satisfies the immediate-following-item rule. + ContentBlock::Text { + text: "Done.".to_string(), + cache_control: None, + }, + ], }, ); @@ -1755,6 +1996,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() encrypted_content: "enc_legacy_payload".to_string(), endpoint: None, }), + redacted_data: None, }]; let legacy_state = build_responses_body_for_provider( &request, @@ -1790,6 +2032,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() encrypted_content: "enc_legacy_root_payload".to_string(), endpoint: None, }), + redacted_data: None, }]; let legacy_root = build_responses_body_for_provider( &request, @@ -1816,6 +2059,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() encrypted_content: "enc_custom_payload".to_string(), endpoint: Some(MINTING_ENDPOINT_FP.to_string()), }), + redacted_data: None, }]; let switched_table = build_responses_body_for_provider( &request, @@ -1852,6 +2096,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() encrypted_content: "enc_codex_payload".to_string(), endpoint: Some(MINTING_ENDPOINT_FP.to_string()), }), + redacted_data: None, }]; let codex_state_on_custom = build_responses_body_for_provider( &request, @@ -1899,18 +2144,27 @@ fn forkguard_fixed_endpoint_legacy_state_without_fingerprint_keeps_replaying() { 0, Message { role: Role::Assistant, - content: vec![ContentBlock::Thinking { - thinking: SENTINEL.to_string(), - signature: None, - state: Some(OpaqueReasoningState { - provider: ApiProvider::OpenaiCodex.as_str().to_string(), - api: "openai-responses".to_string(), - model: request.model.clone(), - id: Some("rs_legacy_codex".to_string()), - encrypted_content: "enc_legacy_codex_payload".to_string(), - endpoint: None, - }), - }], + content: vec![ + ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(OpaqueReasoningState { + provider: ApiProvider::OpenaiCodex.as_str().to_string(), + api: "openai-responses".to_string(), + model: request.model.clone(), + id: Some("rs_legacy_codex".to_string()), + encrypted_content: "enc_legacy_codex_payload".to_string(), + endpoint: None, + }), + redacted_data: None, + }, + // Text pairs the reasoning item with its produced message + // so the sequence satisfies the immediate-following-item rule. + ContentBlock::Text { + text: "Done.".to_string(), + cache_control: None, + }, + ], }, ); let body = build_responses_body_for_provider( @@ -1946,18 +2200,27 @@ fn forkguard_repointed_builtin_legacy_state_without_fingerprint_fails_closed() { 0, Message { role: Role::Assistant, - content: vec![ContentBlock::Thinking { - thinking: SENTINEL.to_string(), - signature: None, - state: Some(OpaqueReasoningState { - provider: ApiProvider::OpenaiCodex.as_str().to_string(), - api: "openai-responses".to_string(), - model: request.model.clone(), - id: Some("rs_legacy_codex".to_string()), - encrypted_content: "enc_legacy_codex_payload".to_string(), - endpoint: None, - }), - }], + content: vec![ + ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(OpaqueReasoningState { + provider: ApiProvider::OpenaiCodex.as_str().to_string(), + api: "openai-responses".to_string(), + model: request.model.clone(), + id: Some("rs_legacy_codex".to_string()), + encrypted_content: "enc_legacy_codex_payload".to_string(), + endpoint: None, + }), + redacted_data: None, + }, + // Text pairs the reasoning item with its produced message + // so the sequence satisfies the immediate-following-item rule. + ContentBlock::Text { + text: "Done.".to_string(), + cache_control: None, + }, + ], }, ); let body = build_responses_body_for_provider( @@ -2001,11 +2264,20 @@ fn forkguard_replay_never_rides_a_route_that_omits_the_include() { 0, Message { role: Role::Assistant, - content: vec![ContentBlock::Thinking { - thinking: SENTINEL.to_string(), - signature: None, - state: Some(state), - }], + content: vec![ + ContentBlock::Thinking { + thinking: SENTINEL.to_string(), + signature: None, + state: Some(state), + redacted_data: None, + }, + // Text pairs the reasoning item with its produced message + // so the sequence satisfies the immediate-following-item rule. + ContentBlock::Text { + text: "Done.".to_string(), + cache_control: None, + }, + ], }, ); let body = build_responses_body_for_provider( @@ -2101,11 +2373,20 @@ async fn forkguard_custom_responses_captured_state_replays_on_the_next_turn() { 0, Message { role: Role::Assistant, - content: vec![ContentBlock::Thinking { - thinking: String::new(), - signature: None, - state: Some(state), - }], + content: vec![ + ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: Some(state), + redacted_data: None, + }, + // Text pairs the reasoning item with its produced message + // so the sequence satisfies the immediate-following-item rule. + ContentBlock::Text { + text: "Done.".to_string(), + cache_control: None, + }, + ], }, ); let prepared = client @@ -2221,11 +2502,20 @@ async fn forkguard_capture_replay_drops_across_a_base_url_edit() { 0, Message { role: Role::Assistant, - content: vec![ContentBlock::Thinking { - thinking: String::new(), - signature: None, - state: Some(captured), - }], + content: vec![ + ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: Some(captured), + redacted_data: None, + }, + // Text pairs the reasoning item with its produced message + // so the sequence satisfies the immediate-following-item rule. + ContentBlock::Text { + text: "Done.".to_string(), + cache_control: None, + }, + ], }, ); let prepared = client diff --git a/crates/tui/src/commands/groups/session/export.rs b/crates/tui/src/commands/groups/session/export.rs index 740d790076..957196a28e 100644 --- a/crates/tui/src/commands/groups/session/export.rs +++ b/crates/tui/src/commands/groups/session/export.rs @@ -1066,6 +1066,7 @@ mod tests { thinking: "private chain of thought".to_string(), signature: Some("signature-secret".to_string()), state: None, + redacted_data: None, }, ContentBlock::ToolUse { id: "call-1".to_string(), diff --git a/crates/tui/src/commands/groups/session/structcopy.rs b/crates/tui/src/commands/groups/session/structcopy.rs index c380444521..bcb640f305 100644 --- a/crates/tui/src/commands/groups/session/structcopy.rs +++ b/crates/tui/src/commands/groups/session/structcopy.rs @@ -1285,6 +1285,7 @@ mod tests { thinking: "private chain of thought".to_string(), signature: Some("signature-secret".to_string()), state: None, + redacted_data: None, }, ContentBlock::ToolUse { id: "call-7".to_string(), diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index b08fd619af..c82850fc55 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -2610,6 +2610,7 @@ mod tests { signature: None, state: None, thinking: thinking.clone(), + redacted_data: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index cf2ba67495..d72e103e8f 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -368,6 +368,7 @@ fn context_pressure_delta_matches_clone_and_push_reference() { thinking: "step".repeat(100), signature: None, state: None, + redacted_data: None, }], }); // Replayed-reasoning case (#perf-r5 fresh-eyes fix): an assistant message @@ -382,6 +383,7 @@ fn context_pressure_delta_matches_clone_and_push_reference() { thinking: "replayed".repeat(300), // 8 bytes per unit -> even count signature: None, state: None, + redacted_data: None, }, ContentBlock::ToolUse { id: "call_1".to_string(), @@ -399,6 +401,7 @@ fn context_pressure_delta_matches_clone_and_push_reference() { thinking: "odd replay".to_string(), // 11 bytes / 4 = 2 (even)... use odd total signature: None, state: None, + redacted_data: None, }, ContentBlock::ToolUse { id: "call_2".to_string(), diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index cd888138dc..69039a44e1 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -14971,6 +14971,258 @@ fn sandbox_policy_for_turn_returns_correct_default_policy_per_mode() { )); } +/// Serves one multi-block thinking + tool-use turn, then a plain closing +/// turn, capturing every outgoing request. +struct MultiThinkingStreamClient { + requests: std::sync::Mutex>, + calls: std::sync::atomic::AtomicUsize, +} + +#[async_trait::async_trait] +impl crate::core::model_client::ModelClient for MultiThinkingStreamClient { + fn provider_name(&self) -> &str { + "anthropic-dialect-test" + } + + fn model(&self) -> &str { + "claude-sonnet-4-6" + } + + async fn create_message( + &self, + _request: crate::models::MessageRequest, + ) -> anyhow::Result { + anyhow::bail!("multi-thinking regression uses the streaming model boundary") + } + + async fn create_message_stream( + &self, + request: crate::models::MessageRequest, + ) -> anyhow::Result { + let call = self + .calls + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + .saturating_add(1); + self.requests + .lock() + .expect("multi-thinking request lock") + .push(request); + use crate::models::{ContentBlockStart, Delta, MessageDelta, StreamEvent}; + let events: Vec = if call == 1 { + vec![ + crate::llm_client::mock::canned::message_start("msg_multi"), + // Block 0: readable thinking with a signature. + StreamEvent::ContentBlockStart { + index: 0, + content_block: ContentBlockStart::Thinking { + thinking: "first block".to_string(), + }, + }, + StreamEvent::ContentBlockDelta { + index: 0, + delta: Delta::ThinkingDelta { + thinking: " continued".to_string(), + }, + }, + StreamEvent::ContentBlockDelta { + index: 0, + delta: Delta::SignatureDelta { + signature: "sig-1".to_string(), + }, + }, + StreamEvent::ContentBlockStop { index: 0 }, + // Block 1: `display: "omitted"` shape — empty text, real + // signature. + StreamEvent::ContentBlockStart { + index: 1, + content_block: ContentBlockStart::Thinking { + thinking: String::new(), + }, + }, + StreamEvent::ContentBlockDelta { + index: 1, + delta: Delta::SignatureDelta { + signature: "sig-2".to_string(), + }, + }, + StreamEvent::ContentBlockStop { index: 1 }, + // Block 2: redacted thinking, complete at start. + StreamEvent::ContentBlockStart { + index: 2, + content_block: ContentBlockStart::RedactedThinking { + data: "ENC_REDACTED_1".to_string(), + }, + }, + StreamEvent::ContentBlockStop { index: 2 }, + // Tool use closes the turn. + StreamEvent::ContentBlockStart { + index: 3, + content_block: ContentBlockStart::ToolUse { + id: "toolu_multi".to_string(), + name: "read_file".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + }, + StreamEvent::ContentBlockDelta { + index: 3, + delta: Delta::InputJsonDelta { + partial_json: r#"{"path":"a.txt"}"#.to_string(), + }, + }, + StreamEvent::ContentBlockStop { index: 3 }, + StreamEvent::MessageDelta { + delta: MessageDelta { + stop_reason: Some("tool_use".to_string()), + stop_sequence: None, + }, + usage: None, + }, + StreamEvent::MessageStop, + ] + } else { + if call > 2 { + anyhow::bail!("unexpected multi-thinking request #{call}"); + } + crate::llm_client::mock::canned::simple_text_turn("done") + }; + let events = events.into_iter().map(Ok); + Ok(Box::pin(futures_util::stream::iter(events))) + } + + async fn health_check(&self) -> anyhow::Result { + Ok(true) + } +} + +/// Adaptive/interleaved thinking emits several thinking blocks per assistant +/// turn, and Anthropic requires the entire sequence — readable, signature-only +/// (`display: "omitted"`), and redacted — replayed unmodified and in order on +/// the follow-up request. +#[tokio::test] +async fn multi_block_and_redacted_thinking_persist_into_followup_requests() { + let config = Config { + provider: Some("custom-a".to_string()), + providers: Some(crate::config::ProvidersConfig { + custom: HashMap::from([( + "custom-a".to_string(), + crate::config::ProviderConfig { + kind: Some("openai-compatible".to_string()), + base_url: Some("http://127.0.0.1:18191/v1".to_string()), + model: Some("claude-sonnet-4-6".to_string()), + api_key: Some("local-test-key".to_string()), + ..crate::config::ProviderConfig::default() + }, + )]), + ..crate::config::ProvidersConfig::default() + }), + ..Config::default() + }; + let engine_config = EngineConfig { + max_steps: 2, + snapshots_enabled: false, + terminal_chrome_enabled: false, + ..EngineConfig::default() + }; + let client = std::sync::Arc::new(MultiThinkingStreamClient { + requests: std::sync::Mutex::new(Vec::new()), + calls: std::sync::atomic::AtomicUsize::new(0), + }); + let shared: crate::core::model_client::SharedModelClient = client.clone(); + let (engine, handle) = Engine::new_with_model_client(engine_config, &config, shared); + let _run = tokio::spawn(engine.run()); + + handle + .send(Op::SendMessage { + content: "inspect the file".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&config, "claude-sonnet-4-6"), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send op"); + + // Wait for the turn to complete. + let mut completed = false; + for _ in 0..200 { + { + let mut rx = handle.rx_event.write().await; + if let Ok(Some(Event::TurnComplete { .. })) = + tokio::time::timeout(Duration::from_millis(200), rx.recv()).await + { + completed = true; + break; + } + } + } + assert!(completed, "turn did not complete"); + + let requests = client.requests.lock().expect("request lock").clone(); + assert!(requests.len() >= 2, "expected a follow-up request"); + let followup = &requests[1]; + let assistant = followup + .messages + .iter() + .rev() + .find(|message| message.role == Role::Assistant) + .expect("follow-up request must contain the prior assistant turn"); + let mut blocks = assistant.content.iter(); + match ( + blocks.next(), + blocks.next(), + blocks.next(), + blocks.next(), + blocks.next(), + ) { + ( + Some(ContentBlock::Thinking { + thinking, + signature, + redacted_data: None, + .. + }), + Some(ContentBlock::Thinking { + thinking: empty, + signature: sig2, + redacted_data: None, + .. + }), + Some(ContentBlock::Thinking { + redacted_data: Some(data), + .. + }), + Some(ContentBlock::ToolUse { .. }), + None, + ) => { + assert_eq!(thinking, "first block continued"); + assert_eq!(signature.as_deref(), Some("sig-1")); + assert!(empty.is_empty(), "omitted-display text stays empty"); + assert_eq!(sig2.as_deref(), Some("sig-2")); + assert_eq!(data, "ENC_REDACTED_1"); + } + other => panic!("unexpected follow-up assistant blocks: {other:?}"), + } +} + #[tokio::test] async fn session_update_preserves_reasoning_tool_only_turn() { let (mut engine, handle) = Engine::new(EngineConfig::default(), &Config::default()); @@ -14981,6 +15233,7 @@ async fn session_update_preserves_reasoning_tool_only_turn() { signature: None, state: None, thinking: "Need a tool before answering.".to_string(), + redacted_data: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 33434375b5..98185b600f 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -690,6 +690,11 @@ struct StreamOutcome { current_thinking: String, current_thinking_signature: Option, current_thinking_state: Option, + /// Thinking blocks already terminated by a `content_block_stop`, in wire + /// order. Adaptive/interleaved thinking emits several per assistant turn; + /// Anthropic requires the whole sequence replayed unmodified, so finished + /// blocks are parked here instead of being overwritten by the next one. + completed_thinking: Vec, tool_uses: Vec, usage: Usage, usage_reported: bool, @@ -720,6 +725,35 @@ pub(super) fn preview_request_error_user_message( format!("{error:#}") } +/// Park a finished thinking block (deltas + signature + optional Responses +/// continuity state) into the turn's ordered completed list. +/// +/// A block survives only if the provider actually emitted reasoning: readable +/// text (minus the wire-only "(reasoning omitted)" placeholder), an Anthropic +/// signature, or an encrypted Responses state. A signature-only block is real +/// content — newer Claude models ship `display: "omitted"` thinking (empty +/// text plus the encrypted signature) — and dropping it would strip a block +/// Anthropic requires back verbatim in tool-loop replays. +fn flush_thinking_block( + thinking: &mut String, + signature: &mut Option, + state: &mut Option, + completed: &mut Vec, +) { + let placeholder_only = crate::client::is_reasoning_replay_placeholder(thinking); + if (!thinking.is_empty() && !placeholder_only) || signature.is_some() || state.is_some() { + completed.push(ContentBlock::Thinking { + thinking: std::mem::take(thinking), + signature: signature.take(), + state: state.take(), + redacted_data: None, + }); + } + thinking.clear(); + *signature = None; + *state = None; +} + fn approval_intent_summary(text: &str) -> Option { let trimmed = text.trim(); if trimmed.is_empty() { @@ -2136,6 +2170,7 @@ impl Engine { current_thinking, current_thinking_signature, current_thinking_state, + mut completed_thinking, mut tool_uses, usage, usage_reported, @@ -2312,17 +2347,23 @@ impl Engine { // variable is still empty at this point and will be // rebuilt on the next round. let mut resume_blocks: Vec = Vec::new(); + // Thinking blocks the stream already terminated stay + // in the retry prefix, in wire order, ahead of the + // block that was still open when the stream died. + resume_blocks.append(&mut completed_thinking); // A wire-only placeholder must not ride into the // retry prefix as stored reasoning either. let thinking_is_placeholder_only = crate::client::is_reasoning_replay_placeholder(¤t_thinking); if (!current_thinking.is_empty() && !thinking_is_placeholder_only) || current_thinking_state.is_some() + || current_thinking_signature.is_some() { resume_blocks.push(ContentBlock::Thinking { thinking: current_thinking.clone(), signature: current_thinking_signature.clone(), state: current_thinking_state.clone(), + redacted_data: None, }); } if !current_text_visible.is_empty() { @@ -2434,15 +2475,24 @@ impl Engine { // that compatibility value to the outgoing JSON only. Persisting // it here leaked an invented "(reasoning omitted)" block into the // transcript and every provider-neutral session replay. + // `completed_thinking` holds every thinking/redacted block the + // stream already terminated, in wire order; the guard below keeps + // a trailing block whose stop event never arrived. Both must + // survive — Anthropic replays the whole thinking sequence + // unmodified, and a signature-only block (Claude's + // `display: "omitted"`) is real content. + content_blocks.append(&mut completed_thinking); let thinking_is_placeholder_only = crate::client::is_reasoning_replay_placeholder(¤t_thinking); if (!current_thinking.is_empty() && !thinking_is_placeholder_only) || current_thinking_state.is_some() + || current_thinking_signature.is_some() { content_blocks.push(ContentBlock::Thinking { thinking: current_thinking.clone(), signature: current_thinking_signature.clone(), state: current_thinking_state.clone(), + redacted_data: None, }); } let mut final_text = current_text_visible.clone(); @@ -5120,6 +5170,12 @@ impl Engine { // thinking block; must be replayed verbatim in tool loops. let mut current_thinking_signature: Option = None; let mut current_thinking_state: Option = None; + // Adaptive/interleaved thinking can emit more than one thinking block + // per assistant turn. Anthropic requires the entire sequence of + // thinking blocks to be replayed "complete and unmodified" and in the + // original order, so finished blocks are parked here in arrival order + // instead of being overwritten by the next block's deltas. + let mut completed_thinking: Vec = Vec::new(); let mut tool_uses: Vec = Vec::new(); let mut usage = Usage { input_tokens: 0, @@ -5469,9 +5525,16 @@ impl Engine { .await; } ContentBlockStart::Thinking { thinking } => { + // A previous thinking block that never saw a stop + // event still counts as finished; park it so the new + // block cannot overwrite it. + flush_thinking_block( + &mut current_thinking, + &mut current_thinking_signature, + &mut current_thinking_state, + &mut completed_thinking, + ); current_thinking = thinking; - current_thinking_signature = None; - current_thinking_state = None; current_block_kind = Some(ContentBlockKind::Thinking); let _ = self .tx_event @@ -5480,6 +5543,34 @@ impl Engine { }) .await; } + ContentBlockStart::RedactedThinking { data } => { + // Anthropic withheld this whole thinking block for + // safety and replaced it with an encrypted payload. + // There are no deltas; the block arrives complete at + // start and must be replayed byte-exact, so park it + // immediately. No UI events: there is no readable + // content to stream. + flush_thinking_block( + &mut current_thinking, + &mut current_thinking_signature, + &mut current_thinking_state, + &mut completed_thinking, + ); + completed_thinking.push(ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: None, + redacted_data: Some(data), + }); + // Kind is cleared so the block's stop event needs no + // handling — it was parked above and has no deltas — + // and so a stale kind cannot emit a ThinkingComplete + // for a provider that skips stops. + current_block_kind = None; + crate::logging::info( + "Captured redacted thinking block for verbatim replay", + ); + } ContentBlockStart::ToolUse { id, name, @@ -5581,6 +5672,17 @@ impl Engine { Delta::ReasoningStateDelta { state } => { current_thinking_state = Some(state); } + Delta::ToolThoughtSignatureDelta { signature } => { + // Google thought signature delivered on a continuation + // chunk of an already-started tool call. First one + // wins: a call carries exactly one signature. + if let Some(&tool_idx) = current_tool_indices.get(&index) + && let Some(tool_state) = tool_uses.get_mut(tool_idx) + && tool_state.thought_signature.is_none() + { + tool_state.thought_signature = Some(signature); + } + } Delta::InputJsonDelta { partial_json } => { if let Some(&tool_idx) = current_tool_indices.get(&index) && let Some(tool_state) = tool_uses.get_mut(tool_idx) @@ -5641,6 +5743,16 @@ impl Engine { index: index as usize, }) .await; + // The block is finished: park it in wire order so + // a following thinking block cannot overwrite it + // (adaptive/interleaved thinking emits several per + // turn; Anthropic replays the whole sequence). + flush_thinking_block( + &mut current_thinking, + &mut current_thinking_signature, + &mut current_thinking_state, + &mut completed_thinking, + ); } Some(ContentBlockKind::ToolUse) | None => {} } @@ -5774,6 +5886,7 @@ impl Engine { current_thinking, current_thinking_signature, current_thinking_state, + completed_thinking, tool_uses, usage, usage_reported, @@ -6850,6 +6963,9 @@ fn stream_event_has_actionable_content(event: &StreamEvent) -> bool { StreamEvent::ContentBlockStart { content_block, .. } => match content_block { ContentBlockStart::Text { text } => !text.is_empty(), ContentBlockStart::Thinking { thinking } => !thinking.is_empty(), + // A redacted thinking block is provider-owned encrypted content — + // its arrival is actionable reasoning output even without text. + ContentBlockStart::RedactedThinking { .. } => true, ContentBlockStart::ToolUse { .. } | ContentBlockStart::ServerToolUse { .. } => true, }, StreamEvent::ContentBlockDelta { delta, .. } => match delta { @@ -6857,6 +6973,7 @@ fn stream_event_has_actionable_content(event: &StreamEvent) -> bool { Delta::ThinkingDelta { thinking } => !thinking.is_empty(), Delta::InputJsonDelta { partial_json } => !partial_json.is_empty(), Delta::SignatureDelta { signature } => !signature.is_empty(), + Delta::ToolThoughtSignatureDelta { signature } => !signature.is_empty(), Delta::ReasoningStateDelta { .. } => true, }, StreamEvent::ToolProjectionWarning { .. } diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index 7aaaff16a2..d924473f11 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -732,6 +732,14 @@ pub enum ContentBlockStart { Text { text: String }, #[serde(rename = "thinking")] Thinking { thinking: String }, + /// Anthropic `redacted_thinking`: the provider withheld the whole block + /// for safety and replaced it with an encrypted payload that arrives + /// complete on the start event (no deltas). It must be captured and + /// replayed byte-exact — the API decrypts it to restore the turn's + /// reasoning, and dropping it makes the next tool-loop request fail with + /// "Expected `thinking` or `redacted_thinking`, but found `tool_use`". + #[serde(rename = "redacted_thinking")] + RedactedThinking { data: String }, #[serde(rename = "tool_use")] ToolUse { id: String, @@ -768,6 +776,13 @@ pub enum Delta { /// of a thinking block on the native Messages stream. #[serde(rename = "signature_delta")] SignatureDelta { signature: String }, + /// Google thought signature that arrived on a continuation chunk of an + /// already-started tool call (`delta.tool_calls[].extra_content.google. + /// thought_signature`). Gemini 3 signs the first function-call part of a + /// step, and some gateways split the signed chunk from the chunk that + /// opens the tool call, so the signature must attach after start. + #[serde(rename = "tool_thought_signature_delta")] + ToolThoughtSignatureDelta { signature: String }, /// Opaque Responses reasoning continuity, attached only when the provider /// returns an encrypted item on the exact originating route. #[serde(rename = "reasoning_state_delta")] @@ -788,6 +803,24 @@ mod tests { use std::any::TypeId; use std::collections::BTreeMap; + #[test] + fn redacted_thinking_stream_start_decodes_with_its_payload() { + // A safeguard-triggered redacted block arrives complete on the start + // event. Before the variant existed this line failed the whole SSE + // event decode and lost the turn. + let block: ContentBlockStart = serde_json::from_value(serde_json::json!({ + "type": "redacted_thinking", + "data": "ENC_REDACTED_PAYLOAD", + })) + .expect("redacted_thinking must decode"); + match block { + ContentBlockStart::RedactedThinking { data } => { + assert_eq!(data, "ENC_REDACTED_PAYLOAD"); + } + other => panic!("expected RedactedThinking, got {other:?}"), + } + } + #[test] fn output_limit_stop_reason_accepts_provider_aliases_only() { for reason in [ diff --git a/crates/tui/src/purge.rs b/crates/tui/src/purge.rs index 1ae5be4701..c0eb53c85f 100644 --- a/crates/tui/src/purge.rs +++ b/crates/tui/src/purge.rs @@ -790,6 +790,7 @@ mod tests { signature: None, state: None, thinking: "let me think...".to_string(), + redacted_data: None, }, ContentBlock::Text { text: "done".to_string(), diff --git a/crates/tui/src/rlm/turn.rs b/crates/tui/src/rlm/turn.rs index d3e2c33979..036459fb35 100644 --- a/crates/tui/src/rlm/turn.rs +++ b/crates/tui/src/rlm/turn.rs @@ -1146,6 +1146,7 @@ mod tests { signature: None, state: None, thinking: "skip".to_string(), + redacted_data: None, }, ContentBlock::Text { text: "second".to_string(), diff --git a/crates/tui/src/runtime_api/sessions.rs b/crates/tui/src/runtime_api/sessions.rs index 81d8ed0ab5..27a86ce965 100644 --- a/crates/tui/src/runtime_api/sessions.rs +++ b/crates/tui/src/runtime_api/sessions.rs @@ -576,6 +576,7 @@ pub(super) fn messages_from_thread_detail(detail: &ThreadDetail) -> Vec thinking: thinking.to_string(), signature: None, state: None, + redacted_data: None, }); } } diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 44857552dc..d596d5cd01 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -8464,6 +8464,7 @@ impl RuntimeThreadManager { thinking, signature: None, state: None, + redacted_data: None, }); } } diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index b321ed2ff5..43e871460d 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -4684,6 +4684,7 @@ async fn seed_thread_keeps_tool_results_on_preceding_turn() -> Result<()> { thinking: "need a tool".to_string(), signature: Some("sig-1".to_string()), state: None, + redacted_data: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), @@ -4811,6 +4812,7 @@ async fn seeded_session_records_carry_a_total_order() -> Result<()> { thinking: "consider".to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), diff --git a/crates/tui/src/tui/auto_router.rs b/crates/tui/src/tui/auto_router.rs index f1c29a6025..313f590386 100644 --- a/crates/tui/src/tui/auto_router.rs +++ b/crates/tui/src/tui/auto_router.rs @@ -142,6 +142,7 @@ mod tests { signature: None, state: None, thinking: "The user seems to be asking me to classify myself.".to_string(), + redacted_data: None, }, ContentBlock::Text { text: "Visible assistant answer.".to_string(), diff --git a/crates/tui/src/tui/history/tests.rs b/crates/tui/src/tui/history/tests.rs index 07d011dcb7..ab46034922 100644 --- a/crates/tui/src/tui/history/tests.rs +++ b/crates/tui/src/tui/history/tests.rs @@ -1915,11 +1915,13 @@ fn restored_history_drops_the_wire_reasoning_placeholder() { thinking: "(reasoning omitted)".to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::Thinking { thinking: "Actual model reasoning".to_string(), signature: None, state: None, + redacted_data: None, }, ], }; diff --git a/crates/tui/src/tui/setup/fleet_draft.rs b/crates/tui/src/tui/setup/fleet_draft.rs index 6e9a1e5242..8b03aa1c1c 100644 --- a/crates/tui/src/tui/setup/fleet_draft.rs +++ b/crates/tui/src/tui/setup/fleet_draft.rs @@ -539,6 +539,7 @@ mod tests { .to_string(), signature: None, state: None, + redacted_data: None, }, ); mock.push_message_response(response); diff --git a/crates/tui/src/tui/setup/model_draft.rs b/crates/tui/src/tui/setup/model_draft.rs index acb97f9c70..c19ba8e904 100644 --- a/crates/tui/src/tui/setup/model_draft.rs +++ b/crates/tui/src/tui/setup/model_draft.rs @@ -410,6 +410,7 @@ mod tests { thinking: r#"Maybe {"about":"A half-formed scratchpad draft."}"#.to_string(), signature: None, state: None, + redacted_data: None, }, ); mock.push_message_response(response); diff --git a/crates/tui/src/tui/ui/dispatch.rs b/crates/tui/src/tui/ui/dispatch.rs index 3d9fcc0589..290280f425 100644 --- a/crates/tui/src/tui/ui/dispatch.rs +++ b/crates/tui/src/tui/ui/dispatch.rs @@ -95,6 +95,7 @@ pub(crate) fn push_assistant_message( thinking, signature: None, state: None, + redacted_data: None, }); } if !text.is_empty() { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 669ea8dffc..0ac9d34191 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5292,6 +5292,7 @@ fn restored_reasoning_and_answer_clear_prior_fold_ownership() { .join("\n"), signature: None, state: None, + redacted_data: None, }, crate::models::ContentBlock::Text { text: "restored final answer".to_string(), diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index fd468e7d0f..31b3c4e1d3 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -864,7 +864,19 @@ pub fn estimate_message_chars(messages: &[Message]) -> usize { for block in &msg.content { match block { ContentBlock::Text { text, .. } => total += text.len(), - ContentBlock::Thinking { thinking, .. } => total += thinking.len(), + // Signature-only (`display: "omitted"`) and redacted blocks + // still ride the wire on replay, so their bytes count even + // when the readable text is empty. + ContentBlock::Thinking { + thinking, + redacted_data, + .. + } => { + total += thinking.len(); + if let Some(data) = redacted_data { + total += data.len(); + } + } ContentBlock::ToolUse { input, .. } => { let mut cw = CountingWriter::new(); let _ = serde_json::to_writer(&mut cw, input); diff --git a/crates/tui/tests/integration/integration_mock_llm.rs b/crates/tui/tests/integration/integration_mock_llm.rs index 4b674cef58..e78c1123d9 100644 --- a/crates/tui/tests/integration/integration_mock_llm.rs +++ b/crates/tui/tests/integration/integration_mock_llm.rs @@ -77,6 +77,7 @@ fn assistant_thinking(thinking: &str, text: &str) -> Message { thinking: thinking.to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::Text { text: text.to_string(), diff --git a/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs b/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs index 756e3c47e6..39c0fed882 100644 --- a/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs +++ b/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs @@ -28,6 +28,7 @@ fn assistant_thinking_tool_call( thinking: thinking.to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::ToolUse { id: id.to_string(), From 5d5015613b99043725395c01b9167bdab19df669 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 8 Oct 2026 02:56:28 +0800 Subject: [PATCH 02/12] fix(tui): round-trip OpenRouter reasoning details Follow-up to the preserved-thinking audit in this PR: OpenRouter's structured reasoning_details sequence is now stored verbatim on the thinking block and replayed unmodified, instead of keeping only the joined text. - storage: ContentBlock::Thinking.reasoning_details (serde-skipped when absent; existing sessions load unchanged) - capture: non-stream responses store the raw entries on OpenRouter routes, filtering the documented "[REDACTED]" streaming placeholder; encrypted-only responses materialize a text-less block so the payload survives - capture: streaming deltas carry cumulative snapshots; entries merge last-wins by index/type and ride the thinking block as Delta::ReasoningDetailsDelta - replay: OpenRouter routes emit the stored sequence on the assistant message and drop the parallel reasoning_content field; effort=off and non-reasoning models suppress it; every other route strips the field so the continuity never leaks across dialects - sanitizer: tool-call turns carrying details no longer get the "(reasoning omitted)" placeholder, and the replay-token estimate counts details bytes Live verification against OpenRouter is still pending (no API credentials in this environment); wire shapes are pinned to OpenRouter's documented round-trip contract with fixtures in lieu of captures. Signed-off-by: asto18089 --- crates/core/src/request.rs | 10 + crates/tui/src/client.rs | 59 ++- crates/tui/src/client/anthropic.rs | 13 + crates/tui/src/client/chat.rs | 475 +++++++++++++++++- .../src/client/chat/tests/stream_decoder.rs | 132 +++++ crates/tui/src/client/ds4_tests.rs | 6 +- crates/tui/src/client/responses.rs | 5 + crates/tui/src/client/responses/tests.rs | 13 + .../tui/src/commands/groups/session/export.rs | 1 + .../src/commands/groups/session/structcopy.rs | 1 + crates/tui/src/compaction.rs | 1 + crates/tui/src/core/engine/preview/tests.rs | 3 + crates/tui/src/core/engine/tests.rs | 1 + crates/tui/src/core/engine/turn_loop.rs | 43 +- crates/tui/src/models.rs | 9 + crates/tui/src/purge.rs | 1 + crates/tui/src/rlm/turn.rs | 1 + crates/tui/src/runtime_api/sessions.rs | 1 + crates/tui/src/runtime_threads.rs | 1 + crates/tui/src/runtime_threads/tests.rs | 2 + crates/tui/src/tui/auto_router.rs | 1 + crates/tui/src/tui/history/tests.rs | 2 + crates/tui/src/tui/setup/fleet_draft.rs | 1 + crates/tui/src/tui/setup/model_draft.rs | 1 + crates/tui/src/tui/ui/dispatch.rs | 1 + crates/tui/src/tui/ui/tests.rs | 1 + .../tests/integration/integration_mock_llm.rs | 1 + ...soning_content_replayed_after_tool_call.rs | 1 + 28 files changed, 774 insertions(+), 13 deletions(-) diff --git a/crates/core/src/request.rs b/crates/core/src/request.rs index 1c0f5b4d36..adfb06991f 100644 --- a/crates/core/src/request.rs +++ b/crates/core/src/request.rs @@ -185,6 +185,15 @@ pub enum ContentBlock { /// for every non-redacted block. #[serde(skip_serializing_if = "Option::is_none", default)] redacted_data: Option, + /// OpenRouter `reasoning_details` entries (`reasoning.text`, + /// `reasoning.encrypted`, `reasoning.summary`), stored verbatim as + /// received. OpenRouter requires the entire sequence to be passed + /// back unmodified on later turns (tool-calling continuity), and the + /// encrypted form carries payloads that `reasoning_content` text + /// cannot. Captured only on OpenRouter routes and replayed only on + /// OpenRouter routes; every other dialect never emits the field. + #[serde(skip_serializing_if = "Option::is_none", default)] + reasoning_details: Option>, }, #[serde(rename = "tool_use")] ToolUse { @@ -236,6 +245,7 @@ impl ContentBlock { signature: None, state: None, redacted_data: None, + reasoning_details: None, } } } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index e491a1de3b..5f4d278f58 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -4208,7 +4208,7 @@ pub(crate) use chat::is_reasoning_replay_placeholder; mod tests { use super::*; use crate::client::chat::{ - build_chat_messages, build_chat_messages_for_request, + ReasoningDetailsBuffer, build_chat_messages, build_chat_messages_for_request, build_chat_messages_for_request_and_provider, count_reasoning_replay_chars, parse_chat_message, parse_sse_chunk, sanitize_thinking_mode_messages, tool_to_chat, tool_to_chat_for_base_url, @@ -5937,6 +5937,7 @@ mod tests { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "call-k3-replay".to_string(), @@ -7182,6 +7183,7 @@ mod tests { endpoint: Some("fp-codex-endpoint".to_string()), }), redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "call-secret-test".to_string(), @@ -8957,6 +8959,7 @@ mod tests { state: None, thinking: "plan".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "done".to_string(), @@ -8998,6 +9001,7 @@ mod tests { state: None, thinking: "plan".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "done".to_string(), @@ -9050,6 +9054,7 @@ mod tests { state: None, thinking: "Need to call a tool".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), @@ -9105,6 +9110,7 @@ mod tests { state: None, thinking: "Need to call a tool".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), @@ -9179,6 +9185,7 @@ mod tests { state: None, thinking: "Internal explanation plan".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "Final answer".to_string(), @@ -9225,6 +9232,7 @@ mod tests { state: None, thinking: "I should explain step by step.".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "Here is the explanation.".to_string(), @@ -10258,6 +10266,8 @@ mod tests { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, true, ); @@ -10383,6 +10393,7 @@ mod tests { state: None, thinking: "plan".to_string(), redacted_data: None, + reasoning_details: None, }], }; let out = build_chat_messages(None, &[message], "some-non-deepseek-model"); @@ -10447,6 +10458,8 @@ mod tests { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, false, ); @@ -10508,6 +10521,8 @@ mod tests { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, false, ); @@ -10533,6 +10548,7 @@ mod tests { state: None, thinking: "Need to inspect the directory".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), @@ -10577,6 +10593,7 @@ mod tests { state: None, thinking: "Need to search".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), @@ -10671,6 +10688,7 @@ mod tests { state: None, thinking: "Need to list files".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "tool-ok".to_string(), @@ -11419,6 +11437,45 @@ mod tests { assert_eq!(assistant_with_reasoning, 2); } + #[test] + fn sanitize_thinking_mode_leaves_details_messages_alone_and_counts_them() { + // OpenRouter details replay: the structured array carries the + // reasoning, so no text placeholder may be injected alongside it, and + // the replay-token estimate must include its bytes. + let details = serde_json::json!([ + { "type": "reasoning.encrypted", "format": "openai-responses-v1", "data": "x" } + ]); + let mut body = json!({ + "model": "deepseek/deepseek-v4-pro", + "messages": [ + { + "role": "assistant", + "content": "", + "reasoning_details": details, + "tool_calls": [{ "id": "1", "type": "function" }] + }, + { "role": "tool", "tool_call_id": "1", "content": "ok" }, + { "role": "user", "content": "next" } + ] + }); + let approx_tokens = sanitize_thinking_mode_messages( + &mut body, + "deepseek/deepseek-v4-pro", + None, + ApiProvider::Openrouter, + ) + .expect("details are replayed and must be reported"); + let message = &body["messages"][0]; + assert!( + message.get("reasoning_content").is_none(), + "a placeholder beside reasoning_details would present the reasoning twice" + ); + // Serialized entry is ~90 bytes -> ~22 tokens; the exact value pins + // that details bytes are counted at all (text alone would be 0). + let serialized_chars = details.to_string().len() as u32; + assert_eq!(approx_tokens, serialized_chars / 4); + } + /// Issue #30: when no thinking-mode replay applies (non-thinking model or /// empty conversation), the sanitizer returns `None` so the footer chip /// stays hidden. diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 64ab704550..5d9eecd0aa 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -1688,12 +1688,14 @@ mod tests { signature: Some("sig-abc".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::Thinking { thinking: "(reasoning omitted)".to_string(), signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "toolu_1".to_string(), @@ -1754,6 +1756,7 @@ mod tests { signature: None, state: None, redacted_data: Some("ENC_REDACTED_PAYLOAD".to_string()), + reasoning_details: None, }, ContentBlock::ToolUse { id: "toolu_1".to_string(), @@ -1806,6 +1809,7 @@ mod tests { signature: Some("sig-omitted".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "toolu_1".to_string(), @@ -1850,18 +1854,21 @@ mod tests { signature: Some("sig-1".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::Thinking { thinking: String::new(), signature: None, state: None, redacted_data: Some("ENC_SECOND".to_string()), + reasoning_details: None, }, ContentBlock::Thinking { thinking: "third block".to_string(), signature: Some("sig-3".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "toolu_1".to_string(), @@ -1904,6 +1911,7 @@ mod tests { signature: Some("sig-old".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "older answer".to_string(), @@ -1926,6 +1934,7 @@ mod tests { signature: Some("sig-current".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "toolu_1".to_string(), @@ -1974,6 +1983,7 @@ mod tests { signature: Some("sig-current".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "toolu_1".to_string(), @@ -2004,6 +2014,7 @@ mod tests { signature: Some("sig-current".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "toolu_1".to_string(), @@ -2043,6 +2054,7 @@ mod tests { signature: Some("sig-current".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "toolu_1".to_string(), @@ -2075,6 +2087,7 @@ mod tests { signature: None, state: None, redacted_data: Some("ENC_REDACTED".to_string()), + reasoning_details: None, }, ContentBlock::ToolUse { id: "toolu_1".to_string(), diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 8a65a8ca58..aee0b230fb 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -970,6 +970,33 @@ fn openai_compatible_reasoning_effort( } } +/// OpenRouter round-trip: when an assistant message carries structured +/// `reasoning_details`, drop the parallel `reasoning_content` text field so +/// the provider receives the reasoning sequence once, verbatim. +fn suppress_reasoning_content_where_details_replay(messages: &mut [Value]) { + for message in messages { + if message.get("role").and_then(Value::as_str) != Some("assistant") { + continue; + } + if message.get("reasoning_details").is_none() { + continue; + } + if let Some(object) = message.as_object_mut() { + object.remove("reasoning_content"); + } + } +} + +/// Captured `reasoning_details` are OpenRouter-owned continuity; strip the +/// field from every message before it can reach any other dialect. +fn strip_openrouter_reasoning_details(messages: &mut [Value]) { + for message in messages { + if let Some(object) = message.as_object_mut() { + object.remove("reasoning_details"); + } + } +} + fn mirror_minimax_reasoning_details_for_messages(messages: &mut [Value]) { for message in messages { if message.get("role").and_then(Value::as_str) != Some("assistant") { @@ -1413,6 +1440,11 @@ impl DeepSeekClient { let mut thinking_started = false; let mut tool_indices: std::collections::HashMap = std::collections::HashMap::new(); let mut reasoning_detail_buffers: std::collections::HashMap = std::collections::HashMap::new(); + // OpenRouter structured `reasoning_details` round-trip: the raw + // entries must be stored verbatim and replayed unmodified (see + // ReasoningDetailsBuffer). Other providers never capture. + let mut reasoning_details_buffer = ReasoningDetailsBuffer::default(); + let capture_reasoning_details = api_provider == ApiProvider::Openrouter; let mut inline_reasoning_tags = InlineReasoningTagState::default(); let reasoning_stream_style = reasoning_stream_style_for_route( api_provider, @@ -1533,6 +1565,8 @@ impl DeepSeekClient { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut reasoning_details_buffer, + capture_reasoning_details, &mut inline_reasoning_tags, reasoning_stream_style, ) { @@ -1627,6 +1661,8 @@ impl DeepSeekClient { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut reasoning_details_buffer, + capture_reasoning_details, &mut inline_reasoning_tags, reasoning_stream_style, ) { @@ -1772,6 +1808,19 @@ impl<'a> PromptBuilder<'a> { false, ); dump_system_prompt_if_requested(&messages); + if provider == ApiProvider::Openrouter { + // The details array is the carrier OpenRouter round-trips + // ("the entire sequence of consecutive reasoning blocks must + // match the outputs generated by the model"); a parallel + // `reasoning_content` field would present the same reasoning + // twice in unknown precedence. + suppress_reasoning_content_where_details_replay(&mut messages); + } else { + // Captured details are OpenRouter-owned continuity. No other + // dialect may see the field, even when a session built on + // OpenRouter is replayed against a direct provider route. + strip_openrouter_reasoning_details(&mut messages); + } if provider == ApiProvider::Arcee { apply_arcee_waf_safe_message_encoding(&mut messages); } @@ -2690,6 +2739,9 @@ fn build_chat_messages_with_reasoning( let mut text_parts = Vec::new(); let mut image_parts = Vec::new(); let mut thinking_parts = Vec::new(); + // Structured OpenRouter continuity captured with this message, if + // any; emitted verbatim below when reasoning replay is on. + let mut wire_reasoning_details: Option> = None; let mut tool_calls = Vec::new(); let mut tool_call_infos = Vec::new(); let mut tool_results: Vec<(String, String, String, Vec)> = Vec::new(); @@ -2715,7 +2767,16 @@ fn build_chat_messages_with_reasoning( }, })); } - ContentBlock::Thinking { thinking, .. } => thinking_parts.push(thinking.clone()), + ContentBlock::Thinking { + thinking, + reasoning_details, + .. + } => { + thinking_parts.push(thinking.clone()); + if wire_reasoning_details.is_none() { + wire_reasoning_details = reasoning_details.clone(); + } + } ContentBlock::ToolUse { id, name, @@ -2784,6 +2845,14 @@ fn build_chat_messages_with_reasoning( let mut reasoning_content = thinking_parts.join("\n"); let has_text = !content.trim().is_empty(); let has_tool_calls = !tool_calls.is_empty(); + // Structured OpenRouter details replay only when this route + // replays reasoning at all; every other route never sees the + // field (route post-processing strips it as defense in depth). + let wire_reasoning_details = if include_reasoning { + wire_reasoning_details + } else { + None + }; // Reasoning replay must be a function of the stored message ONLY, // never of later history. DeepSeek's prefix cache hashes the raw // bytes of every message; flipping `reasoning_content` on/off @@ -2795,7 +2864,11 @@ fn build_chat_messages_with_reasoning( // (DeepSeek 400s without it), but text-only assistant messages // simply omit the field when there's nothing to replay. let mut has_reasoning = include_reasoning && !reasoning_content.trim().is_empty(); - if include_reasoning && has_tool_calls && !has_reasoning { + if include_reasoning + && has_tool_calls + && !has_reasoning + && wire_reasoning_details.is_none() + { logging::warn( "Substituting placeholder reasoning_content for DeepSeek tool-call assistant message", ); @@ -2807,7 +2880,7 @@ fn build_chat_messages_with_reasoning( // `tool_calls` are missing/null. Skip such entries even if they // carry reasoning-only metadata unless we can send a non-null // placeholder content field. - if !has_text && !has_tool_calls && !has_reasoning { + if !has_text && !has_tool_calls && !has_reasoning && wire_reasoning_details.is_none() { pending_tool_calls.clear(); deferred_tool_result_images.clear(); continue; @@ -2817,7 +2890,7 @@ fn build_chat_messages_with_reasoning( "role": "assistant", "content": if has_text { json!(content) - } else if has_reasoning { + } else if has_reasoning || wire_reasoning_details.is_some() { json!("") } else { Value::Null @@ -2826,6 +2899,9 @@ fn build_chat_messages_with_reasoning( if has_reasoning { msg["reasoning_content"] = json!(reasoning_content); } + if let Some(details) = wire_reasoning_details { + msg["reasoning_details"] = Value::Array(details); + } if has_tool_calls { msg["tool_calls"] = json!(tool_calls); let expected_tool_result_count = tool_call_infos.len(); @@ -3220,10 +3296,15 @@ pub(super) fn sanitize_thinking_mode_messages_for_route( continue; } let has_tool_calls = msg.get("tool_calls").is_some(); - let needs_placeholder = msg - .get("reasoning_content") - .and_then(Value::as_str) - .is_none_or(|s| s.trim().is_empty()); + // OpenRouter details replay: the structured array already carries the + // reasoning; injecting a text placeholder alongside it would present + // the sequence twice. + let has_details = msg.get("reasoning_details").is_some(); + let needs_placeholder = !has_details + && msg + .get("reasoning_content") + .and_then(Value::as_str) + .is_none_or(|s| s.trim().is_empty()); if has_tool_calls && needs_placeholder { msg["reasoning_content"] = json!(REASONING_REPLAY_PLACEHOLDER); substitutions = substitutions.saturating_add(1); @@ -3238,6 +3319,13 @@ pub(super) fn sanitize_thinking_mode_messages_for_route( replay_messages = replay_messages.saturating_add(1); } } + if let Some(details) = msg.get("reasoning_details") { + let len = details.to_string().len() as u64; + if len > 0 { + replay_chars = replay_chars.saturating_add(len); + replay_messages = replay_messages.saturating_add(1); + } + } } if substitutions > 0 { logging::warn(format!( @@ -3687,6 +3775,65 @@ fn reasoning_message_text(value: &Value) -> Option { }) } +/// OpenRouter documents that streamed encrypted entries can arrive as the +/// literal `[REDACTED]` placeholder instead of a real payload. A placeholder +/// cannot be replayed, so it is dropped at capture time rather than stored +/// to poison the round-trip. +fn replayable_reasoning_details(details: &[Value]) -> Vec { + details + .iter() + .filter(|entry| entry.get("data").and_then(Value::as_str) != Some("[REDACTED]")) + .cloned() + .collect() +} + +/// Accumulates OpenRouter `reasoning_details` entries across stream chunks. +/// OpenRouter repeats the growing array on successive chunks (the same +/// cumulative-snapshot shape MiniMax uses), so entries are keyed by `index` +/// when present, else by `type`, and the last version of each key wins. +#[derive(Default)] +pub(super) struct ReasoningDetailsBuffer { + order: Vec, + entries: HashMap, +} + +impl ReasoningDetailsBuffer { + /// Absorb one chunk's entries; returns true when the snapshot changed. + fn absorb(&mut self, details: &[Value]) -> bool { + let mut changed = false; + for entry in replayable_reasoning_details(details) { + let key = entry + .get("index") + .and_then(Value::as_u64) + .map(|index| index.to_string()) + .or_else(|| { + entry + .get("type") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| format!("_pos{}", self.order.len())); + if !self.order.contains(&key) { + self.order.push(key.clone()); + changed = true; + } + if self.entries.get(&key) != Some(&entry) { + changed = true; + } + self.entries.insert(key, entry); + } + changed + } + + /// Entries in first-seen order, most complete version of each. + fn snapshot(&self) -> Vec { + self.order + .iter() + .filter_map(|key| self.entries.get(key).cloned()) + .collect() + } +} + #[cfg(test)] pub(super) fn parse_chat_message(payload: &Value) -> Result { parse_chat_message_for_route(payload, ApiProvider::Openai, "") @@ -3720,6 +3867,20 @@ fn parse_chat_message_for_route( .context("Chat API response missing message")?; let mut content_blocks = Vec::new(); + // OpenRouter structured reasoning (`reasoning.text` with signatures, + // `reasoning.encrypted` payloads) must round-trip verbatim for tool-call + // continuity; the joined text alone cannot carry it. Store the raw + // entries only on OpenRouter routes — every other dialect must never + // see the field. + let openrouter_reasoning_details = if provider == ApiProvider::Openrouter { + message + .get("reasoning_details") + .and_then(Value::as_array) + .map(|details| replayable_reasoning_details(details)) + .filter(|details| !details.is_empty()) + } else { + None + }; if let Some(reasoning) = reasoning_message_text(message).filter(|reasoning| !reasoning.trim().is_empty()) { @@ -3727,8 +3888,20 @@ fn parse_chat_message_for_route( signature: None, state: None, redacted_data: None, + reasoning_details: openrouter_reasoning_details.clone(), thinking: reasoning.to_string(), }); + } else if let Some(details) = openrouter_reasoning_details { + // Encrypted-only details with no readable text (hidden-CoT models + // behind OpenRouter): the payload is the only reasoning continuity + // this turn has, so materialize the block around it. + content_blocks.push(ContentBlock::Thinking { + signature: None, + state: None, + redacted_data: None, + reasoning_details: Some(details), + thinking: String::new(), + }); } let (mistral_thinking, mistral_text) = if is_exact_mistral_chat_route(provider, base_url) { extract_mistral_polymorphic_content(message) @@ -3740,6 +3913,7 @@ fn parse_chat_message_for_route( signature: None, state: None, redacted_data: None, + reasoning_details: None, thinking, }); } @@ -3994,6 +4168,8 @@ fn parse_sse_data_frame( thinking_started: &mut bool, tool_indices: &mut std::collections::HashMap, reasoning_detail_buffers: &mut std::collections::HashMap, + reasoning_details_buffer: &mut ReasoningDetailsBuffer, + capture_reasoning_details: bool, inline_reasoning_tags: &mut InlineReasoningTagState, reasoning_stream_style: ReasoningStreamStyle, ) -> SseDataFrame { @@ -4010,6 +4186,8 @@ fn parse_sse_data_frame( thinking_started, tool_indices, reasoning_detail_buffers, + reasoning_details_buffer, + capture_reasoning_details, inline_reasoning_tags, reasoning_stream_style, ) @@ -4028,6 +4206,8 @@ pub(super) fn parse_sse_chunk( thinking_started: &mut bool, tool_indices: &mut std::collections::HashMap, reasoning_detail_buffers: &mut std::collections::HashMap, + reasoning_details_buffer: &mut ReasoningDetailsBuffer, + capture_reasoning_details: bool, is_reasoning_model: bool, ) -> Vec { let mut inline_reasoning_tags = InlineReasoningTagState::default(); @@ -4043,6 +4223,8 @@ pub(super) fn parse_sse_chunk( thinking_started, tool_indices, reasoning_detail_buffers, + reasoning_details_buffer, + capture_reasoning_details, &mut inline_reasoning_tags, reasoning_stream_style, ) @@ -4057,6 +4239,8 @@ fn parse_sse_chunk_with_reasoning_style( thinking_started: &mut bool, tool_indices: &mut std::collections::HashMap, reasoning_detail_buffers: &mut std::collections::HashMap, + reasoning_details_buffer: &mut ReasoningDetailsBuffer, + capture_reasoning_details: bool, inline_reasoning_tags: &mut InlineReasoningTagState, reasoning_stream_style: ReasoningStreamStyle, ) -> Vec { @@ -4160,6 +4344,25 @@ fn parse_sse_chunk_with_reasoning_style( ); } + // OpenRouter structured reasoning: carry the raw + // `delta.reasoning_details` entries verbatim alongside the + // joined text delta. The snapshot rides the same thinking block + // index as the text deltas; the engine keeps only the last + // snapshot per block, which is the most complete version + // OpenRouter sent. Capture is wired per route (OpenRouter only); + // `capture_reasoning_details` is false everywhere else. + if capture_reasoning_details + && let Some(details) = delta.get("reasoning_details").and_then(Value::as_array) + && reasoning_details_buffer.absorb(details) + { + events.push(StreamEvent::ContentBlockDelta { + index: *content_index, + delta: Delta::ReasoningDetailsDelta { + details: reasoning_details_buffer.snapshot(), + }, + }); + } + // Generic OpenAI-compatible proxies sometimes stream answer text // in `reasoning_content`. If this route is configured with no // reasoning semantics, render that field as normal text when no @@ -4432,6 +4635,8 @@ mod stream_diagnostics_tests { &mut thinking_started, &mut tool_indices, &mut reasoning_buffers, + &mut ReasoningDetailsBuffer::default(), + false, false, ); assert_eq!( @@ -4457,6 +4662,8 @@ mod stream_diagnostics_tests { &mut thinking_started, &mut tool_indices, &mut reasoning_buffers, + &mut ReasoningDetailsBuffer::default(), + false, false, ); assert!( @@ -4667,6 +4874,7 @@ mod minimax_reasoning_replay_tests { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "Done.".to_string(), @@ -4767,6 +4975,7 @@ mod minimax_reasoning_replay_tests { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "I'll read the widget first.".to_string(), @@ -4888,6 +5097,245 @@ mod minimax_reasoning_replay_tests { } } +#[cfg(test)] +mod openrouter_reasoning_details_tests { + //! OpenRouter structured reasoning round-trip: the `reasoning_details` + //! sequence is captured verbatim, replayed verbatim, and never leaks to + //! another dialect (docs/use-cases/reasoning-tokens, re-checked 2026-10-08). + + use super::{build_chat_messages_for_request_and_provider, parse_chat_message_for_route}; + use crate::config::{ApiProvider, DEFAULT_OPENROUTER_MODEL}; + use crate::models::Role; + use crate::models::{ContentBlock, Message, MessageRequest}; + use serde_json::{Value, json}; + + fn details_entries() -> Vec { + vec![ + json!({ + "type": "reasoning.text", + "format": "anthropic-claude-v1", + "index": 0, + "text": "step one", + "signature": "sha256:abc", + }), + json!({ + "type": "reasoning.encrypted", + "format": "openai-responses-v1", + "index": 1, + "data": "opaque-blob", + }), + ] + } + + fn request_with_details() -> MessageRequest { + MessageRequest { + model: DEFAULT_OPENROUTER_MODEL.to_string(), + messages: vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "step one".to_string(), + signature: None, + state: None, + redacted_data: None, + reasoning_details: Some(details_entries()), + }, + ContentBlock::Text { + text: "Done.".to_string(), + cache_control: None, + }, + ], + }], + max_tokens: 16, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: None, + temperature: None, + top_p: None, + } + } + + #[test] + fn openrouter_replays_details_verbatim_instead_of_reasoning_content() { + let messages = build_chat_messages_for_request_and_provider( + &request_with_details(), + ApiProvider::Openrouter, + ); + let assistant = &messages[0]; + assert_eq!( + assistant.get("reasoning_details"), + Some(&Value::Array(details_entries())), + "the structured sequence must round-trip unmodified" + ); + assert!( + assistant.get("reasoning_content").is_none(), + "a parallel reasoning_content field would present the same reasoning twice" + ); + } + + #[test] + fn other_routes_never_see_captured_details() { + for provider in [ApiProvider::Openai, ApiProvider::Deepseek] { + let messages = + build_chat_messages_for_request_and_provider(&request_with_details(), provider); + assert!( + messages[0].get("reasoning_details").is_none(), + "{provider:?} must not receive OpenRouter-owned details" + ); + } + } + + #[test] + fn openrouter_effort_off_suppresses_details_replay() { + let mut request = request_with_details(); + request.reasoning_effort = Some("off".to_string()); + let messages = + build_chat_messages_for_request_and_provider(&request, ApiProvider::Openrouter); + assert!( + messages[0].get("reasoning_details").is_none(), + "reasoning replay off must also drop structured details" + ); + } + + #[test] + fn openrouter_response_captures_details_and_filters_placeholders() { + let payload = json!({ + "id": "or-1", + "model": DEFAULT_OPENROUTER_MODEL, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Done.", + "reasoning": "step one", + "reasoning_details": [ + { + "type": "reasoning.text", + "format": "anthropic-claude-v1", + "index": 0, + "text": "step one", + "signature": "sha256:abc", + }, + { + "type": "reasoning.encrypted", + "format": "openai-responses-v1", + "index": 1, + "data": "[REDACTED]", + }, + { + "type": "reasoning.encrypted", + "format": "openai-responses-v1", + "index": 2, + "data": "opaque-blob", + }, + ], + }, + "finish_reason": "stop", + }], + }); + let response = + parse_chat_message_for_route(&payload, ApiProvider::Openrouter, "").expect("parse"); + let details = response + .content + .iter() + .find_map(|block| match block { + ContentBlock::Thinking { + reasoning_details, .. + } => reasoning_details.clone(), + _ => None, + }) + .expect("details captured on the thinking block"); + assert_eq!( + details.len(), + 2, + "the [REDACTED] placeholder cannot be replayed and must not be stored" + ); + assert_eq!(details[0]["type"], "reasoning.text"); + assert_eq!(details[1]["index"], 2); + } + + #[test] + fn openrouter_encrypted_only_details_materialize_a_block() { + let payload = json!({ + "id": "or-2", + "model": DEFAULT_OPENROUTER_MODEL, + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Done.", + "reasoning_details": [ + { + "type": "reasoning.encrypted", + "format": "openai-responses-v1", + "index": 0, + "data": "opaque-blob", + } + ], + }, + "finish_reason": "stop", + }], + }); + let response = + parse_chat_message_for_route(&payload, ApiProvider::Openrouter, "").expect("parse"); + let thinking_blocks: Vec<_> = response + .content + .iter() + .filter_map(|block| match block { + ContentBlock::Thinking { + thinking, + reasoning_details, + .. + } => Some((thinking.clone(), reasoning_details.clone())), + _ => None, + }) + .collect(); + assert_eq!( + thinking_blocks.len(), + 1, + "the payload is the only continuity this turn has" + ); + assert!(thinking_blocks[0].0.is_empty()); + assert!(thinking_blocks[0].1.is_some()); + } + + #[test] + fn non_openrouter_responses_never_store_details() { + let payload = json!({ + "id": "mm-1", + "model": "MiniMax-M3", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "Done.", + "reasoning_details": [{ "type": "text", "text": "minimax trace" }], + }, + "finish_reason": "stop", + }], + }); + let response = + parse_chat_message_for_route(&payload, ApiProvider::Minimax, "").expect("parse"); + let stores_details = response.content.iter().any(|block| { + matches!( + block, + ContentBlock::Thinking { + reasoning_details: Some(_), + .. + } + ) + }); + assert!( + !stores_details, + "capture is OpenRouter-only; other providers keep the text-only shape" + ); + } +} + // === #103 Phase 4: SSE decoder behavior on canned chunk sequences ============ #[cfg(test)] @@ -7598,6 +8046,7 @@ mod mistral_reasoning_tests { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "I will inspect it now.".to_string(), @@ -8418,6 +8867,8 @@ mod google_thought_signature_tests { &mut thinking_started, &mut tool_indices, &mut reasoning_buffers, + &mut ReasoningDetailsBuffer::default(), + false, &mut inline_tags, ReasoningStreamStyle::None, ); @@ -8480,6 +8931,8 @@ mod google_thought_signature_tests { &mut thinking_started, &mut tool_indices, &mut reasoning_buffers, + &mut ReasoningDetailsBuffer::default(), + false, &mut inline_tags, style, ); @@ -8490,6 +8943,8 @@ mod google_thought_signature_tests { &mut thinking_started, &mut tool_indices, &mut reasoning_buffers, + &mut ReasoningDetailsBuffer::default(), + false, &mut inline_tags, style, ); @@ -8542,6 +8997,8 @@ mod google_thought_signature_tests { &mut thinking_started, &mut tool_indices, &mut reasoning_buffers, + &mut ReasoningDetailsBuffer::default(), + false, &mut inline_tags, ReasoningStreamStyle::None, ); @@ -8624,6 +9081,8 @@ mod google_thought_signature_tests { &mut thinking_started, &mut tool_indices, &mut reasoning_buffers, + &mut ReasoningDetailsBuffer::default(), + false, &mut inline_tags, reasoning_stream_style_for_route( ApiProvider::Google, diff --git a/crates/tui/src/client/chat/tests/stream_decoder.rs b/crates/tui/src/client/chat/tests/stream_decoder.rs index 490dac3e90..387abc16e2 100644 --- a/crates/tui/src/client/chat/tests/stream_decoder.rs +++ b/crates/tui/src/client/chat/tests/stream_decoder.rs @@ -27,6 +27,8 @@ fn decode_chunk_with_reasoning(json_text: &str, is_reasoning_model: bool) -> Vec &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, is_reasoning_model, ) } @@ -52,6 +54,8 @@ fn decode_chunks_with_style( &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, &mut inline_reasoning_tags, reasoning_stream_style, )); @@ -112,6 +116,8 @@ fn decode_sse_byte_chunks(chunks: &[&[u8]]) -> Result, InvalidS &mut self.thinking_started, &mut self.tool_indices, &mut self.reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, &mut self.inline_reasoning_tags, ReasoningStreamStyle::SeparateField, ) { @@ -312,6 +318,8 @@ fn decoder_streams_moonshot_multi_chunk_reasoning_as_thinking() { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, is_reasoning, )); } @@ -400,6 +408,8 @@ fn decoder_streams_minimax_reasoning_details_as_incremental_thinking() { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, is_reasoning, )); } @@ -472,6 +482,8 @@ fn modelstudio_streams_reasoning_content_as_thinking() { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, &mut inline_reasoning_tags, style, )); @@ -545,6 +557,8 @@ fn decoder_does_not_render_reasoning_as_text_for_known_provider_models() { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, is_reasoning_model, ); @@ -753,6 +767,8 @@ fn decoder_treats_done_frame_as_terminal() { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, &mut inline_reasoning_tags, ReasoningStreamStyle::SeparateField, ); @@ -1496,3 +1512,119 @@ fn mistral_stream_blocks_are_decoded_only_by_the_mistral_style() { } ))); } + +// === OpenRouter structured reasoning_details capture ======================== + +/// Decode chunks with an explicit details-capture flag, mirroring the live +/// loop's wiring (`capture_reasoning_details` is true on OpenRouter only). +fn decode_chunks_capturing_details(chunks: &[Value], capture: bool) -> Vec { + let mut content_index = 0u32; + let mut text_started = false; + let mut thinking_started = false; + let mut tool_indices = std::collections::HashMap::new(); + let mut reasoning_detail_buffers = std::collections::HashMap::new(); + let mut reasoning_details_buffer = ReasoningDetailsBuffer::default(); + let mut events = Vec::new(); + for chunk in chunks { + events.extend(parse_sse_chunk( + chunk, + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + &mut reasoning_detail_buffers, + &mut reasoning_details_buffer, + capture, + true, + )); + } + events +} + +fn details_delta_payloads(events: &[StreamEvent]) -> Vec> { + events + .iter() + .filter_map(|event| match event { + StreamEvent::ContentBlockDelta { + delta: Delta::ReasoningDetailsDelta { details }, + .. + } => Some(details.clone()), + _ => None, + }) + .collect() +} + +#[test] +fn openrouter_details_snapshots_merge_last_wins_in_first_seen_order() { + // OpenRouter repeats the growing array on each chunk: entry 0's text + // completes and entry 1 appears. The stored snapshot must hold the most + // complete version of each entry in first-seen order. + let chunks = vec![ + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning": "al", + "reasoning_details": [ + { "type": "reasoning.text", "index": 0, "text": "al" } + ] + } + }] + }), + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "index": 0, "text": "alpha" }, + { "type": "reasoning.encrypted", "index": 1, "data": "blob" } + ] + } + }] + }), + ]; + let events = decode_chunks_capturing_details(&chunks, true); + let snapshots = details_delta_payloads(&events); + assert_eq!(snapshots.len(), 2, "one snapshot per absorbing chunk"); + assert_eq!(snapshots[0].len(), 1); + let last = snapshots.last().expect("final snapshot"); + assert_eq!(last.len(), 2, "entry count grows with the array"); + assert_eq!(last[0]["text"], "alpha", "last version of entry 0 wins"); + assert_eq!(last[0]["type"], "reasoning.text", "first-seen order kept"); + assert_eq!(last[1]["type"], "reasoning.encrypted"); +} + +#[test] +fn openrouter_details_placeholder_entries_never_enter_the_snapshot() { + let chunks = vec![serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.encrypted", "index": 0, "data": "[REDACTED]" } + ] + } + }] + })]; + let events = decode_chunks_capturing_details(&chunks, true); + assert!( + details_delta_payloads(&events).is_empty(), + "a [REDACTED] payload cannot be replayed and must not be captured" + ); +} + +#[test] +fn non_openrouter_routes_never_emit_details_deltas() { + let chunks = vec![serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "text", "text": "minimax trace" } + ] + } + }] + })]; + let events = decode_chunks_capturing_details(&chunks, false); + assert!(details_delta_payloads(&events).is_empty()); +} diff --git a/crates/tui/src/client/ds4_tests.rs b/crates/tui/src/client/ds4_tests.rs index 417000e90e..ad4dcdb4e1 100644 --- a/crates/tui/src/client/ds4_tests.rs +++ b/crates/tui/src/client/ds4_tests.rs @@ -1,5 +1,5 @@ use super::DeepSeekClient; -use super::chat::{parse_chat_message, parse_sse_chunk}; +use super::chat::{ReasoningDetailsBuffer, parse_chat_message, parse_sse_chunk}; use crate::config::{Config, ProviderConfig, ProvidersConfig}; use crate::models::Role; use crate::models::{ContentBlock, Delta, Message, MessageRequest, StreamEvent, Tool}; @@ -202,6 +202,8 @@ fn replay_placeholder_echo_is_dropped_from_ingest() { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, true, ) }) @@ -273,6 +275,8 @@ fn streaming_fixture_accepts_delayed_tool_arguments_and_usage_tail() { &mut thinking_started, &mut tool_indices, &mut reasoning_detail_buffers, + &mut ReasoningDetailsBuffer::default(), + false, false, ) }) diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index bcf3c2492d..84d1c77f21 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -703,6 +703,7 @@ impl DeepSeekClient { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, // Redacted thinking does not occur on Responses // routes (encrypted reasoning rides the reasoning @@ -770,6 +771,10 @@ impl DeepSeekClient { *existing = Some(state); } } + Delta::ReasoningDetailsDelta { .. } => { + // OpenRouter chat-route details never occur on + // the Responses bridge. + } } } StreamEvent::ContentBlockStop { index } => { diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index 4a07d64e1f..02ae1e08ae 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -77,6 +77,7 @@ fn codex_reasoning_block(model: &str) -> ContentBlock { endpoint: Some("fp-codex-endpoint".to_string()), }), redacted_data: None, + reasoning_details: None, } } @@ -683,6 +684,7 @@ fn deepseek_flash_responses_body_uses_stateless_0731_contract() { signature: None, state: None, redacted_data: None, + reasoning_details: None, }], }, ); @@ -775,6 +777,7 @@ fn codex_replays_only_exact_model_opaque_reasoning_state() { signature: None, state: Some(state), redacted_data: None, + reasoning_details: None, }, // Text pairs the reasoning item with its produced message so // the sequence satisfies the immediate-following-item rule. @@ -1928,6 +1931,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() signature: None, state: Some(state), redacted_data: None, + reasoning_details: None, }, // Text pairs the reasoning item with its produced message // so the sequence satisfies the immediate-following-item rule. @@ -1997,6 +2001,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() endpoint: None, }), redacted_data: None, + reasoning_details: None, }]; let legacy_state = build_responses_body_for_provider( &request, @@ -2033,6 +2038,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() endpoint: None, }), redacted_data: None, + reasoning_details: None, }]; let legacy_root = build_responses_body_for_provider( &request, @@ -2060,6 +2066,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() endpoint: Some(MINTING_ENDPOINT_FP.to_string()), }), redacted_data: None, + reasoning_details: None, }]; let switched_table = build_responses_body_for_provider( &request, @@ -2097,6 +2104,7 @@ fn forkguard_custom_responses_replays_only_exact_model_opaque_reasoning_state() endpoint: Some(MINTING_ENDPOINT_FP.to_string()), }), redacted_data: None, + reasoning_details: None, }]; let codex_state_on_custom = build_responses_body_for_provider( &request, @@ -2157,6 +2165,7 @@ fn forkguard_fixed_endpoint_legacy_state_without_fingerprint_keeps_replaying() { endpoint: None, }), redacted_data: None, + reasoning_details: None, }, // Text pairs the reasoning item with its produced message // so the sequence satisfies the immediate-following-item rule. @@ -2213,6 +2222,7 @@ fn forkguard_repointed_builtin_legacy_state_without_fingerprint_fails_closed() { endpoint: None, }), redacted_data: None, + reasoning_details: None, }, // Text pairs the reasoning item with its produced message // so the sequence satisfies the immediate-following-item rule. @@ -2270,6 +2280,7 @@ fn forkguard_replay_never_rides_a_route_that_omits_the_include() { signature: None, state: Some(state), redacted_data: None, + reasoning_details: None, }, // Text pairs the reasoning item with its produced message // so the sequence satisfies the immediate-following-item rule. @@ -2379,6 +2390,7 @@ async fn forkguard_custom_responses_captured_state_replays_on_the_next_turn() { signature: None, state: Some(state), redacted_data: None, + reasoning_details: None, }, // Text pairs the reasoning item with its produced message // so the sequence satisfies the immediate-following-item rule. @@ -2508,6 +2520,7 @@ async fn forkguard_capture_replay_drops_across_a_base_url_edit() { signature: None, state: Some(captured), redacted_data: None, + reasoning_details: None, }, // Text pairs the reasoning item with its produced message // so the sequence satisfies the immediate-following-item rule. diff --git a/crates/tui/src/commands/groups/session/export.rs b/crates/tui/src/commands/groups/session/export.rs index 957196a28e..7540e1fadf 100644 --- a/crates/tui/src/commands/groups/session/export.rs +++ b/crates/tui/src/commands/groups/session/export.rs @@ -1067,6 +1067,7 @@ mod tests { signature: Some("signature-secret".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "call-1".to_string(), diff --git a/crates/tui/src/commands/groups/session/structcopy.rs b/crates/tui/src/commands/groups/session/structcopy.rs index bcb640f305..713e1516a5 100644 --- a/crates/tui/src/commands/groups/session/structcopy.rs +++ b/crates/tui/src/commands/groups/session/structcopy.rs @@ -1286,6 +1286,7 @@ mod tests { signature: Some("signature-secret".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "call-7".to_string(), diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index c82850fc55..54ccbac1ce 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -2611,6 +2611,7 @@ mod tests { state: None, thinking: thinking.clone(), redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index d72e103e8f..d18663dc74 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -369,6 +369,7 @@ fn context_pressure_delta_matches_clone_and_push_reference() { signature: None, state: None, redacted_data: None, + reasoning_details: None, }], }); // Replayed-reasoning case (#perf-r5 fresh-eyes fix): an assistant message @@ -384,6 +385,7 @@ fn context_pressure_delta_matches_clone_and_push_reference() { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "call_1".to_string(), @@ -402,6 +404,7 @@ fn context_pressure_delta_matches_clone_and_push_reference() { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "call_2".to_string(), diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 69039a44e1..6111d10ace 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -15234,6 +15234,7 @@ async fn session_update_preserves_reasoning_tool_only_turn() { state: None, thinking: "Need a tool before answering.".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 98185b600f..dad47e7f15 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -690,6 +690,10 @@ struct StreamOutcome { current_thinking: String, current_thinking_signature: Option, current_thinking_state: Option, + /// OpenRouter `reasoning_details` snapshot for the current thinking + /// block; the stream repeats the growing array, so only the last + /// snapshot per block is kept and replayed verbatim. + current_thinking_details: Option>, /// Thinking blocks already terminated by a `content_block_stop`, in wire /// order. Adaptive/interleaved thinking emits several per assistant turn; /// Anthropic requires the whole sequence replayed unmodified, so finished @@ -730,28 +734,37 @@ pub(super) fn preview_request_error_user_message( /// /// A block survives only if the provider actually emitted reasoning: readable /// text (minus the wire-only "(reasoning omitted)" placeholder), an Anthropic -/// signature, or an encrypted Responses state. A signature-only block is real +/// signature, an encrypted Responses state, or OpenRouter structured +/// `reasoning_details`. A signature-only block is real /// content — newer Claude models ship `display: "omitted"` thinking (empty /// text plus the encrypted signature) — and dropping it would strip a block -/// Anthropic requires back verbatim in tool-loop replays. +/// Anthropic requires back verbatim in tool-loop replays. The same holds for +/// an encrypted-only OpenRouter details array. fn flush_thinking_block( thinking: &mut String, signature: &mut Option, state: &mut Option, + details: &mut Option>, completed: &mut Vec, ) { let placeholder_only = crate::client::is_reasoning_replay_placeholder(thinking); - if (!thinking.is_empty() && !placeholder_only) || signature.is_some() || state.is_some() { + if (!thinking.is_empty() && !placeholder_only) + || signature.is_some() + || state.is_some() + || details.is_some() + { completed.push(ContentBlock::Thinking { thinking: std::mem::take(thinking), signature: signature.take(), state: state.take(), redacted_data: None, + reasoning_details: details.take(), }); } thinking.clear(); *signature = None; *state = None; + *details = None; } fn approval_intent_summary(text: &str) -> Option { @@ -2170,6 +2183,7 @@ impl Engine { current_thinking, current_thinking_signature, current_thinking_state, + current_thinking_details, mut completed_thinking, mut tool_uses, usage, @@ -2358,12 +2372,14 @@ impl Engine { if (!current_thinking.is_empty() && !thinking_is_placeholder_only) || current_thinking_state.is_some() || current_thinking_signature.is_some() + || current_thinking_details.is_some() { resume_blocks.push(ContentBlock::Thinking { thinking: current_thinking.clone(), signature: current_thinking_signature.clone(), state: current_thinking_state.clone(), redacted_data: None, + reasoning_details: current_thinking_details.clone(), }); } if !current_text_visible.is_empty() { @@ -2487,12 +2503,14 @@ impl Engine { if (!current_thinking.is_empty() && !thinking_is_placeholder_only) || current_thinking_state.is_some() || current_thinking_signature.is_some() + || current_thinking_details.is_some() { content_blocks.push(ContentBlock::Thinking { thinking: current_thinking.clone(), signature: current_thinking_signature.clone(), state: current_thinking_state.clone(), redacted_data: None, + reasoning_details: current_thinking_details.clone(), }); } let mut final_text = current_text_visible.clone(); @@ -5170,6 +5188,10 @@ impl Engine { // thinking block; must be replayed verbatim in tool loops. let mut current_thinking_signature: Option = None; let mut current_thinking_state: Option = None; + // OpenRouter structured reasoning entries for the current thinking + // block; each delta replaces the snapshot (the stream repeats the + // growing array) and the final one is replayed verbatim. + let mut current_thinking_details: Option> = None; // Adaptive/interleaved thinking can emit more than one thinking block // per assistant turn. Anthropic requires the entire sequence of // thinking blocks to be replayed "complete and unmodified" and in the @@ -5532,9 +5554,11 @@ impl Engine { &mut current_thinking, &mut current_thinking_signature, &mut current_thinking_state, + &mut current_thinking_details, &mut completed_thinking, ); current_thinking = thinking; + current_thinking_details = None; current_block_kind = Some(ContentBlockKind::Thinking); let _ = self .tx_event @@ -5554,6 +5578,7 @@ impl Engine { &mut current_thinking, &mut current_thinking_signature, &mut current_thinking_state, + &mut current_thinking_details, &mut completed_thinking, ); completed_thinking.push(ContentBlock::Thinking { @@ -5561,6 +5586,7 @@ impl Engine { signature: None, state: None, redacted_data: Some(data), + reasoning_details: None, }); // Kind is cleared so the block's stop event needs no // handling — it was parked above and has no deltas — @@ -5672,6 +5698,12 @@ impl Engine { Delta::ReasoningStateDelta { state } => { current_thinking_state = Some(state); } + Delta::ReasoningDetailsDelta { details } => { + // OpenRouter repeats the growing details array on + // successive chunks; the last snapshot is the most + // complete version and replaces any earlier one. + current_thinking_details = Some(details); + } Delta::ToolThoughtSignatureDelta { signature } => { // Google thought signature delivered on a continuation // chunk of an already-started tool call. First one @@ -5751,6 +5783,7 @@ impl Engine { &mut current_thinking, &mut current_thinking_signature, &mut current_thinking_state, + &mut current_thinking_details, &mut completed_thinking, ); } @@ -5886,6 +5919,7 @@ impl Engine { current_thinking, current_thinking_signature, current_thinking_state, + current_thinking_details, completed_thinking, tool_uses, usage, @@ -6975,6 +7009,9 @@ fn stream_event_has_actionable_content(event: &StreamEvent) -> bool { Delta::SignatureDelta { signature } => !signature.is_empty(), Delta::ToolThoughtSignatureDelta { signature } => !signature.is_empty(), Delta::ReasoningStateDelta { .. } => true, + // Structured OpenRouter reasoning is provider-owned content even + // when no readable text delta accompanies it. + Delta::ReasoningDetailsDelta { details } => !details.is_empty(), }, StreamEvent::ToolProjectionWarning { .. } | StreamEvent::MessageStart { .. } diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index d924473f11..93a8fab5e0 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -787,6 +787,15 @@ pub enum Delta { /// returns an encrypted item on the exact originating route. #[serde(rename = "reasoning_state_delta")] ReasoningStateDelta { state: OpaqueReasoningState }, + /// OpenRouter `reasoning_details` snapshot for the current thinking + /// block, carried verbatim from the streamed `delta.reasoning_details` + /// entries. Each event replaces the previous snapshot; the last one + /// before the block closes is the most complete version OpenRouter sent + /// (their stream repeats the growing array). Entries whose encrypted + /// payload arrived as the `[REDACTED]` streaming placeholder never + /// enter the snapshot — they cannot be replayed. + #[serde(rename = "reasoning_details_delta")] + ReasoningDetailsDelta { details: Vec }, } #[allow(dead_code)] diff --git a/crates/tui/src/purge.rs b/crates/tui/src/purge.rs index c0eb53c85f..04f1b6c7a9 100644 --- a/crates/tui/src/purge.rs +++ b/crates/tui/src/purge.rs @@ -791,6 +791,7 @@ mod tests { state: None, thinking: "let me think...".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "done".to_string(), diff --git a/crates/tui/src/rlm/turn.rs b/crates/tui/src/rlm/turn.rs index 036459fb35..9a823c4a31 100644 --- a/crates/tui/src/rlm/turn.rs +++ b/crates/tui/src/rlm/turn.rs @@ -1147,6 +1147,7 @@ mod tests { state: None, thinking: "skip".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "second".to_string(), diff --git a/crates/tui/src/runtime_api/sessions.rs b/crates/tui/src/runtime_api/sessions.rs index 27a86ce965..617f2e8687 100644 --- a/crates/tui/src/runtime_api/sessions.rs +++ b/crates/tui/src/runtime_api/sessions.rs @@ -577,6 +577,7 @@ pub(super) fn messages_from_thread_detail(detail: &ThreadDetail) -> Vec signature: None, state: None, redacted_data: None, + reasoning_details: None, }); } } diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index d596d5cd01..61ece593cb 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -8465,6 +8465,7 @@ impl RuntimeThreadManager { signature: None, state: None, redacted_data: None, + reasoning_details: None, }); } } diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index 43e871460d..ea6ef8633d 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -4685,6 +4685,7 @@ async fn seed_thread_keeps_tool_results_on_preceding_turn() -> Result<()> { signature: Some("sig-1".to_string()), state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), @@ -4813,6 +4814,7 @@ async fn seeded_session_records_carry_a_total_order() -> Result<()> { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: "tool-1".to_string(), diff --git a/crates/tui/src/tui/auto_router.rs b/crates/tui/src/tui/auto_router.rs index 313f590386..c7ba5a1aa0 100644 --- a/crates/tui/src/tui/auto_router.rs +++ b/crates/tui/src/tui/auto_router.rs @@ -143,6 +143,7 @@ mod tests { state: None, thinking: "The user seems to be asking me to classify myself.".to_string(), redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: "Visible assistant answer.".to_string(), diff --git a/crates/tui/src/tui/history/tests.rs b/crates/tui/src/tui/history/tests.rs index ab46034922..9cae4b8bd0 100644 --- a/crates/tui/src/tui/history/tests.rs +++ b/crates/tui/src/tui/history/tests.rs @@ -1916,12 +1916,14 @@ fn restored_history_drops_the_wire_reasoning_placeholder() { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::Thinking { thinking: "Actual model reasoning".to_string(), signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ], }; diff --git a/crates/tui/src/tui/setup/fleet_draft.rs b/crates/tui/src/tui/setup/fleet_draft.rs index 8b03aa1c1c..b4724362e4 100644 --- a/crates/tui/src/tui/setup/fleet_draft.rs +++ b/crates/tui/src/tui/setup/fleet_draft.rs @@ -540,6 +540,7 @@ mod tests { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ); mock.push_message_response(response); diff --git a/crates/tui/src/tui/setup/model_draft.rs b/crates/tui/src/tui/setup/model_draft.rs index c19ba8e904..34c2659d13 100644 --- a/crates/tui/src/tui/setup/model_draft.rs +++ b/crates/tui/src/tui/setup/model_draft.rs @@ -411,6 +411,7 @@ mod tests { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ); mock.push_message_response(response); diff --git a/crates/tui/src/tui/ui/dispatch.rs b/crates/tui/src/tui/ui/dispatch.rs index 290280f425..3226d0068f 100644 --- a/crates/tui/src/tui/ui/dispatch.rs +++ b/crates/tui/src/tui/ui/dispatch.rs @@ -96,6 +96,7 @@ pub(crate) fn push_assistant_message( signature: None, state: None, redacted_data: None, + reasoning_details: None, }); } if !text.is_empty() { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 0ac9d34191..525842b1b1 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5293,6 +5293,7 @@ fn restored_reasoning_and_answer_clear_prior_fold_ownership() { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, crate::models::ContentBlock::Text { text: "restored final answer".to_string(), diff --git a/crates/tui/tests/integration/integration_mock_llm.rs b/crates/tui/tests/integration/integration_mock_llm.rs index e78c1123d9..ce55aec158 100644 --- a/crates/tui/tests/integration/integration_mock_llm.rs +++ b/crates/tui/tests/integration/integration_mock_llm.rs @@ -78,6 +78,7 @@ fn assistant_thinking(thinking: &str, text: &str) -> Message { signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::Text { text: text.to_string(), diff --git a/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs b/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs index 39c0fed882..f72a6832c5 100644 --- a/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs +++ b/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs @@ -29,6 +29,7 @@ fn assistant_thinking_tool_call( signature: None, state: None, redacted_data: None, + reasoning_details: None, }, ContentBlock::ToolUse { id: id.to_string(), From f6dfe79a2da9b7dd83615aa7f42e5a603e21ba72 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 8 Oct 2026 02:56:41 +0800 Subject: [PATCH 03/12] fix(tui): count replayed thinking in estimates The compaction token estimator still used the old DeepSeek-only rule: thinking counted only on assistant messages that contain tool calls, and only the readable text. The preserved-thinking replay contracts in this PR now send far more back: Anthropic replays signed/redacted blocks on every prior turn, Responses replays encrypted reasoning items with their function-call chain, and OpenRouter replays structured reasoning_details. A text-only or payload-only thinking block scored zero and made compaction fire late. Count thinking blocks on every assistant message and charge the full replayed byte set: readable text, Anthropic signature, redacted payload, Responses encrypted_content, and OpenRouter details entries. Erring high only compacts slightly early, matching the inline-image floor's documented stance; the billed-usage signal still wins where available. Signed-off-by: asto18089 --- crates/tui/src/compaction.rs | 209 +++++++++++++++++++++++++++++++++-- 1 file changed, 202 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 54ccbac1ce..e54a7b1b1c 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -354,9 +354,33 @@ pub(crate) fn estimate_tokens_for_message(message: &Message, include_thinking: b .iter() .map(|c| match c { ContentBlock::Text { text, .. } => text.len() / 4, - // Historical reasoning blocks are UI/session metadata for DeepSeek. - // Only current-turn tool-call reasoning is sent back to the API. - ContentBlock::Thinking { thinking, .. } if include_thinking => thinking.len() / 4, + ContentBlock::Thinking { + thinking, + signature, + state, + redacted_data, + reasoning_details, + } if include_thinking => { + // Every byte of a stored thinking block is replay-shaped + // input under at least one provider's contract: the readable + // text (Anthropic replays it on all prior turns, DeepSeek on + // tool-call turns), the Anthropic signature and redacted + // payload, the Responses `encrypted_content`, and OpenRouter + // `reasoning_details` entries all go back verbatim. Counting + // all of it errs high on providers that drop a subset, which + // compacts slightly early rather than overflowing. + let bytes = thinking.len() + + signature.as_deref().map_or(0, str::len) + + state.as_ref().map_or(0, |s| s.encrypted_content.len()) + + redacted_data.as_deref().map_or(0, str::len) + + reasoning_details.as_ref().map_or(0, |details| { + details + .iter() + .map(|entry| entry.to_string().len()) + .sum::() + }); + bytes / 4 + } ContentBlock::Thinking { .. } => 0, ContentBlock::ToolUse { input, .. } => serde_json::to_string(input) .map(|s| s.len() / 4) @@ -396,12 +420,15 @@ pub(crate) fn estimate_tokens_for_message(message: &Message, include_thinking: b const IMAGE_TOKEN_ESTIMATE: usize = 1000; pub fn estimate_tokens(messages: &[Message]) -> usize { - // Rough estimate: ~4 chars per token. DeepSeek thinking-mode rule: any - // assistant message with tool_calls keeps its reasoning_content forever - // (replayed in all subsequent requests). Final text-only answers drop it. + // Rough estimate: ~4 chars per token. Thinking blocks count on every + // assistant message: the preserved-thinking replay contracts (Anthropic + // signed/redacted blocks on all prior turns, Responses reasoning items + // with their function-call chain, OpenRouter reasoning_details, DeepSeek + // tool-call turns) send stored reasoning back on later requests, so a + // tool-call-only rule undercounts the budget and compacts too late. messages .iter() - .map(|message| estimate_tokens_for_message(message, message_has_tool_use(message))) + .map(|message| estimate_tokens_for_message(message, true)) .sum() } @@ -1592,6 +1619,7 @@ mod quota_tests; #[cfg(test)] mod tests { use crate::models::{ImageUrlContent, Message}; + use codewhale_core::request::OpaqueReasoningState; #[test] fn inline_image_estimates_nonzero_tokens() { @@ -2668,6 +2696,173 @@ mod tests { assert!(estimate_tokens(&historical_messages) > lower_bound); } + #[test] + fn estimate_tokens_counts_thinking_on_text_only_assistant_turns() { + // Preserved-thinking replay is no longer a DeepSeek tool-round rule: + // Anthropic Messages replays signed thinking on every prior assistant + // turn, text-only ones included. A tool-call-only rule would count + // this message at 0 and compact too late. + let thinking = "reasoning ".repeat(800); + let messages = vec![ + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: thinking.clone(), + signature: None, + state: None, + redacted_data: None, + reasoning_details: None, + }, + ContentBlock::Text { + text: "Answer.".to_string(), + cache_control: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "Next.".to_string(), + cache_control: None, + }], + }, + ]; + let lower_bound = thinking.len() / 5; + assert!( + estimate_tokens(&messages) > lower_bound, + "thinking on a text-only assistant turn must count toward the estimate" + ); + } + + #[test] + fn estimate_tokens_counts_opaque_thinking_payloads() { + // Signature, redacted payload, Responses encrypted state, and + // OpenRouter reasoning_details entries are all replayed verbatim; + // counting only the readable text would estimate them at 0. + let base = estimate_tokens_for_message( + &Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: None, + redacted_data: None, + reasoning_details: None, + }], + }, + true, + ); + assert_eq!(base, 0, "an empty block must still estimate to 0"); + + let payload = "x".repeat(400); + let with_signature = estimate_tokens_for_message( + &Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: String::new(), + signature: Some(payload.clone()), + state: None, + redacted_data: None, + reasoning_details: None, + }], + }, + true, + ); + assert!( + with_signature >= payload.len() / 5, + "signature bytes are replayed and must count" + ); + + let with_redacted = estimate_tokens_for_message( + &Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: None, + redacted_data: Some(payload.clone()), + reasoning_details: None, + }], + }, + true, + ); + assert!( + with_redacted >= payload.len() / 5, + "redacted_thinking data is replayed byte-exact and must count" + ); + + let with_state = estimate_tokens_for_message( + &Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: Some(OpaqueReasoningState { + provider: "openai".to_string(), + api: "responses".to_string(), + model: "gpt-test".to_string(), + id: None, + encrypted_content: payload.clone(), + endpoint: None, + }), + redacted_data: None, + reasoning_details: None, + }], + }, + true, + ); + assert!( + with_state >= payload.len() / 5, + "Responses encrypted_content is replayed and must count" + ); + + let details_entry = serde_json::json!({ + "type": "reasoning.encrypted", + "format": "openai-responses-v1", + "data": payload, + }); + let with_details = estimate_tokens_for_message( + &Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: None, + redacted_data: None, + reasoning_details: Some(vec![details_entry.clone()]), + }], + }, + true, + ); + assert!( + with_details >= details_entry.to_string().len() / 5, + "OpenRouter reasoning_details entries are replayed and must count" + ); + } + + #[test] + fn estimate_tokens_still_skips_thinking_when_opted_out() { + // The include_thinking=false contract (used to isolate non-reasoning + // content, e.g. the inline-image floor check) must keep ignoring + // thinking blocks entirely. + let msg = Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: "reasoning ".repeat(800), + signature: None, + state: None, + redacted_data: None, + reasoning_details: None, + }], + }; + assert_eq!( + estimate_tokens_for_message(&msg, false), + 0, + "opted-out estimates must not count thinking bytes" + ); + } + #[test] fn should_compact_respects_enabled_flag() { let config = CompactionConfig { From ff2bb95b81ccd5c0800108eb7a8f72b8ab1251cc Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 8 Oct 2026 03:41:30 +0800 Subject: [PATCH 04/12] fix(tui): close the fresh-review findings on replay contracts Fresh 11-shard review of the preserved-thinking series surfaced two defeats of the new contracts and a set of robustness gaps; this closes them. - Anthropic: drop opus-4-7/opus-4-8 from the adaptive-on-omission list. Adaptive thinking is off by default on those models ("requests with no thinking field run without thinking, matching Opus 4.6 behavior"), so omission there must strip the final tool turn - the exemption let replayed signed thinking ride into requests whose wire runs without thinking on exactly those models: the first-party API strips that server-side (documented graceful degradation), and strict gateways may reject the conflict outright. Pinned for both. - Responses: exempt Concentrate from the chain-drop gate via a shared route_captures_encrypted_reasoning predicate, keeping the gate in lockstep with the capture set - the route never captures, so gating its chains erased every prior tool call and result from the second turn on. Pinned. - Responses: pair replays by the frozen provider identity key instead of the generic provider kind, so a failover between two distinct custom gateways cannot replay each other's encrypted payloads. - Responses: summarize the chain-drop and orphaned-item warns once per request with counts instead of one line per affected message, and hoist the reasoning-capability lookup out of the history loop. - Anthropic: a redacted_thinking start without data now decodes to an empty payload (serde default) with a capture warn instead of failing the event; the non-stream shim rewrites malformed redacted blocks the same way. Both pinned at the SSE decoder layer. - Engine: an empty signature_delta no longer mints a signature-only block (an unsigned block would only earn a 400 on replay); the guard is duplicated across the flush, retry-prefix, and success paths and each arm is pinned, including a stop-less trailing block stream. - Withholding and chunk-capture limitations are documented where the next reader needs them (forwarding-proxy tradeoff on non-native hosts; text-only-chunk signatures fail closed). - chat: allow the too-many-arguments lint on the SSE chunk parser, whose parameters are the parse threads themselves (the count crossed the threshold with the OpenRouter capture threading). 6 new tests. Full lib suite 11959 passed; the only failures are the 2 environmental NO_NEW_PRIVS child-process tests and parallel-load timing flakes in the untouched remote_control/runtime_threads/ model_inventory suites, all passing standalone. Integration 287, cucumber 16, codewhale-core 82, fmt clean, no new clippy lints. Signed-off-by: asto18089 --- crates/tui/src/client/anthropic.rs | 129 +++++++++++++++--- crates/tui/src/client/chat.rs | 12 ++ crates/tui/src/client/responses.rs | 72 +++++++--- crates/tui/src/client/responses/tests.rs | 161 +++++++++++++++++++++++ crates/tui/src/core/engine/tests.rs | 111 +++++++++++++++- crates/tui/src/core/engine/turn_loop.rs | 69 +++++++++- crates/tui/src/models.rs | 8 +- 7 files changed, 522 insertions(+), 40 deletions(-) diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 5d9eecd0aa..58d2a98fe4 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -218,11 +218,14 @@ impl DeepSeekClient { { strip_thinking_from_final_tool_turn(messages); } - // Redacted thinking payloads can only be decrypted by the platform - // that minted them (first-party API, Bedrock, Vertex). Anthropic- - // dialect gateways that merely speak the protocol would reject the - // unknown block type, so those routes drop the block instead of - // shipping a request they cannot validate. + // Redacted thinking payloads are encrypted by Anthropic's first-party + // API and decrypt server-side there, so pass-through hosts cannot + // validate them — but neither can we tell a terminating gateway from + // a forwarding proxy. Withholding on every non-native host is the + // conservative call: a terminating gateway would reject the unknown + // block type outright, while a forwarding proxy loses the block's + // continuity (a redacted-only current tool turn can still 400 there). + // Native `*.anthropic.com` routes keep the payload byte-exact. if !is_native_anthropic_base_url(&self.base_url) && let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) { @@ -492,14 +495,22 @@ impl DeepSeekClient { /// unknown block type would fail the whole response decode and lose the turn /// — including the tool calls. Streaming responses carry the same blocks as /// typed `ContentBlockStart::RedactedThinking` events and need no shim. +/// A block whose `data` is missing or not a string violates the contract; +/// it is still rewritten (with an empty payload) so the turn decodes and the +/// failure surfaces at replay with a diagnosable shape instead of as a +/// whole-response decode error. fn normalize_redacted_thinking_blocks(value: &mut Value) { let Some(content) = value.get_mut("content").and_then(Value::as_array_mut) else { return; }; for block in content.iter_mut() { - if block.get("type").and_then(Value::as_str) == Some("redacted_thinking") - && let Some(data) = block.get("data").and_then(Value::as_str) - { + if block.get("type").and_then(Value::as_str) == Some("redacted_thinking") { + let data = block.get("data").and_then(Value::as_str).unwrap_or(""); + if data.is_empty() { + logging::warn( + "Response carried a redacted thinking block with a missing or non-string payload; capturing it with an empty payload", + ); + } *block = json!({ "type": "thinking", "thinking": "", @@ -511,18 +522,21 @@ fn normalize_redacted_thinking_blocks(value: &mut Value) { /// Models that run adaptive thinking when the `thinking` field is omitted. /// -/// The 2026 thinking docs are explicit that for the Claude 5 generation and -/// the Fable/Mythos lines, omitting `thinking` is NOT "disabled" — the model -/// runs adaptive thinking — so replayed signed thinking blocks stay valid. -/// For every older model an omitted field means thinking is off, and the API -/// rejects thinking content in the current tool-use turn once thinking is -/// disabled, so those turns need their thinking blocks stripped. +/// The docs are explicit that adaptive thinking is on by default only for the +/// Claude 5 generation ("Adaptive thinking is on by default on Claude Sonnet 5 +/// and Claude Opus 5" — AWS Bedrock adaptive-thinking page) and that the +/// Fable/Mythos lines are adaptive-only, so omitting `thinking` is NOT +/// "disabled" there and replayed signed thinking blocks stay valid. Older +/// models treat omission as thinking off — the migration guide states +/// "Adaptive thinking is off by default on Claude Opus 4.7: requests with no +/// thinking field run without thinking, matching Opus 4.6 behavior" — and the +/// API rejects thinking content in the current tool-use turn once thinking is +/// disabled, so those turns (Opus 4.7/4.8 included) need their thinking +/// blocks stripped. fn anthropic_omitted_thinking_defaults_to_adaptive(model: &str) -> bool { let model = model.to_ascii_lowercase(); model.contains("fable") || model.contains("mythos") - || model.contains("opus-4-7") - || model.contains("opus-4-8") || ["opus-5", "sonnet-5", "haiku-5"] .iter() .any(|family| model.contains(family)) @@ -2073,6 +2087,89 @@ mod tests { assert_eq!(assistant[0]["type"].as_str(), Some("tool_use")); } + #[test] + fn disabled_thinking_strips_on_opus_4_7_and_4_8() { + // Adaptive thinking is off by default on Opus 4.7/4.8 ("requests with + // no thinking field run without thinking, matching Opus 4.6 + // behavior" — model migration guide), so omission there must strip + // the current tool turn exactly like an explicit off. These models + // previously sat on the adaptive-on-omission list and the strip + // never fired, bricking the tool loop with a deterministic 400. + for model in ["claude-opus-4-7", "claude-opus-4-8"] { + let client = test_client(); + let mut request = request_with(model, Some("off"), None, None); + request.messages = vec![Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: "current tool turn reasoning".to_string(), + signature: Some("sig-current".to_string()), + state: None, + redacted_data: None, + reasoning_details: None, + }, + ContentBlock::ToolUse { + id: "toolu_1".to_string(), + name: "read_file".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + ], + }]; + + let body = client.build_anthropic_body(&request, true); + let assistant = &body["messages"][0]["content"]; + assert_eq!( + assistant.as_array().map(Vec::len), + Some(1), + "{model}: thinking must be stripped from the final tool turn: {body}" + ); + assert_eq!(assistant[0]["type"].as_str(), Some("tool_use")); + } + } + + #[test] + fn sse_redacted_content_block_start_decodes_through_the_stream_decoder() { + // The exact event whose decode used to drop the turn's block: a + // `redacted_thinking` content_block_start through the SSE decoder. + // The contract-violating no-data form must still decode (empty + // payload + capture warn) instead of failing the event and silently + // losing the block. + use crate::models::ContentBlockStart; + + let decode = |line: &str| { + convert_anthropic_sse_data(line) + .expect("content_block_start must be recognized") + .expect("decode must succeed") + }; + + let event = decode( + r#"{"type":"content_block_start","index":1,"content_block":{"type":"redacted_thinking","data":"ENC"}}"#, + ); + match event { + StreamEvent::ContentBlockStart { + index, + content_block: ContentBlockStart::RedactedThinking { data }, + } => { + assert_eq!(index, 1); + assert_eq!(data, "ENC"); + } + other => panic!("unexpected event for redacted start: {other:?}"), + } + + let malformed = decode( + r#"{"type":"content_block_start","index":2,"content_block":{"type":"redacted_thinking"}}"#, + ); + match malformed { + StreamEvent::ContentBlockStart { + content_block: ContentBlockStart::RedactedThinking { data }, + .. + } => assert_eq!(data, "", "missing data must default to an empty payload"), + other => panic!("unexpected event for malformed redacted start: {other:?}"), + } + } + #[test] fn non_native_gateways_never_receive_redacted_thinking_blocks() { // Redacted payloads can only be decrypted by the platform that minted diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index aee0b230fb..00c01549ec 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -4198,7 +4198,12 @@ fn parse_sse_data_frame( /// Parse a single SSE chunk from the Chat Completions streaming API into /// our internal `StreamEvent` representation. +/// +/// The parameters are the per-stream parse threads themselves (`&mut` state +/// plus two route flags); threading them through a struct would only move +/// the same count behind a field access, so the lint is allowed explicitly. #[cfg(test)] +#[allow(clippy::too_many_arguments)] pub(super) fn parse_sse_chunk( chunk: &Value, content_index: &mut u32, @@ -4424,6 +4429,13 @@ fn parse_sse_chunk_with_reasoning_style( // the rest of the non-start fields. The official // compat route also signs the chunk-level // `delta.extra_content` on the pre-tool text part. + // Known limit: that chunk-level form is only read + // when the same delta also carries the `tool_calls` + // entry. A signature emitted exclusively on an + // earlier text-only chunk is not buffered here — + // the validator then fails closed with its + // diagnostic instead of replaying a call Google + // would reject unsigned. if let Some(signature) = tool_call_thought_signature(tc, delta) .filter(|value| !value.is_empty()) { diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 84d1c77f21..51add90c07 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -831,10 +831,17 @@ fn message_has_tool_calls(msg: &crate::models::Message) -> bool { } /// Whether any thinking block of this message replays its encrypted -/// reasoning on this route: same provider, same Responses API, same model. +/// reasoning on this request: same endpoint-scoped provider tag, same +/// Responses api shape, same model, and the endpoint rules (fingerprint +/// match, or the fingerprint-less official-endpoint credit). This is exactly +/// the reattach gate inside [`convert_messages_to_responses_input`], kept in +/// one place so the chain-drop gate can never disagree with the gate that +/// actually reattaches the item. fn message_has_replayable_reasoning( msg: &crate::models::Message, - provider: &ApiProvider, + reasoning_provider_tag: &str, + reasoning_endpoint_fingerprint: &str, + fingerprintless_replay_official: bool, model: &str, ) -> bool { msg.content.iter().any(|block| { @@ -844,9 +851,16 @@ fn message_has_replayable_reasoning( state: Some(state), redacted_data: None, .. - } if state.provider == provider.as_str() + } if state.provider == reasoning_provider_tag && state.api == "openai-responses" && state.model == model + && match &state.endpoint { + None => { + !is_custom_reasoning_tag(&state.provider) + && fingerprintless_replay_official + } + Some(captured) => captured == reasoning_endpoint_fingerprint, + } ) }) } @@ -860,9 +874,9 @@ fn message_has_replayable_reasoning( /// immediately preceded by the encrypted `reasoning` item that produced it, /// and every `reasoning` item must be immediately followed by its produced /// item. When a message's reasoning is unusable on this route (captured on a -/// different model or provider, or by a build that did not capture it), its -/// whole tool chain — reasoning, function calls, and their outputs — is -/// dropped instead of shipping a request the API rejects outright. +/// different model, provider identity, or by a build that did not capture +/// it), its whole tool chain — reasoning, function calls, and their outputs — +/// is dropped instead of shipping a request the API rejects outright. pub(super) fn convert_messages_to_responses_input( request: &MessageRequest, provider: ApiProvider, @@ -887,8 +901,12 @@ pub(super) fn convert_messages_to_responses_input( // never receive an encrypted replay item either, so the replay gate // cannot drift from the include gate. let replays_encrypted_reasoning = responses_route_sends_encrypted_reasoning_include(provider); + // Hoisted: the reasoning-capability lookup lowercases and consults the + // catalog, and the loop would otherwise repeat it per assistant message. + let reasoning_capable_model = crate::models::model_supports_reasoning(&request.model); let mut items = Vec::new(); let mut orphaned_call_ids: HashSet = HashSet::new(); + let mut dropped_chains: usize = 0; for msg in &request.messages { // Channel selection lives in the shared placement table; this adapter @@ -953,23 +971,30 @@ pub(super) fn convert_messages_to_responses_input( // instead; the tool outputs are filtered out of the following // user/tool messages via `orphaned_call_ids`. Models that // never reason legitimately emit bare calls, so the gate only - // applies to reasoning-capable request models. + // applies to reasoning-capable request models — and only on + // routes that capture at all: a route whose requests carry no + // `include` can never satisfy the replay check, so gating + // there would erase history the route itself accepts. let mut chain_dropped = false; - if !is_deepseek - && crate::models::model_supports_reasoning(&request.model) + if replays_encrypted_reasoning + && reasoning_capable_model && message_has_tool_calls(msg) - && !message_has_replayable_reasoning(msg, &provider, &request.model) + && !message_has_replayable_reasoning( + msg, + reasoning_provider_tag, + reasoning_endpoint_fingerprint, + fingerprintless_replay_official, + &request.model, + ) { chain_dropped = true; + dropped_chains += 1; for block in &msg.content { if let ContentBlock::ToolUse { id, .. } = block { let (call_id, _item_id) = parse_tool_use_id(id); orphaned_call_ids.insert(call_id); } } - logging::warn( - "Responses replay dropped an assistant tool chain whose encrypted reasoning is unavailable for this route/model (captured on a different model or provider, or by an older build); its tool calls and outputs are omitted to keep the request valid", - ); } for block in &msg.content { match block { @@ -1138,6 +1163,16 @@ pub(super) fn convert_messages_to_responses_input( } } + if dropped_chains > 0 { + // One summary per request instead of one line per affected message: + // stale history re-fires the condition on every subsequent request + // until compaction retires the turns, and per-message warns would + // spam verbose logs for a single root cause. + logging::warn(format!( + "Responses replay dropped {dropped_chains} assistant tool chain(s) whose encrypted reasoning is unavailable for this route/model/identity (captured on a different model, provider identity, or by an older build); their tool calls and outputs are omitted to keep the request valid" + )); + } + if !is_deepseek { // A `reasoning` item must be immediately followed by the item it // produced (its function_call, or the assistant message for text @@ -1146,6 +1181,7 @@ pub(super) fn convert_messages_to_responses_input( // fail with "was provided without its required following item", so // it is dropped here. let mut keep = vec![true; items.len()]; + let mut dropped_orphans: usize = 0; for (index, item) in items.iter().enumerate() { if item.get("type").and_then(Value::as_str) != Some("reasoning") { continue; @@ -1157,18 +1193,20 @@ pub(super) fn convert_messages_to_responses_input( }); if !followed_by_produced_item { keep[index] = false; + dropped_orphans += 1; } } let mut paired = Vec::with_capacity(items.len()); for (index, item) in items.into_iter().enumerate() { if keep[index] { paired.push(item); - } else { - logging::warn( - "Responses replay dropped an orphaned reasoning item with no following output item", - ); } } + if dropped_orphans > 0 { + logging::warn(format!( + "Responses replay dropped {dropped_orphans} orphaned reasoning item(s) with no following output item" + )); + } items = paired; } diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index 02ae1e08ae..c6672d3c0c 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -1400,6 +1400,167 @@ fn responses_input_includes_user_role_tool_results() { assert_eq!(input[2]["output"], "<6 items>"); } +#[test] +fn concentrate_route_keeps_tool_chains() { + // The Concentrate route never captures encrypted reasoning (no + // `store:false`, no `include`), so nothing can ever satisfy the replay + // check on it. Its tool chains must therefore bypass the chain-drop + // gate: gating them erased every prior tool call and result from the + // second turn onward, on a route that ships bare calls against + // server-side state and worked. + let request = MessageRequest { + model: "deepseek-v4-pro".to_string(), + messages: vec![ + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "call_abc|fc_123".to_string(), + name: "checklist_write".to_string(), + input: json!({"items": []}), + caller: None, + thought_signature: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call_abc|fc_123".to_string(), + content: "<6 items>".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ], + max_tokens: 128, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: None, + temperature: None, + top_p: None, + }; + + let input = convert_messages_to_responses_input( + &request, + ApiProvider::Concentrate, + ApiProvider::Concentrate.as_str(), + "fp-concentrate-endpoint", + BODY_TEST_BASE_URL, + ); + let types: Vec<&str> = input + .iter() + .filter_map(|item| item["type"].as_str()) + .collect(); + assert_eq!( + types, + vec!["function_call", "function_call_output"], + "Concentrate must keep its tool history: {types:?}" + ); +} + +#[test] +fn custom_provider_replay_scopes_to_the_exact_identity() { + // Encrypted reasoning captured by one custom Responses gateway must not + // replay on another: the generic `custom` kind is shared by every + // user-defined endpoint, so the endpoint-scoped tag plus the endpoint + // fingerprint gate the pairing (a failover between gateways would + // otherwise ship payloads the new gateway cannot decrypt — an + // unretryable 400). This pins the chain-drop side of that gate: when no + // state replays, the whole tool chain leaves the wire with it. + const GATEWAY_A: &str = "custom/gateway-a"; + const GATEWAY_A_FP: &str = "fp-gateway-a-endpoint"; + let request_captured_on = |fingerprint: Option<&str>| MessageRequest { + model: "gpt-5.5".to_string(), + messages: vec![ + Message { + role: Role::Assistant, + content: vec![ + ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: Some(OpaqueReasoningState { + provider: GATEWAY_A.to_string(), + api: "openai-responses".to_string(), + model: "gpt-5.5".to_string(), + id: None, + encrypted_content: "enc_custom".to_string(), + endpoint: fingerprint.map(str::to_string), + }), + redacted_data: None, + reasoning_details: None, + }, + ContentBlock::ToolUse { + id: "call_abc|fc_123".to_string(), + name: "checklist_write".to_string(), + input: json!({"items": []}), + caller: None, + thought_signature: None, + }, + ], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call_abc|fc_123".to_string(), + content: "<6 items>".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ], + max_tokens: 128, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: None, + stream: None, + temperature: None, + top_p: None, + }; + + // Same gateway (tag and fingerprint): the chain replays intact. + let same = convert_messages_to_responses_input( + &request_captured_on(Some(GATEWAY_A_FP)), + ApiProvider::Custom, + GATEWAY_A, + GATEWAY_A_FP, + BODY_TEST_BASE_URL, + ); + let same_types: Vec<&str> = same + .iter() + .filter_map(|item| item["type"].as_str()) + .collect(); + assert_eq!( + same_types, + vec!["reasoning", "function_call", "function_call_output"], + "same-identity custom gateway must replay the chain: {same_types:?}" + ); + + // A failover to a different endpoint under the same table (the URL was + // re-pointed): the fingerprint mismatch drops the whole chain fail-closed + // instead of shipping encrypted content the new endpoint cannot decrypt. + let switched = convert_messages_to_responses_input( + &request_captured_on(Some(GATEWAY_A_FP)), + ApiProvider::Custom, + GATEWAY_A, + "fp-gateway-b-endpoint", + BODY_TEST_BASE_URL, + ); + let switched_types: Vec<&str> = switched + .iter() + .filter_map(|item| item["type"].as_str()) + .collect(); + assert!( + !switched_types.contains(&"function_call") && !switched_types.contains(&"reasoning"), + "cross-endpoint custom failover must not replay foreign encrypted content: {switched_types:?}" + ); +} + #[test] fn responses_input_encodes_tool_call_names() { let request = MessageRequest { diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 6111d10ace..733d878934 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -14972,10 +14972,14 @@ fn sandbox_policy_for_turn_returns_correct_default_policy_per_mode() { } /// Serves one multi-block thinking + tool-use turn, then a plain closing -/// turn, capturing every outgoing request. +/// turn, capturing every outgoing request. `trailing_signature_only` swaps +/// the first stream for one where the signature-only block never receives a +/// stop event and only the tool use follows it — the success-path persist +/// guard is then the only thing that can keep the block. struct MultiThinkingStreamClient { requests: std::sync::Mutex>, calls: std::sync::atomic::AtomicUsize, + trailing_signature_only: bool, } #[async_trait::async_trait] @@ -15008,7 +15012,53 @@ impl crate::core::model_client::ModelClient for MultiThinkingStreamClient { .expect("multi-thinking request lock") .push(request); use crate::models::{ContentBlockStart, Delta, MessageDelta, StreamEvent}; - let events: Vec = if call == 1 { + let trailing = self.trailing_signature_only; + let events: Vec = if trailing && call == 1 { + // One signature-only thinking block whose stop event never + // arrives, directly followed by the tool use: no flush runs + // before the stream ends, so only the success-path persist + // guard can park the block. + vec![ + crate::llm_client::mock::canned::message_start("msg_trailing"), + StreamEvent::ContentBlockStart { + index: 0, + content_block: ContentBlockStart::Thinking { + thinking: String::new(), + }, + }, + StreamEvent::ContentBlockDelta { + index: 0, + delta: Delta::SignatureDelta { + signature: "sig-trailing".to_string(), + }, + }, + StreamEvent::ContentBlockStart { + index: 1, + content_block: ContentBlockStart::ToolUse { + id: "toolu_multi".to_string(), + name: "read_file".to_string(), + input: json!({}), + caller: None, + thought_signature: None, + }, + }, + StreamEvent::ContentBlockDelta { + index: 1, + delta: Delta::InputJsonDelta { + partial_json: r#"{"path":"a.txt"}"#.to_string(), + }, + }, + StreamEvent::ContentBlockStop { index: 1 }, + StreamEvent::MessageDelta { + delta: MessageDelta { + stop_reason: Some("tool_use".to_string()), + stop_sequence: None, + }, + usage: None, + }, + StreamEvent::MessageStop, + ] + } else if call == 1 { vec![ crate::llm_client::mock::canned::message_start("msg_multi"), // Block 0: readable thinking with a signature. @@ -15102,6 +15152,57 @@ impl crate::core::model_client::ModelClient for MultiThinkingStreamClient { /// the follow-up request. #[tokio::test] async fn multi_block_and_redacted_thinking_persist_into_followup_requests() { + let requests = run_multi_thinking_turn(false).await; + assert_followup_carries_full_thinking_sequence(&requests); +} + +/// The success-path persist guard duplicates the signature-only clause for a +/// trailing block whose stop event never arrives (the stopped path is pinned +/// by the multi-block test above). Without that duplicated arm, an +/// omitted-display block in a stop-less stream would vanish from the +/// follow-up request and Anthropic would 400 on the bare `tool_use` replay. +#[tokio::test] +async fn signature_only_block_survives_a_stream_that_skips_its_stop_event() { + let requests = run_multi_thinking_turn(true).await; + assert!(requests.len() >= 2, "expected a follow-up request"); + let followup = &requests[1]; + let assistant = followup + .messages + .iter() + .rev() + .find(|message| message.role == Role::Assistant) + .expect("follow-up request must contain the prior assistant turn"); + let mut blocks = assistant.content.iter(); + match (blocks.next(), blocks.next(), blocks.next()) { + ( + Some(ContentBlock::Thinking { + thinking, + signature, + state: None, + redacted_data: None, + .. + }), + Some(ContentBlock::ToolUse { .. }), + None, + ) => { + assert!(thinking.is_empty(), "omitted-display text stays empty"); + assert_eq!( + signature.as_deref(), + Some("sig-trailing"), + "the trailing stop-less signature-only block must persist" + ); + } + other => panic!("unexpected follow-up assistant blocks: {other:?}"), + } +} + +/// Drive a two-request turn whose first stream carries the full thinking +/// sequence (readable+signed, signature-only, redacted, tool use), capturing +/// every outgoing request. `trailing_signature_only` swaps the first stream +/// for the stop-less trailing-block shape. +async fn run_multi_thinking_turn( + trailing_signature_only: bool, +) -> Vec { let config = Config { provider: Some("custom-a".to_string()), providers: Some(crate::config::ProvidersConfig { @@ -15128,6 +15229,7 @@ async fn multi_block_and_redacted_thinking_persist_into_followup_requests() { let client = std::sync::Arc::new(MultiThinkingStreamClient { requests: std::sync::Mutex::new(Vec::new()), calls: std::sync::atomic::AtomicUsize::new(0), + trailing_signature_only, }); let shared: crate::core::model_client::SharedModelClient = client.clone(); let (engine, handle) = Engine::new_with_model_client(engine_config, &config, shared); @@ -15176,7 +15278,10 @@ async fn multi_block_and_redacted_thinking_persist_into_followup_requests() { } assert!(completed, "turn did not complete"); - let requests = client.requests.lock().expect("request lock").clone(); + client.requests.lock().expect("request lock").clone() +} + +fn assert_followup_carries_full_thinking_sequence(requests: &[crate::models::MessageRequest]) { assert!(requests.len() >= 2, "expected a follow-up request"); let followup = &requests[1]; let assistant = followup diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index dad47e7f15..6d15c82d63 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -749,7 +749,7 @@ fn flush_thinking_block( ) { let placeholder_only = crate::client::is_reasoning_replay_placeholder(thinking); if (!thinking.is_empty() && !placeholder_only) - || signature.is_some() + || signature_carries_content(signature.as_deref()) || state.is_some() || details.is_some() { @@ -767,6 +767,14 @@ fn flush_thinking_block( *details = None; } +/// A signature only counts as block content when it is non-empty: a provider +/// streaming a bare empty `signature_delta` must not mint a signature-only +/// (`display: "omitted"`) thinking block out of nothing and replay +/// `{"signature": ""}` back to the API. +fn signature_carries_content(signature: Option<&str>) -> bool { + signature.is_some_and(|signature| !signature.is_empty()) +} + fn approval_intent_summary(text: &str) -> Option { let trimmed = text.trim(); if trimmed.is_empty() { @@ -2371,7 +2379,7 @@ impl Engine { crate::client::is_reasoning_replay_placeholder(¤t_thinking); if (!current_thinking.is_empty() && !thinking_is_placeholder_only) || current_thinking_state.is_some() - || current_thinking_signature.is_some() + || signature_carries_content(current_thinking_signature.as_deref()) || current_thinking_details.is_some() { resume_blocks.push(ContentBlock::Thinking { @@ -2502,7 +2510,7 @@ impl Engine { crate::client::is_reasoning_replay_placeholder(¤t_thinking); if (!current_thinking.is_empty() && !thinking_is_placeholder_only) || current_thinking_state.is_some() - || current_thinking_signature.is_some() + || signature_carries_content(current_thinking_signature.as_deref()) || current_thinking_details.is_some() { content_blocks.push(ContentBlock::Thinking { @@ -5574,6 +5582,16 @@ impl Engine { // start and must be replayed byte-exact, so park it // immediately. No UI events: there is no readable // content to stream. + stream_content_bytes = stream_content_bytes.saturating_add(data.len()); + if data.is_empty() { + // With serde's default this can only come from a + // contract-violating block (missing `data`). It + // still parks so the failure surfaces at replay + // with a diagnosable shape. + crate::logging::warn( + "Captured a redacted thinking block with an empty payload; it will replay verbatim and the API may reject the request", + ); + } flush_thinking_block( &mut current_thinking, &mut current_thinking_signature, @@ -7097,6 +7115,51 @@ mod tests { use std::time::Duration; use tempfile::tempdir; + #[test] + fn flush_keeps_signature_only_blocks_but_not_empty_signature_mints() { + // A display:"omitted" block (empty text, real signature) is real + // content and must park; a bare empty `signature_delta` must not + // mint one — replaying {"signature": ""} would only earn a 400. + let mut completed = Vec::new(); + flush_thinking_block( + &mut String::new(), + &mut Some("sig-real".to_string()), + &mut None, + &mut None, + &mut completed, + ); + assert_eq!(completed.len(), 1); + match &completed[0] { + ContentBlock::Thinking { signature, .. } => { + assert_eq!(signature.as_deref(), Some("sig-real")); + } + other => panic!("expected a thinking block: {other:?}"), + } + + completed.clear(); + flush_thinking_block( + &mut String::new(), + &mut Some(String::new()), + &mut None, + &mut None, + &mut completed, + ); + assert!( + completed.is_empty(), + "an empty-signature-only block must not persist: {completed:?}" + ); + + completed.clear(); + flush_thinking_block( + &mut "readable reasoning".to_string(), + &mut Some(String::new()), + &mut None, + &mut None, + &mut completed, + ); + assert_eq!(completed.len(), 1, "readable text persists regardless"); + } + /// A 1x1 PNG, kept as bytes so the tool-result image tests need no /// checked-in fixture file. const PNG_1X1: &[u8] = &[ diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index 93a8fab5e0..a96e163328 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -739,7 +739,13 @@ pub enum ContentBlockStart { /// reasoning, and dropping it makes the next tool-loop request fail with /// "Expected `thinking` or `redacted_thinking`, but found `tool_use`". #[serde(rename = "redacted_thinking")] - RedactedThinking { data: String }, + RedactedThinking { + /// Defaulted so a contract-violating start event without `data` + /// still decodes (empty payload + capture warn) instead of failing + /// the whole event and silently losing the turn. + #[serde(default)] + data: String, + }, #[serde(rename = "tool_use")] ToolUse { id: String, From b6fc45cd0517731665564509a0c07b1dfbe2057b Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 8 Oct 2026 12:14:11 +0800 Subject: [PATCH 05/12] fix(tui): harden thinking capture against malformed provider output Fresh-review follow-ups on the preserved-thinking series: three capture paths that a nonconforming gateway output could still defeat. - Google thought signatures: an empty string is no signature. The opening-chunk and non-stream capture paths stored Some(""), which the engine's first-capture-wins race turns into a blocker for the real signature arriving on a continuation chunk of the same tool call (and replays as extra_content.google.thought_signature: ""). Both paths now apply the same empty filter as the continuation path, and both shapes are pinned. - Anthropic redacted start events: #[serde(default)] covers a missing key only, so an explicit "data": null still failed the whole SSE event and silently lost the block. A custom deserializer now decodes both malformed shapes to the empty payload that the capture site warns about. Pinned. - OpenRouter reasoning_details without an index are keyed by their position in the incoming cumulative array instead of by type and a running counter: a type key merged two same-type entries (losing one), and the counter re-keyed every reappearance of an entry, growing the snapshot. Pinned. - The placeholder-substitution warn no longer names DeepSeek; it fires on every reasoning-replay route with an empty thinking payload. Signed-off-by: asto18089 --- crates/tui/src/client/chat.rs | 168 ++++++++++++++++-- .../src/client/chat/tests/stream_decoder.rs | 42 +++++ crates/tui/src/models.rs | 41 ++++- 3 files changed, 235 insertions(+), 16 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 00c01549ec..4082fa9e3c 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -2870,7 +2870,7 @@ fn build_chat_messages_with_reasoning( && wire_reasoning_details.is_none() { logging::warn( - "Substituting placeholder reasoning_content for DeepSeek tool-call assistant message", + "Substituting placeholder reasoning_content for a tool-call assistant message on a reasoning-replay route", ); reasoning_content = String::from(REASONING_REPLAY_PLACEHOLDER); has_reasoning = true; @@ -3790,7 +3790,11 @@ fn replayable_reasoning_details(details: &[Value]) -> Vec { /// Accumulates OpenRouter `reasoning_details` entries across stream chunks. /// OpenRouter repeats the growing array on successive chunks (the same /// cumulative-snapshot shape MiniMax uses), so entries are keyed by `index` -/// when present, else by `type`, and the last version of each key wins. +/// when present, else by their position in the incoming array — stable under +/// the cumulative-snapshot protocol, where a `type` key would collide (two +/// same-type entries in one array would merge and lose one) and a running +/// position counter would re-key every reappearance of an entry. The last +/// version of each key wins. #[derive(Default)] pub(super) struct ReasoningDetailsBuffer { order: Vec, @@ -3801,18 +3805,15 @@ impl ReasoningDetailsBuffer { /// Absorb one chunk's entries; returns true when the snapshot changed. fn absorb(&mut self, details: &[Value]) -> bool { let mut changed = false; - for entry in replayable_reasoning_details(details) { + for (position, entry) in replayable_reasoning_details(details) + .into_iter() + .enumerate() + { let key = entry .get("index") .and_then(Value::as_u64) .map(|index| index.to_string()) - .or_else(|| { - entry - .get("type") - .and_then(Value::as_str) - .map(str::to_string) - }) - .unwrap_or_else(|| format!("_pos{}", self.order.len())); + .unwrap_or_else(|| format!("_arr{position}")); if !self.order.contains(&key) { self.order.push(key.clone()); changed = true; @@ -3961,9 +3962,13 @@ fn parse_chat_message_for_route( }) }); + // An empty string is no signature; storing Some("") would both + // replay `extra_content.google.thought_signature: ""` and block a + // real signature captured later for the same call. let thought_signature = call .pointer("/extra_content/google/thought_signature") .and_then(Value::as_str) + .filter(|value| !value.is_empty()) .map(str::to_string); content_blocks.push(ContentBlock::ToolUse { id, @@ -4497,8 +4502,14 @@ fn parse_sse_chunk_with_reasoning_style( }) }); - let thought_signature = - tool_call_thought_signature(tc, delta).map(str::to_string); + // Mirror the continuation-chunk path: an empty + // string is no signature. Storing Some("") here + // would win the engine's first-capture-wins race + // and drop the real signature when it arrives on + // a later chunk of the same tool call. + let thought_signature = tool_call_thought_signature(tc, delta) + .filter(|value| !value.is_empty()) + .map(str::to_string); events.push(StreamEvent::ContentBlockStart { index: block_index, content_block: ContentBlockStart::ToolUse { @@ -9031,6 +9042,139 @@ mod google_thought_signature_tests { ); } + #[test] + fn google_empty_signature_on_the_opening_chunk_never_blocks_the_real_one() { + // A gateway that emits `"thought_signature": ""` on the chunk + // opening the tool call must not win the engine's first-capture-wins + // race: storing Some("") would drop the real signature arriving on + // the continuation chunk and replay an empty string instead. + let opening_chunk = json!({ + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "id": "call-g-10", + "type": "function", + "function": { "name": "read", "arguments": "{" }, + "extra_content": { + "google": { "thought_signature": "" } + } + }] + } + }] + }); + let continuation_chunk = json!({ + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": 0, + "function": { "arguments": "}" }, + "extra_content": { + "google": { "thought_signature": "SIG-real" } + } + }] + } + }] + }); + let mut content_index = 0u32; + let mut text_started = false; + let mut thinking_started = false; + let mut tool_indices = std::collections::HashMap::new(); + let mut reasoning_buffers = std::collections::HashMap::new(); + let mut inline_tags = InlineReasoningTagState::default(); + let style = ReasoningStreamStyle::None; + let opening_events = parse_sse_chunk_with_reasoning_style( + &opening_chunk, + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + &mut reasoning_buffers, + &mut ReasoningDetailsBuffer::default(), + false, + &mut inline_tags, + style, + ); + let opening_signature = opening_events.iter().find_map(|event| match event { + StreamEvent::ContentBlockStart { + content_block: + ContentBlockStart::ToolUse { + thought_signature, .. + }, + .. + } => thought_signature.clone(), + _ => None, + }); + assert_eq!( + opening_signature.as_deref(), + None, + "an empty opening signature must be stored as absent: {opening_events:?}" + ); + let continuation_events = parse_sse_chunk_with_reasoning_style( + &continuation_chunk, + &mut content_index, + &mut text_started, + &mut thinking_started, + &mut tool_indices, + &mut reasoning_buffers, + &mut ReasoningDetailsBuffer::default(), + false, + &mut inline_tags, + style, + ); + let real_signature = continuation_events.iter().find_map(|event| match event { + StreamEvent::ContentBlockDelta { + delta: Delta::ToolThoughtSignatureDelta { signature }, + .. + } => Some(signature.clone()), + _ => None, + }); + assert_eq!( + real_signature.as_deref(), + Some("SIG-real"), + "the real continuation signature must still attach: {continuation_events:?}" + ); + } + + #[test] + fn google_nonstream_empty_signature_stays_absent() { + // Non-stream mirror of the empty-signature rule: `""` is no + // signature, so the ToolUse block must carry None rather than an + // empty string that would replay as `thought_signature: ""`. + let payload = json!({ + "id": "chatcmpl-g-empty", + "model": "gemini-3.1-pro-preview", + "choices": [{ + "finish_reason": "tool_calls", + "message": {"role": "assistant", "content": null, "tool_calls": [{ + "id": "call-g-11", + "type": "function", + "function": { "name": "read", "arguments": "{}" }, + "extra_content": { + "google": { "thought_signature": "" } + } + }]} + }] + }); + let message = + parse_chat_message_for_route(&payload, ApiProvider::Google, DEFAULT_GOOGLE_BASE_URL) + .expect("Google payload parses"); + let signature = message.content.iter().find_map(|block| match block { + ContentBlock::ToolUse { + thought_signature, .. + } => thought_signature.clone(), + _ => None, + }); + assert_eq!( + signature.as_deref(), + None, + "an empty non-stream signature must stay absent: {:?}", + message.content + ); + } + #[test] fn google_route_defaults_to_separate_field_stream_style_and_config_still_wins() { // Stream parity with the non-streaming decoder: on the exact official diff --git a/crates/tui/src/client/chat/tests/stream_decoder.rs b/crates/tui/src/client/chat/tests/stream_decoder.rs index 387abc16e2..8c9344621a 100644 --- a/crates/tui/src/client/chat/tests/stream_decoder.rs +++ b/crates/tui/src/client/chat/tests/stream_decoder.rs @@ -1613,6 +1613,48 @@ fn openrouter_details_placeholder_entries_never_enter_the_snapshot() { ); } +#[test] +fn openrouter_details_without_index_key_by_array_position() { + // Entries without an `index` are keyed by their position in the + // incoming cumulative array. A `type` key would merge two same-type + // entries into one (losing the other), and a running counter would + // re-key every reappearance of an entry and grow the snapshot. + let chunks = vec![ + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "text": "one" }, + { "type": "reasoning.text", "text": "two" } + ] + } + }] + }), + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "text": "one" }, + { "type": "reasoning.text", "text": "two!" } + ] + } + }] + }), + ]; + let events = decode_chunks_capturing_details(&chunks, true); + let snapshots = details_delta_payloads(&events); + assert_eq!(snapshots.len(), 2, "one snapshot per absorbing chunk"); + let last = snapshots.last().expect("final snapshot"); + assert_eq!(last.len(), 2, "both index-less entries survive"); + assert_eq!(last[0]["text"], "one"); + assert_eq!( + last[1]["text"], "two!", + "last version of the second entry wins" + ); +} + #[test] fn non_openrouter_routes_never_emit_details_deltas() { let chunks = vec![serde_json::json!({ diff --git a/crates/tui/src/models.rs b/crates/tui/src/models.rs index a96e163328..4b3995ee86 100644 --- a/crates/tui/src/models.rs +++ b/crates/tui/src/models.rs @@ -723,6 +723,17 @@ pub enum StreamEvent { Error { error: serde_json::Value }, } +/// Decode the `redacted_thinking` start-event payload, tolerating the +/// missing-key shape (`#[serde(default)]`) and an explicit `"data": null` +/// from nonconforming gateways. Both decode to the empty payload that the +/// capture site warns about, instead of failing the whole SSE event. +fn deserialize_redacted_data<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::deserialize(deserializer)?.unwrap_or_default()) +} + #[allow(dead_code)] #[derive(Debug, Deserialize, Clone)] #[serde(tag = "type")] @@ -740,10 +751,13 @@ pub enum ContentBlockStart { /// "Expected `thinking` or `redacted_thinking`, but found `tool_use`". #[serde(rename = "redacted_thinking")] RedactedThinking { - /// Defaulted so a contract-violating start event without `data` - /// still decodes (empty payload + capture warn) instead of failing - /// the whole event and silently losing the turn. - #[serde(default)] + /// Tolerant of both a missing and an explicit-null `data` + /// (`deserialize_redacted_data` + default): a contract-violating + /// start event still decodes (empty payload + capture warn) instead + /// of failing the whole event and silently losing the turn. Note + /// that `#[serde(default)]` alone only covers the missing key — an + /// explicit `"data": null` would still fail `String` decoding. + #[serde(default, deserialize_with = "deserialize_redacted_data")] data: String, }, #[serde(rename = "tool_use")] @@ -836,6 +850,25 @@ mod tests { } } + #[test] + fn redacted_thinking_start_tolerates_an_explicit_null_payload() { + // `#[serde(default)]` covers a missing key only; a nonconforming + // gateway sending `"data": null` must still decode to the empty + // payload (capture warn downstream) instead of failing the whole + // SSE event and silently losing the turn's redacted block. + let block: ContentBlockStart = serde_json::from_value(serde_json::json!({ + "type": "redacted_thinking", + "data": null, + })) + .expect("an explicit null payload must not fail the event decode"); + match block { + ContentBlockStart::RedactedThinking { data } => { + assert_eq!(data, ""); + } + other => panic!("expected RedactedThinking, got {other:?}"), + } + } + #[test] fn output_limit_stop_reason_accepts_provider_aliases_only() { for reason in [ From c290fc0770ded0e43164233816bf4eeeb28a25c1 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 8 Oct 2026 12:15:24 +0800 Subject: [PATCH 06/12] fix(tui): treat flushed thinking as received content on stream death Two engine predicates still described the world before thinking blocks became first-class persisted content. - stream_died_with_nothing checked only the in-flight accumulator, so a stream that died (non-network error, resume budgets spent) after a thinking block had been flushed at its stop event classified as "died with nothing" and silently re-ran the request - double-billing for thinking already received where the base surfaced the real error. The completed list now counts as received content, and the death surfaces its error. Pinned with a stream that completes a block and then dies. - has_provider_reasoning recognized only readable text and Responses state, so the reasoning-only recovery ladder could not fire for a clean stop carrying exactly one of the block shapes this series made first-class: signature-only (display "omitted"), redacted, or details-only. Those turns dead-ended with "no answer or tool call" where a text thinking block rides the cached-prefix retry. The predicate now accepts every replayable shape, reusing the empty- signature guard so a bare empty delta still counts as nothing. Both shapes pinned through the existing reasoning-only recovery harness. Signed-off-by: asto18089 --- crates/tui/src/core/engine/tests.rs | 276 ++++++++++++++++++++++-- crates/tui/src/core/engine/turn_loop.rs | 15 +- 2 files changed, 277 insertions(+), 14 deletions(-) diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 733d878934..d798e70a73 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -22367,6 +22367,19 @@ async fn interactive_thinking_only_drop_preserves_nothing_and_never_claims_it_di ); } +/// Which reasoning-only shape the canned response carries. All three are +/// real provider reasoning with no sendable content. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ReasoningOnlyShape { + /// Readable thinking text. + Text, + /// A signature-only (`display: "omitted"`) block: empty text plus the + /// encrypted signature. + SignatureOnly, + /// An Anthropic redacted block: payload arrives complete on the start. + RedactedOnly, +} + /// A model client that answers with ONLY hidden reasoning and a clean stop for /// its first `reasoning_only` calls, then a real text answer. No transport /// error: the stream completes normally but carries no sendable content — the @@ -22375,6 +22388,7 @@ struct ReasoningOnlyCleanFinishModelClient { calls: std::sync::atomic::AtomicUsize, reasoning_only: usize, stop_reason: &'static str, + shape: ReasoningOnlyShape, /// Every outbound request's messages, in order, so a test can tell a /// request-scoped nudge from one written into the session. requests: std::sync::Mutex>>, @@ -22412,19 +22426,50 @@ impl crate::core::model_client::ModelClient for ReasoningOnlyCleanFinishModelCli if call <= self.reasoning_only { // A protocol-complete response that opened and closed only a // thinking block: no text, no tool call, and a clean stop reason. - let events: Vec> = vec![ - Ok(canned::message_start("reasoning_only_msg")), - Ok(StreamEvent::ContentBlockStart { - index: 0, - content_block: crate::models::ContentBlockStart::Thinking { - thinking: String::new(), - }, - }), - Ok(canned::thinking_delta(0, "reasoning with no final channel")), - Ok(canned::block_stop(0)), - Ok(canned::message_delta(self.stop_reason, None)), - Ok(canned::message_stop()), - ]; + let events: Vec> = match self.shape { + ReasoningOnlyShape::Text => vec![ + Ok(canned::message_start("reasoning_only_msg")), + Ok(StreamEvent::ContentBlockStart { + index: 0, + content_block: crate::models::ContentBlockStart::Thinking { + thinking: String::new(), + }, + }), + Ok(canned::thinking_delta(0, "reasoning with no final channel")), + Ok(canned::block_stop(0)), + Ok(canned::message_delta(self.stop_reason, None)), + Ok(canned::message_stop()), + ], + ReasoningOnlyShape::SignatureOnly => vec![ + Ok(canned::message_start("reasoning_only_msg")), + Ok(StreamEvent::ContentBlockStart { + index: 0, + content_block: crate::models::ContentBlockStart::Thinking { + thinking: String::new(), + }, + }), + Ok(StreamEvent::ContentBlockDelta { + index: 0, + delta: crate::models::Delta::SignatureDelta { + signature: "sig-omitted-display".to_string(), + }, + }), + Ok(canned::block_stop(0)), + Ok(canned::message_delta(self.stop_reason, None)), + Ok(canned::message_stop()), + ], + ReasoningOnlyShape::RedactedOnly => vec![ + Ok(canned::message_start("reasoning_only_msg")), + Ok(StreamEvent::ContentBlockStart { + index: 0, + content_block: crate::models::ContentBlockStart::RedactedThinking { + data: "ENC_REDACTED_PAYLOAD".to_string(), + }, + }), + Ok(canned::message_delta(self.stop_reason, None)), + Ok(canned::message_stop()), + ], + }; return Ok(Box::pin(futures_util::stream::iter(events))); } let events = canned::simple_text_turn("the recovered answer") @@ -22460,11 +22505,30 @@ async fn run_reasoning_only_turn_with_reprompts( ) -> ( std::sync::Arc, Vec, +) { + run_reasoning_only_turn_with_shape( + ReasoningOnlyShape::Text, + reasoning_only, + stop_reason, + max_reprompts, + ) + .await +} + +async fn run_reasoning_only_turn_with_shape( + shape: ReasoningOnlyShape, + reasoning_only: usize, + stop_reason: &'static str, + max_reprompts: u32, +) -> ( + std::sync::Arc, + Vec, ) { let model = std::sync::Arc::new(ReasoningOnlyCleanFinishModelClient { calls: std::sync::atomic::AtomicUsize::new(0), reasoning_only, stop_reason, + shape, requests: std::sync::Mutex::new(Vec::new()), }); let client: crate::core::model_client::SharedModelClient = model.clone(); @@ -22598,6 +22662,192 @@ async fn reasoning_only_length_stop_fails_without_retry() { assert_eq!(status, TurnOutcomeStatus::Failed); } +/// Signature-only (`display: "omitted"`) and redacted-only responses are the +/// same reasoning-only failure mode as text thinking: real provider reasoning +/// with no sendable content, so a clean stop must ride the same cached-prefix +/// recovery instead of dead-ending the turn. These are exactly the block +/// shapes the preserved-thinking work made first-class. +#[tokio::test] +async fn signature_only_and_redacted_only_reasoning_stops_recover() { + for shape in [ + ReasoningOnlyShape::SignatureOnly, + ReasoningOnlyShape::RedactedOnly, + ] { + let (model, events) = run_reasoning_only_turn_with_shape( + shape, + 1, + "stop", + crate::config::DEFAULT_REASONING_ONLY_REPROMPTS, + ) + .await; + + assert_eq!( + model.calls.load(std::sync::atomic::Ordering::SeqCst), + 2, + "the {shape:?} reasoning-only response must be re-requested exactly once" + ); + let status = events + .iter() + .find_map(|event| match event { + Event::TurnComplete { status, .. } => Some(*status), + _ => None, + }) + .unwrap_or_else(|| panic!("terminal TurnComplete for {shape:?}")); + assert_eq!( + status, + TurnOutcomeStatus::Completed, + "{shape:?} must recover" + ); + assert!( + !events.iter().any(|event| matches!( + event, + Event::Error { envelope, .. } + if envelope.message.contains("no answer or tool call") + )), + "a recovered {shape:?} turn must not surface the incomplete-response error: {events:?}" + ); + } +} + +/// A stream that completed a thinking block (flushed at its stop event) and +/// then died with a transport error has received content: the death must +/// surface its real error instead of silently re-running the request, which +/// would double-bill for the thinking already produced. +struct StreamDeathAfterThinkingClient { + calls: std::sync::atomic::AtomicUsize, +} + +#[async_trait::async_trait] +impl crate::core::model_client::ModelClient for StreamDeathAfterThinkingClient { + fn provider_name(&self) -> &str { + "stream-death" + } + + fn model(&self) -> &str { + "local-model" + } + + async fn create_message( + &self, + _request: crate::models::MessageRequest, + ) -> anyhow::Result { + anyhow::bail!("stream-death recovery uses the streaming model boundary") + } + + async fn create_message_stream( + &self, + _request: crate::models::MessageRequest, + ) -> anyhow::Result { + use crate::llm_client::mock::canned; + self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let events: Vec> = vec![ + Ok(canned::message_start("death_msg")), + Ok(StreamEvent::ContentBlockStart { + index: 0, + content_block: crate::models::ContentBlockStart::Thinking { + thinking: String::new(), + }, + }), + Ok(canned::thinking_delta(0, "received reasoning")), + Ok(StreamEvent::ContentBlockDelta { + index: 0, + delta: crate::models::Delta::SignatureDelta { + signature: "sig-1".to_string(), + }, + }), + Ok(canned::block_stop(0)), + Err(anyhow::anyhow!("transport boom")), + ]; + Ok(Box::pin(futures_util::stream::iter(events))) + } + + async fn health_check(&self) -> anyhow::Result { + Ok(true) + } +} + +#[tokio::test] +async fn stream_death_after_completed_thinking_surfaces_the_error() { + let model = std::sync::Arc::new(StreamDeathAfterThinkingClient { + calls: std::sync::atomic::AtomicUsize::new(0), + }); + let client: crate::core::model_client::SharedModelClient = model.clone(); + let config = Config::default(); + let engine_config = EngineConfig { + max_steps: 1, + snapshots_enabled: false, + subagents_enabled: false, + terminal_chrome_enabled: true, + ..EngineConfig::default() + }; + let (engine, handle) = Engine::new_with_model_client(engine_config, &config, client); + let run_task = tokio::spawn(engine.run()); + handle + .send(Op::SendMessage { + content: "solve the task".to_string(), + mode: AppMode::Agent, + route: resolved_route_for_test(&config, crate::config::DEFAULT_TEXT_MODEL), + compaction: Box::new(CompactionConfig::default()), + goal_objective: None, + goal_token_budget: None, + goal_status: crate::tools::goal::GoalStatus::Active, + reasoning_effort: None, + reasoning_effort_auto: false, + auto_model: false, + allow_shell: false, + trust_mode: false, + auto_approve: false, + approval_mode: crate::tui::approval::ApprovalMode::Suggest, + translation_enabled: false, + allowed_tools: None, + dynamic_tools: Vec::new(), + hook_executor: None, + verbosity: None, + provenance: UserInputProvenance::ExternalUser, + turn_tool_security: None, + submission_id: None, + }) + .await + .expect("send stream-death turn"); + let mut events = Vec::new(); + loop { + let event = tokio::time::timeout(model_turn_event_timeout(), async { + handle.rx_event.write().await.recv().await + }) + .await + .expect("stream-death event timeout") + .expect("stream-death event"); + let terminal = matches!(event, Event::TurnComplete { .. }); + events.push(event); + if terminal { + break; + } + } + handle.send(Op::Shutdown).await.expect("shutdown engine"); + run_task.await.expect("engine task"); + + assert_eq!( + model.calls.load(std::sync::atomic::Ordering::SeqCst), + 1, + "a stream that already delivered a thinking block must not be re-issued" + ); + let status = events + .iter() + .find_map(|event| match event { + Event::TurnComplete { status, .. } => Some(*status), + _ => None, + }) + .expect("terminal TurnComplete"); + assert_eq!(status, TurnOutcomeStatus::Failed); + assert!( + events.iter().any(|event| matches!( + event, + Event::Error { envelope, .. } if envelope.message.contains("transport boom") + )), + "the real transport error must surface: {events:?}" + ); +} + /// The reasoning-only nudge rides one request and is never written to the /// session. /// diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 6d15c82d63..e9fd4ad692 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -2319,10 +2319,16 @@ impl Engine { // appends a synthetic user message to the persisted // conversation — the retried request is the persisted // conversation re-issued, nothing else. + // `completed_thinking` is part of the "something" too: a block + // already flushed at its stop event is received content, and + // silently re-running the request would double-bill for it. A + // stream that died after completing thinking must surface its + // error like any other post-content death. let stream_died_with_nothing = stream_errors > 0 && tool_uses.is_empty() && current_text_visible.trim().is_empty() && current_thinking.trim().is_empty() + && completed_thinking.is_empty() && !pending_message_complete; let pending_resume = match pending_resume { Some(resume) => Some(resume), @@ -2629,9 +2635,16 @@ impl Engine { block, ContentBlock::Thinking { thinking, + signature, state, + redacted_data, + reasoning_details, .. - } if !thinking.trim().is_empty() || state.is_some() + } if !thinking.trim().is_empty() + || signature_carries_content(signature.as_deref()) + || state.is_some() + || redacted_data.is_some() + || reasoning_details.is_some() ) }); From 7d3a93bb0ed680be241e507d8c3df7c5bbaeb96a Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 8 Oct 2026 12:15:29 +0800 Subject: [PATCH 07/12] fix(tui): count every replayed thinking payload in the estimates Two estimators still scored provider-replayed thinking bytes as zero. - The compaction estimator counted signatures, redacted payloads, encrypted state, and reasoning_details on thinking blocks but scored zero for the Google thought_signature that rides every replayed tool call on the exact Google route. - The /config status char estimate claimed that signature-only (display "omitted") blocks count their bytes while the code counted only readable text and redacted payloads - a signature-only block scored exactly zero. It now counts the signature, the encrypted state, and reasoning_details entries like the compaction estimator, and the comment describes what the code does. Both pinned. Signed-off-by: asto18089 --- crates/tui/src/compaction.rs | 42 +++++++++++++++++++++++++++++--- crates/tui/src/utils.rs | 46 ++++++++++++++++++++++++++++++++---- 2 files changed, 81 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index e54a7b1b1c..5355a9744d 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -382,9 +382,20 @@ pub(crate) fn estimate_tokens_for_message(message: &Message, include_thinking: b bytes / 4 } ContentBlock::Thinking { .. } => 0, - ContentBlock::ToolUse { input, .. } => serde_json::to_string(input) - .map(|s| s.len() / 4) - .unwrap_or(100), + ContentBlock::ToolUse { + input, + thought_signature, + .. + } => { + let input_tokens = serde_json::to_string(input) + .map(|s| s.len() / 4) + .unwrap_or(100); + // Google's thought_signature rides every replayed tool call + // on the exact Google route, so it is replay-shaped input + // like the thinking payloads above. + let signature_tokens = thought_signature.as_deref().map_or(0, str::len) / 4; + input_tokens + signature_tokens + } ContentBlock::ToolResult { content, content_blocks, @@ -2863,6 +2874,31 @@ mod tests { ); } + #[test] + fn estimate_tokens_counts_google_thought_signatures_on_tool_calls() { + // The thought_signature replays verbatim on every tool call of the + // exact Google route, so it is replay-shaped input like the thinking + // payloads above and must not score zero. + let signature = "s".repeat(400); + let tool_use = |thought_signature: Option| Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "call-1".to_string(), + name: "read".to_string(), + input: serde_json::json!({}), + caller: None, + thought_signature, + }], + }; + let with_signature = estimate_tokens_for_message(&tool_use(Some(signature.clone())), true); + let bare = estimate_tokens_for_message(&tool_use(None), true); + assert_eq!( + with_signature - bare, + signature.len() / 4, + "signature bytes must count toward the tool-call estimate" + ); + } + #[test] fn should_compact_respects_enabled_flag() { let config = CompactionConfig { diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index 31b3c4e1d3..5d0d3cad0c 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -864,18 +864,33 @@ pub fn estimate_message_chars(messages: &[Message]) -> usize { for block in &msg.content { match block { ContentBlock::Text { text, .. } => total += text.len(), - // Signature-only (`display: "omitted"`) and redacted blocks - // still ride the wire on replay, so their bytes count even - // when the readable text is empty. + // Every payload a thinking block can carry rides the wire + // under at least one provider's replay contract (Anthropic + // signature/redacted payload, Responses encrypted state, + // OpenRouter reasoning_details), so all of it counts even + // when the readable text is empty — a signature-only + // (`display: "omitted"`) block is exactly that shape. ContentBlock::Thinking { thinking, + signature, + state, redacted_data, - .. + reasoning_details, } => { total += thinking.len(); + total += signature.as_deref().map_or(0, str::len); if let Some(data) = redacted_data { total += data.len(); } + if let Some(state) = state { + total += state.encrypted_content.len(); + } + if let Some(details) = reasoning_details { + total += details + .iter() + .map(|entry| entry.to_string().len()) + .sum::(); + } } ContentBlock::ToolUse { input, .. } => { let mut cw = CountingWriter::new(); @@ -925,6 +940,29 @@ mod tests { assert_eq!(redacted_identifier_for_log(""), ""); } + #[test] + fn estimate_message_chars_counts_signature_only_thinking_blocks() { + use super::estimate_message_chars; + use crate::models::{ContentBlock, Message, Role}; + // A signature-only (`display: "omitted"`) block rides the wire with + // empty readable text; its signature bytes must still count, exactly + // as the compaction estimator counts them. + let message = Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: String::new(), + signature: Some("SIG-BYTES".to_string()), + state: None, + redacted_data: None, + reasoning_details: None, + }], + }; + assert!( + estimate_message_chars(std::slice::from_ref(&message)) >= "SIG-BYTES".len(), + "signature-only blocks must not score zero" + ); + } + #[test] fn display_path_contracts_home_prefix() { let h = home("/Users/alice"); From 900d0991ecc4c2d2335ab750fedeb1b24ec88a22 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 8 Oct 2026 12:15:41 +0800 Subject: [PATCH 08/12] test(tui): pin the Responses capture route lockstep store:false, include, and encrypted-reasoning capture must stay the same route set; the body builder now reads that set from the shared route_captures_encrypted_reasoning predicate instead of restating it inline, and a lockstep test walks the route variants (DeepSeek-CN spelled out beside DeepSeek and Concentrate) asserting the wire and the predicate agree. Also: document at provider_identity_for that a custom provider whose table key exactly names a built-in slug deliberately shadows the built-in route and inherits its identity, and cite the sources for every entry of the adaptive-on-omission model list (Haiku 5.5 adaptive by default and the Opus 4.8 phrasing, per the current AWS Bedrock adaptive-thinking page and the anthropics/skills model-migration guide). Signed-off-by: asto18089 --- crates/tui/src/client/anthropic.rs | 15 +++++---- crates/tui/src/client/responses.rs | 7 +++- crates/tui/src/client/responses/tests.rs | 42 ++++++++++++++++++++++++ crates/tui/src/config.rs | 9 +++++ 4 files changed, 66 insertions(+), 7 deletions(-) diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 58d2a98fe4..378304fa38 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -524,15 +524,18 @@ fn normalize_redacted_thinking_blocks(value: &mut Value) { /// /// The docs are explicit that adaptive thinking is on by default only for the /// Claude 5 generation ("Adaptive thinking is on by default on Claude Sonnet 5 -/// and Claude Opus 5" — AWS Bedrock adaptive-thinking page) and that the -/// Fable/Mythos lines are adaptive-only, so omitting `thinking` is NOT +/// and Claude Opus 5" — AWS Bedrock adaptive-thinking page; same page: +/// "Claude Haiku 5.5 uses adaptive thinking by default", so the Haiku 5.x +/// line belongs here too) and that the Fable/Mythos lines are adaptive-only +/// (explicit disabled thinking 400s there), so omitting `thinking` is NOT /// "disabled" there and replayed signed thinking blocks stay valid. Older /// models treat omission as thinking off — the migration guide states /// "Adaptive thinking is off by default on Claude Opus 4.7: requests with no -/// thinking field run without thinking, matching Opus 4.6 behavior" — and the -/// API rejects thinking content in the current tool-use turn once thinking is -/// disabled, so those turns (Opus 4.7/4.8 included) need their thinking -/// blocks stripped. +/// thinking field run without thinking, matching Opus 4.6 behavior" and, for +/// the successor, "like Opus 4.7, a request with no `thinking` field runs +/// without thinking" — and the API rejects thinking content in the current +/// tool-use turn once thinking is disabled, so those turns (Opus 4.7/4.8 +/// included) need their thinking blocks stripped. fn anthropic_omitted_thinking_defaults_to_adaptive(model: &str) -> bool { let model = model.to_ascii_lowercase(); model.contains("fable") diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 51add90c07..4571a781b8 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -118,7 +118,12 @@ pub(super) fn build_responses_body_for_provider( "model": model, "stream": true, }); - if !is_deepseek && !is_concentrate { + // One shared route set decides `store:false`, `include`, and capture — + // see `responses_route_sends_encrypted_reasoning_include`. Keeping all + // three on the same predicate is what makes capture symmetric with the + // request. + let captures_reasoning = responses_route_sends_encrypted_reasoning_include(provider); + if captures_reasoning { body["store"] = json!(false); } // Every Responses route receives the same resolved request envelope as diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index c6672d3c0c..f05d90d8aa 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -1400,6 +1400,48 @@ fn responses_input_includes_user_role_tool_results() { assert_eq!(input[2]["output"], "<6 items>"); } +#[test] +fn responses_include_and_store_stay_in_lockstep_with_the_capture_set() { + // `store:false`, `include`, and encrypted-reasoning capture must all key + // off the same route set (`responses_route_sends_encrypted_reasoning_include`): a route + // that requests the include but cannot capture ships reasoning items it + // never stores, and a route that captures without requesting the include + // loses them. The DeepSeek-CN arm is spelled out because it is the + // sibling route most easily forgotten when the set is edited. + for (provider, captures) in [ + (ApiProvider::OpenaiCodex, true), + (ApiProvider::OpencodeZen, true), + (ApiProvider::Custom, true), + (ApiProvider::Deepseek, false), + (ApiProvider::DeepseekCN, false), + (ApiProvider::Concentrate, false), + ] { + let request = minimal_responses_request(); + let body = build_responses_body_for_provider( + &request, + provider, + provider.as_str(), + "fp-lockstep-endpoint", + BODY_TEST_BASE_URL, + ); + assert_eq!( + body.get("include").is_some(), + captures, + "include lockstep failed for {provider:?}: {body}" + ); + assert_eq!( + body.get("store").is_some(), + captures, + "store lockstep failed for {provider:?}: {body}" + ); + assert_eq!( + responses_route_sends_encrypted_reasoning_include(provider), + captures, + "the capture predicate must agree with the wire for {provider:?}" + ); + } +} + #[test] fn concentrate_route_keeps_tool_chains() { // The Concentrate route never captures encrypted reasoning (no diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 3c6ea26876..79500d310a 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -5129,6 +5129,15 @@ impl Config { } /// Return the exact non-secret key for an active provider route. + /// + /// A custom provider whose table key exactly names a built-in slug + /// (e.g. `openai-codex`) deliberately shadows that built-in route and + /// therefore also inherits its identity: payloads stay replay-compatible + /// with a plain endpoint mirror of the built-in API. Pointing the same + /// key at a gateway with different payload semantics is a + /// misconfiguration this key cannot detect — encrypted reasoning + /// captured on the built-in route would then be replayed to the shadow + /// and rejected there. Distinct gateways must use distinct keys. #[must_use] pub(crate) fn provider_identity_for(&self, provider: ApiProvider) -> String { if provider == ApiProvider::Custom From 0c2d0dd3fa1340cb0449a79a2d857538fb554ed2 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 8 Oct 2026 12:51:02 +0800 Subject: [PATCH 09/12] fix(tui): merge streamed OpenRouter reasoning details incrementally The buffer treated each chunk's reasoning_details array as a cumulative snapshot and stored the last version per key. OpenRouter's documented streaming contract is the opposite: "each reasoning detail chunk is sent as it becomes available" and "the complete reasoning sequence is built by concatenating all chunks in order" - so two non-cumulative text fragments for one index ("al" then "pha") stored only "pha", truncating the replayed sequence the API requires unmodified and breaking tool continuity on exactly the routes this series preserves. Fragments for a known key now merge into the stored entry: text continues the stored text, unless the fragment extends it as a prefix - a cumulative re-send from a gateway that repeats the growing array, handled the same way as the plaintext reasoning path - and fields the fragment carries that the entry still lacks (a signature trailing the text, an id, a format) are filled in. The old cumulative fixture, the index-less position keying, and the new non-cumulative concatenation case are all pinned; the concatenation pin was reviewed in by an external reviewer. Signed-off-by: asto18089 --- crates/tui/src/client/chat.rs | 77 ++++++++++++++++--- .../src/client/chat/tests/stream_decoder.rs | 55 +++++++++++++ 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 4082fa9e3c..f8cf79e354 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -3787,14 +3787,67 @@ fn replayable_reasoning_details(details: &[Value]) -> Vec { .collect() } +/// Merge one streamed fragment into its stored entry, per OpenRouter's +/// documented incremental contract ("each reasoning detail chunk is sent as +/// it becomes available"; "the complete reasoning sequence is built by +/// concatenating all chunks in order"). Fragment text continues the stored +/// text — last-wins would truncate a replayed sequence the API requires +/// unmodified. A fragment that instead extends the stored text as a prefix +/// is a cumulative re-send and replaces it, matching the plaintext +/// `reasoning` path's prefix handling. Any other field the fragment carries +/// that the entry still lacks (a signature trailing the text, an id, a +/// format) is filled in. +fn merge_reasoning_detail_entry(stored: &mut Value, fragment: &Value) -> bool { + let mut changed = false; + if let Some(incoming) = fragment.get("text").and_then(Value::as_str) + && !incoming.is_empty() + { + let current = stored.get("text").and_then(Value::as_str).unwrap_or(""); + if incoming.starts_with(current) { + if incoming.len() != current.len() { + stored["text"] = Value::String(incoming.to_string()); + changed = true; + } + } else { + let mut merged = String::with_capacity(current.len() + incoming.len()); + merged.push_str(current); + merged.push_str(incoming); + stored["text"] = Value::String(merged); + changed = true; + } + } + if let (Some(fields), Some(incoming_fields)) = (stored.as_object_mut(), fragment.as_object()) { + for (field, value) in incoming_fields { + if field == "text" { + continue; + } + let lacks = match fields.get(field) { + None | Some(Value::Null) => true, + Some(Value::String(text)) => text.is_empty(), + _ => false, + }; + if lacks { + fields.insert(field.clone(), value.clone()); + changed = true; + } + } + } + changed +} + /// Accumulates OpenRouter `reasoning_details` entries across stream chunks. -/// OpenRouter repeats the growing array on successive chunks (the same -/// cumulative-snapshot shape MiniMax uses), so entries are keyed by `index` -/// when present, else by their position in the incoming array — stable under -/// the cumulative-snapshot protocol, where a `type` key would collide (two -/// same-type entries in one array would merge and lose one) and a running -/// position counter would re-key every reappearance of an entry. The last -/// version of each key wins. +/// OpenRouter's documented streaming contract is incremental — "each +/// reasoning detail chunk is sent as it becomes available", "the complete +/// reasoning sequence is built by concatenating all chunks in order" — so a +/// fragment for a known key (the entry's `index`, or its position in the +/// incoming array when the entry carries none; a `type` key would collide +/// two same-type entries into one) is merged into the stored entry rather +/// than replacing it. Fragment text continues the stored text unless it +/// extends that text as a prefix, which is treated as a cumulative re-send +/// from a gateway that repeats the growing array (the same dual handling as +/// the plaintext `reasoning` path), and fields the fragment carries that the +/// entry still lacks — a signature trailing the text, an id, a format — are +/// filled in. #[derive(Default)] pub(super) struct ReasoningDetailsBuffer { order: Vec, @@ -3816,17 +3869,19 @@ impl ReasoningDetailsBuffer { .unwrap_or_else(|| format!("_arr{position}")); if !self.order.contains(&key) { self.order.push(key.clone()); + self.entries.insert(key, entry); changed = true; - } - if self.entries.get(&key) != Some(&entry) { + } else if merge_reasoning_detail_entry( + self.entries.get_mut(&key).expect("key is present in order"), + &entry, + ) { changed = true; } - self.entries.insert(key, entry); } changed } - /// Entries in first-seen order, most complete version of each. + /// Entries in first-seen order, merged to their most complete version. fn snapshot(&self) -> Vec { self.order .iter() diff --git a/crates/tui/src/client/chat/tests/stream_decoder.rs b/crates/tui/src/client/chat/tests/stream_decoder.rs index 8c9344621a..7a03dcf706 100644 --- a/crates/tui/src/client/chat/tests/stream_decoder.rs +++ b/crates/tui/src/client/chat/tests/stream_decoder.rs @@ -1655,6 +1655,61 @@ fn openrouter_details_without_index_key_by_array_position() { ); } +#[test] +fn openrouter_details_fragments_concatenate_per_index() { + // OpenRouter's documented streaming contract: each detail chunk is sent + // as it becomes available and the complete sequence is built by + // concatenating chunks in order. Two non-cumulative fragments for one + // index must join ("al" + "pha" = "alpha") — a last-wins store would + // replay only "pha", truncating a sequence the API requires unmodified. + // A trailing signature-only fragment (Anthropic-style) fills the entry's + // signature instead of being dropped. + let chunks = vec![ + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "index": 0, "text": "al" } + ] + } + }] + }), + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "index": 0, "text": "pha" } + ] + } + }] + }), + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "index": 0, "text": "", "signature": "SIG-tail" } + ] + } + }] + }), + ]; + let events = decode_chunks_capturing_details(&chunks, true); + let snapshots = details_delta_payloads(&events); + let last = snapshots.last().expect("final snapshot"); + assert_eq!(last.len(), 1, "one entry across all fragments"); + assert_eq!( + last[0]["text"], "alpha", + "non-cumulative fragments must concatenate in arrival order" + ); + assert_eq!( + last[0]["signature"], "SIG-tail", + "a trailing signature must fill the entry" + ); +} + #[test] fn non_openrouter_routes_never_emit_details_deltas() { let chunks = vec![serde_json::json!({ From 0477cf79d050c4700816d8fbea5241a13323b128 Mon Sep 17 00:00:00 2001 From: asto Date: Thu, 8 Oct 2026 16:09:42 +0800 Subject: [PATCH 10/12] fix(tui): close the review round on doc conformance and replay guards The fresh 13-lane review of the preserved-thinking series verified every claim against live provider docs (2026-10-08) and found no functional or performance bugs and no smuggled changes; this commit closes its must-fix and should-fix findings. - Anthropic: the disabled-strip rationale claimed the API "rejects" thinking content in the current tool-use turn with a 400. Current docs say the opposite: mid-turn conflicts degrade gracefully ("the API doesn't error. Instead, it silently disables thinking... the thinking content is stripped"). The strip stays - it mirrors the documented server-side behavior before a strict gateway can see the conflict, and keeps the older prefix byte-stable for cache - but the comments and test pins now cite the graceful strip, not a deterministic rejection. The redacted-withholding rationale is likewise reworded: platform-bound decryption of `data` is not a documented fact, gateway compatibility is the actual conservative reason. - Anthropic: the redacted withhold could emit an assistant message with an empty `content: []` on non-native hosts when a persisted turn carried only redacted blocks (reachable from foreign/imported sessions); such messages are now dropped - they carry no `tool_use`, so no `tool_result` can dangle. Pinned for both directions. - Gemini: the fail-closed signature validator now treats an empty `thought_signature` as missing, matching the capture-side filter; sessions saved before the empty-string filter would otherwise pass validation and fail at Google with a raw 400 instead of the actionable diagnostic. Pinned. - Estimators: `estimate_message_chars` now counts Google `thought_signature` bytes on tool calls like the token estimator already did, and the engine's active-input parity term uses the same every-message rule as the estimator base, restoring the exactness its 80k-pair pin claims; the orphaned `message_has_tool_use` helper is removed. Char-side counting pinned. - Tests: the start-arm flush guard (thinking start without an intervening stop) is now driven end-to-end by a stop-less-first-block stream, and the new `stream_event_has_actionable_content` arms are pinned directly; the Responses pairing comments now cite the docs' actual completeness-and-positions rule instead of an undocumented "immediately" adjacency. 4 new tests. Full lib suite green; fmt clean; no new clippy lints. Signed-off-by: asto18089 --- crates/tui/src/client/anthropic.rs | 148 +++++++++++++++++++----- crates/tui/src/client/chat.rs | 85 +++++++++++++- crates/tui/src/client/responses.rs | 34 +++--- crates/tui/src/compaction.rs | 7 -- crates/tui/src/core/engine.rs | 10 +- crates/tui/src/core/engine/tests.rs | 58 +++++++--- crates/tui/src/core/engine/turn_loop.rs | 44 +++++++ crates/tui/src/utils.rs | 37 +++++- 8 files changed, 348 insertions(+), 75 deletions(-) diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index 378304fa38..fb8bf7d150 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -159,9 +159,12 @@ impl DeepSeekClient { .map(|raw| raw.trim().to_ascii_lowercase()); // True when the wire carries thinking disabled (explicit // `{"type":"disabled"}`, or omitted on a model that treats omission - // as off). Anthropic rejects thinking content in the current - // tool-use turn in that state, so the final tool turn gets its - // thinking blocks stripped below. + // as off). The first-party API degrades that conflict gracefully — + // it silently strips the current turn's thinking and keeps thinking + // off for the request, without an error — so stripping below mirrors + // the documented server-side behavior, keeps the older prefix + // byte-stable for cache, and defends gateways that may reject the + // conflict outright instead of degrading. let mut thinking_disabled_on_wire = false; match effort.as_deref() { _ if is_minimax_provider && !is_minimax => {} @@ -206,8 +209,10 @@ impl DeepSeekClient { // thinking is off on the wire — explicit off above, a budget that // could not be formed (max_tokens too small), or a non-thinking // model. On models where omission means adaptive-on the blocks stay; - // everywhere else the current tool turn must lose them or the API - // rejects the request outright. + // everywhere else the current tool turn is stripped so the wire + // matches what a request that never carried thinking would look + // like: the API strips that thinking server-side anyway, and strict + // gateways may reject it instead of degrading. if body.get("thinking").is_none() && !anthropic_omitted_thinking_defaults_to_adaptive(&model) { @@ -218,24 +223,37 @@ impl DeepSeekClient { { strip_thinking_from_final_tool_turn(messages); } - // Redacted thinking payloads are encrypted by Anthropic's first-party - // API and decrypt server-side there, so pass-through hosts cannot - // validate them — but neither can we tell a terminating gateway from - // a forwarding proxy. Withholding on every non-native host is the - // conservative call: a terminating gateway would reject the unknown - // block type outright, while a forwarding proxy loses the block's - // continuity (a redacted-only current tool turn can still 400 there). - // Native `*.anthropic.com` routes keep the payload byte-exact. + // Anthropic documents `redacted_thinking.data` as opaque encrypted + // content to pass back unchanged and says nothing about third-party + // gateways understanding the block type, and we cannot tell a + // terminating gateway from a forwarding proxy to the first-party + // API (which would handle the block). Withholding on every + // non-native host is the conservative call: a strict terminating + // gateway would reject the unknown block type outright, while a + // forwarding proxy only loses the block's continuity (a + // redacted-only current tool turn can still 400 there). Native + // `*.anthropic.com` routes keep the payload byte-exact. if !is_native_anthropic_base_url(&self.base_url) && let Some(messages) = body.get_mut("messages").and_then(Value::as_array_mut) { - for message in messages.iter_mut() { - if let Some(blocks) = message.get_mut("content").and_then(Value::as_array_mut) { - blocks.retain(|block| { - block.get("type").and_then(Value::as_str) != Some("redacted_thinking") - }); - } - } + messages.retain_mut(|message| { + let is_assistant = message.get("role").and_then(Value::as_str) == Some("assistant"); + let Some(blocks) = message.get_mut("content").and_then(Value::as_array_mut) else { + return true; + }; + blocks.retain(|block| { + block.get("type").and_then(Value::as_str) != Some("redacted_thinking") + }); + // A message that only carried redacted blocks would + // otherwise go out with an empty `content: []`, which + // Messages-compatible endpoints reject — the same + // every-retry 400 this withhold exists to prevent. Dropping + // the message is safe: a redacted-only assistant turn + // carries no `tool_use`, so no `tool_result` can dangle. + // Reachable only from foreign/imported sessions; the live + // engine never persists a reasoning-only turn. + !(is_assistant && blocks.is_empty()) + }); } // Sampling parameters: Claude 4.7+ rejects temperature/top_p @@ -533,9 +551,11 @@ fn normalize_redacted_thinking_blocks(value: &mut Value) { /// "Adaptive thinking is off by default on Claude Opus 4.7: requests with no /// thinking field run without thinking, matching Opus 4.6 behavior" and, for /// the successor, "like Opus 4.7, a request with no `thinking` field runs -/// without thinking" — and the API rejects thinking content in the current -/// tool-use turn once thinking is disabled, so those turns (Opus 4.7/4.8 -/// included) need their thinking blocks stripped. +/// without thinking" — and on those turns replayed thinking in the current +/// tool-use turn is thinking-after-disable: the first-party API strips it +/// server-side without an error, and strict gateways may reject it instead, +/// so those turns (Opus 4.7/4.8 included) shed the blocks here and the wire +/// stays byte-identical to a request that never carried them. fn anthropic_omitted_thinking_defaults_to_adaptive(model: &str) -> bool { let model = model.to_ascii_lowercase(); model.contains("fable") @@ -547,10 +567,14 @@ fn anthropic_omitted_thinking_defaults_to_adaptive(model: &str) -> bool { /// Remove thinking blocks from the final assistant message of a tool turn. /// -/// Anthropic fails the request when thinking content appears in the current -/// tool-use turn while thinking is disabled; in every other position -/// replayed thinking content is simply ignored, so only the tool turn is -/// rewritten and cache-relevant history bytes stay untouched. +/// With thinking disabled on the wire, thinking content in the current +/// tool-use turn is a conflict: the first-party API silently strips it and +/// keeps thinking off (documented graceful degradation, no error), while +/// strict gateways may reject the request outright. Stripping here yields +/// the same wire as the server-side strip would, before a gateway can see +/// it. In every other position replayed thinking content is simply ignored, +/// so only the tool turn is rewritten and cache-relevant history bytes stay +/// untouched. fn strip_thinking_from_final_tool_turn(messages: &mut [Value]) { let Some(last_assistant) = messages .iter_mut() @@ -2097,7 +2121,10 @@ mod tests { // behavior" — model migration guide), so omission there must strip // the current tool turn exactly like an explicit off. These models // previously sat on the adaptive-on-omission list and the strip - // never fired, bricking the tool loop with a deterministic 400. + // never fired, so replayed signed thinking rode into a request whose + // wire runs without thinking — the first-party API silently strips + // it (thinking-after-disable degrades gracefully), but strict + // gateways may reject the conflict outright. for model in ["claude-opus-4-7", "claude-opus-4-8"] { let client = test_client(); let mut request = request_with(model, Some("off"), None, None); @@ -2175,8 +2202,9 @@ mod tests { #[test] fn non_native_gateways_never_receive_redacted_thinking_blocks() { - // Redacted payloads can only be decrypted by the platform that minted - // them; a Messages-compatible gateway would reject the unknown type. + // Conservative withholding: the docs promise nothing about + // third-party gateways understanding `redacted_thinking`, and a + // strict Messages-compatible gateway would reject the unknown type. let client = anthropic_test_client(Some("https://gateway.example.com/anthropic")); let mut request = request_with("claude-sonnet-4-6", None, None, None); request.messages = vec![Message { @@ -2216,6 +2244,66 @@ mod tests { ); } + #[test] + fn gateway_withhold_drops_messages_that_only_carried_redacted_blocks() { + // A redacted-only assistant message (reachable from foreign or + // imported sessions; the live engine never persists a reasoning-only + // turn) must not go out as `content: []` after the withhold — that + // is exactly the every-retry 400 this withhold exists to prevent. + // Dropping the message is safe: no `tool_use` means no dangling + // `tool_result`. + let client = anthropic_test_client(Some("https://gateway.example.com/anthropic")); + let mut request = request_with("claude-sonnet-4-6", None, None, None); + request.messages = vec![ + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "do the thing".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::Thinking { + thinking: String::new(), + signature: None, + state: None, + redacted_data: Some("ENC_REDACTED_ONLY".to_string()), + reasoning_details: None, + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "continue".to_string(), + cache_control: None, + }], + }, + ]; + + let body = client.build_anthropic_body(&request, true); + let messages = body["messages"].as_array().expect("messages array"); + assert_eq!( + messages.len(), + 2, + "the redacted-only assistant message must be dropped, not emptied: {body}" + ); + assert_eq!(messages[0]["role"].as_str(), Some("user")); + assert_eq!(messages[1]["role"].as_str(), Some("user")); + assert!( + !body.to_string().contains("ENC_REDACTED_ONLY"), + "the payload must stay withheld: {body}" + ); + + // First-party Anthropic keeps the message and its block. + let native = test_client().build_anthropic_body(&request, true); + assert_eq!( + native["messages"].as_array().map(Vec::len), + Some(3), + "native routes keep the redacted-only turn: {native}" + ); + } + #[test] fn non_stream_redacted_thinking_blocks_decode_into_redacted_data() { let payload = json!({ diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index f8cf79e354..28cdc8ba70 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -710,9 +710,14 @@ fn validate_google_thought_signature_replay( let Some(first_call) = tool_calls.first() else { continue; }; + // An empty string is no signature: capture filters it since this + // series, but sessions saved by older builds can still carry + // `Some("")` — treat it as missing so the replay fails here with + // the diagnostic instead of at Google with a raw 400. let missing = first_call .pointer("/extra_content/google/thought_signature") .and_then(Value::as_str) + .filter(|signature| !signature.is_empty()) .is_none(); if missing { let id = first_call.get("id").and_then(Value::as_str).unwrap_or("?"); @@ -3640,8 +3645,11 @@ fn reasoning_stream_style_for_route( // Google's official OpenAI-compat route returns reasoning summaries in // `delta.reasoning_content` (the non-streaming decoder already promotes // the same field to a Thinking block), so stream parity applies here. - // Scoping to the exact official host keeps the generic-proxy fallback — - // render reasoning_content as answer text — for unknown gateways. + // Not spelled out in Google's current official pages — best-effort + // interpretation of an, if present, `reasoning_content` field, so the + // risk is only how a summary renders, never what is sent. Scoping to + // the exact official host keeps the generic-proxy fallback — render + // reasoning_content as answer text — for unknown gateways. if is_exact_google_chat_route(provider, base_url) { return ReasoningStreamStyle::SeparateField; } @@ -4488,7 +4496,11 @@ fn parse_sse_chunk_with_reasoning_style( // call); capture it instead of dropping it with // the rest of the non-start fields. The official // compat route also signs the chunk-level - // `delta.extra_content` on the pre-tool text part. + // `delta.extra_content` on the pre-tool text part + // (observed shapes; Google's current docs no + // longer spell out the streaming placement — + // their thought-signature page is a stub — so + // treat both forms as best-effort). // Known limit: that chunk-level form is only read // when the same delta also carries the `tool_calls` // entry. A signature emitted exclusively on an @@ -8796,6 +8808,73 @@ mod google_thought_signature_tests { ); } + #[test] + fn google_legacy_empty_signature_is_treated_as_missing() { + // Capture has filtered empty-string signatures since the + // preserved-thinking series, but sessions saved by older builds can + // still carry `Some("")` on the first call of the current turn. + // Validation must treat that as missing so the turn fails with the + // actionable diagnostic instead of at Google with a raw 400. + let request = MessageRequest { + model: "gemini-3.1-pro-preview".to_string(), + messages: vec![ + Message { + role: Role::User, + content: vec![ContentBlock::Text { + text: "Read it.".to_string(), + cache_control: None, + }], + }, + Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "call-g-empty".to_string(), + name: "read".to_string(), + input: json!({"path": "a.toml"}), + caller: None, + thought_signature: Some(String::new()), + }], + }, + Message { + role: Role::User, + content: vec![ContentBlock::ToolResult { + tool_use_id: "call-g-empty".to_string(), + content: "a".to_string(), + is_error: None, + content_blocks: None, + }], + }, + ], + max_tokens: 64, + system: None, + tools: None, + tool_choice: None, + metadata: None, + thinking: None, + reasoning_effort: Some("high".to_string()), + stream: None, + temperature: None, + top_p: None, + }; + + let error = match build_chat_wire_body( + &request, + ApiProvider::Google, + DEFAULT_GOOGLE_BASE_URL, + false, + ) { + Ok(body) => panic!( + "an empty-string signature is no signature; body built: {}", + body.body + ), + Err(error) => error, + }; + assert!( + error.to_string().contains("call-g-empty"), + "the diagnostic must name the unsigned call: {error}" + ); + } + #[test] fn google_older_turns_without_signatures_are_not_validated() { // Google enforces signatures only on the current turn; a session diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 4571a781b8..16bb4af8c0 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -874,14 +874,17 @@ fn message_has_replayable_reasoning( /// /// `reasoning_provider_tag` scopes opaque-reasoning replay to the endpoint /// that minted the state; see [`build_responses_body_for_provider`]. -/// -/// Pairing integrity: with `store:false`, every `function_call` item must be -/// immediately preceded by the encrypted `reasoning` item that produced it, -/// and every `reasoning` item must be immediately followed by its produced -/// item. When a message's reasoning is unusable on this route (captured on a -/// different model, provider identity, or by a build that did not capture -/// it), its whole tool chain — reasoning, function calls, and their outputs — -/// is dropped instead of shipping a request the API rejects outright. +/// Pairing integrity: with `store:false`, a `function_call` item needs the +/// encrypted `reasoning` item that produced it replayed ahead of it, and a +/// `reasoning` item needs the item(s) it produced to follow it — the docs' +/// rule is completeness plus original positions ("all items between the last +/// user message and your function call output are passed into the next +/// response untouched"), and the API rejects a reasoning item whose +/// following item is missing. When a message's reasoning is unusable on +/// this route (captured on a different model, endpoint tag or fingerprint, +/// or by a build that did not capture it), its whole tool chain — reasoning, +/// function calls, and their outputs — is dropped instead of shipping a +/// request the API rejects outright. pub(super) fn convert_messages_to_responses_input( request: &MessageRequest, provider: ApiProvider, @@ -1174,17 +1177,18 @@ pub(super) fn convert_messages_to_responses_input( // until compaction retires the turns, and per-message warns would // spam verbose logs for a single root cause. logging::warn(format!( - "Responses replay dropped {dropped_chains} assistant tool chain(s) whose encrypted reasoning is unavailable for this route/model/identity (captured on a different model, provider identity, or by an older build); their tool calls and outputs are omitted to keep the request valid" + "Responses replay dropped {dropped_chains} assistant tool chain(s) whose encrypted reasoning is unavailable for this route/model/endpoint (captured on a different model, endpoint tag or fingerprint, or by an older build); their tool calls and outputs are omitted to keep the request valid" )); } if !is_deepseek { - // A `reasoning` item must be immediately followed by the item it - // produced (its function_call, or the assistant message for text - // output). An orphaned reasoning item — in practice a turn that was - // interrupted before producing any output — makes the whole request - // fail with "was provided without its required following item", so - // it is dropped here. + // A `reasoning` item needs the item it produced to follow it (its + // function_call, or the assistant message for text output) — the + // pairing validator rejects a reasoning item whose following item is + // missing ("was provided without its required following item"). An + // orphaned reasoning item — in practice a turn that was interrupted + // before producing any output — is dropped here rather than shipped + // to fail the whole request. let mut keep = vec![true; items.len()]; let mut dropped_orphans: usize = 0; for (index, item) in items.iter().enumerate() { diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 5355a9744d..c2e2b7929c 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -443,13 +443,6 @@ pub fn estimate_tokens(messages: &[Message]) -> usize { .sum() } -pub(crate) fn message_has_tool_use(message: &Message) -> bool { - message - .content - .iter() - .any(|block| matches!(block, ContentBlock::ToolUse { .. })) -} - pub(crate) fn estimate_text_tokens_conservative(text: &str) -> usize { text.chars().count().div_ceil(3) } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 1b20f1c586..58ec106aee 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -3950,16 +3950,14 @@ impl Engine { // ceil((S+own)*3/2) − ceil(S*3/2) = floor(own*3/2) + 1 exactly when // S is even and own is odd; pinned exhaustively (80k pairs) and per // case by `context_pressure_delta_matches_clone_and_push_reference`. + // `true` must mirror the estimator's own rule (every message's + // thinking payloads count since preserved-thinking replay); the old + // tool-call-only gate would make S's parity drift from base's rule. let sum: usize = self .session .messages .iter() - .map(|m| { - crate::compaction::estimate_tokens_for_message( - m, - crate::compaction::message_has_tool_use(m), - ) - }) + .map(|m| crate::compaction::estimate_tokens_for_message(m, true)) .sum(); let own = current_text.len() / 4; let mut inflated_delta = own * 3 / 2; diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index d798e70a73..e04f1506d0 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -14972,14 +14972,23 @@ fn sandbox_policy_for_turn_returns_correct_default_policy_per_mode() { } /// Serves one multi-block thinking + tool-use turn, then a plain closing -/// turn, capturing every outgoing request. `trailing_signature_only` swaps -/// the first stream for one where the signature-only block never receives a -/// stop event and only the tool use follows it — the success-path persist -/// guard is then the only thing that can keep the block. +/// turn, capturing every outgoing request. The shape swaps the first stream: +/// `TrailingSignatureOnly` never sends the signature-only block's stop event +/// and only the tool use follows it — the success-path persist guard is then +/// the only thing that can keep the block; `StoplessFirstBlock` drops the +/// FIRST block's stop event, so the second thinking block's start is the +/// only thing that can flush it (the start-arm flush guard). struct MultiThinkingStreamClient { requests: std::sync::Mutex>, calls: std::sync::atomic::AtomicUsize, - trailing_signature_only: bool, + shape: MultiThinkingStreamShape, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum MultiThinkingStreamShape { + Full, + TrailingSignatureOnly, + StoplessFirstBlock, } #[async_trait::async_trait] @@ -15012,8 +15021,8 @@ impl crate::core::model_client::ModelClient for MultiThinkingStreamClient { .expect("multi-thinking request lock") .push(request); use crate::models::{ContentBlockStart, Delta, MessageDelta, StreamEvent}; - let trailing = self.trailing_signature_only; - let events: Vec = if trailing && call == 1 { + use MultiThinkingStreamShape as Shape; + let events: Vec = if call == 1 && self.shape == Shape::TrailingSignatureOnly { // One signature-only thinking block whose stop event never // arrives, directly followed by the tool use: no flush runs // before the stream ends, so only the success-path persist @@ -15137,6 +15146,18 @@ impl crate::core::model_client::ModelClient for MultiThinkingStreamClient { } crate::llm_client::mock::canned::simple_text_turn("done") }; + // The stop-less-first-block shape deletes ONLY the first thinking + // block's stop event: the second thinking block's start event is + // then the only thing that can flush the first block, pinning the + // start-arm flush guard. + let events: Vec = if self.shape == Shape::StoplessFirstBlock { + events + .into_iter() + .filter(|event| !matches!(event, StreamEvent::ContentBlockStop { index: 0 })) + .collect() + } else { + events + }; let events = events.into_iter().map(Ok); Ok(Box::pin(futures_util::stream::iter(events))) } @@ -15152,7 +15173,7 @@ impl crate::core::model_client::ModelClient for MultiThinkingStreamClient { /// the follow-up request. #[tokio::test] async fn multi_block_and_redacted_thinking_persist_into_followup_requests() { - let requests = run_multi_thinking_turn(false).await; + let requests = run_multi_thinking_turn(MultiThinkingStreamShape::Full).await; assert_followup_carries_full_thinking_sequence(&requests); } @@ -15163,7 +15184,7 @@ async fn multi_block_and_redacted_thinking_persist_into_followup_requests() { /// follow-up request and Anthropic would 400 on the bare `tool_use` replay. #[tokio::test] async fn signature_only_block_survives_a_stream_that_skips_its_stop_event() { - let requests = run_multi_thinking_turn(true).await; + let requests = run_multi_thinking_turn(MultiThinkingStreamShape::TrailingSignatureOnly).await; assert!(requests.len() >= 2, "expected a follow-up request"); let followup = &requests[1]; let assistant = followup @@ -15196,12 +15217,23 @@ async fn signature_only_block_survives_a_stream_that_skips_its_stop_event() { } } +/// The start-arm flush guard: a thinking block whose stop event never +/// arrives must still be parked when the NEXT thinking block starts, ahead +/// of the new block. Without that flush, the first block's deltas would be +/// silently overwritten by the second block and the replayed sequence would +/// be truncated. +#[tokio::test] +async fn stopless_first_block_survives_the_next_thinking_start() { + let requests = run_multi_thinking_turn(MultiThinkingStreamShape::StoplessFirstBlock).await; + assert_followup_carries_full_thinking_sequence(&requests); +} + /// Drive a two-request turn whose first stream carries the full thinking /// sequence (readable+signed, signature-only, redacted, tool use), capturing -/// every outgoing request. `trailing_signature_only` swaps the first stream -/// for the stop-less trailing-block shape. +/// every outgoing request. `shape` swaps the first stream per the client's +/// contract. async fn run_multi_thinking_turn( - trailing_signature_only: bool, + shape: MultiThinkingStreamShape, ) -> Vec { let config = Config { provider: Some("custom-a".to_string()), @@ -15229,7 +15261,7 @@ async fn run_multi_thinking_turn( let client = std::sync::Arc::new(MultiThinkingStreamClient { requests: std::sync::Mutex::new(Vec::new()), calls: std::sync::atomic::AtomicUsize::new(0), - trailing_signature_only, + shape, }); let shared: crate::core::model_client::SharedModelClient = client.clone(); let (engine, handle) = Engine::new_with_model_client(engine_config, &config, shared); diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index e9fd4ad692..6d47d66468 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -7755,6 +7755,50 @@ mod tests { assert!(stream_event_has_actionable_content( &canned::tool_use_block_start(0, "call-1", "read_file") )); + + // The preserved-thinking variants: a redacted block is provider- + // owned content the moment it arrives, an empty signature/details + // fragment carries nothing, and a non-empty one is real content. + assert!(stream_event_has_actionable_content( + &StreamEvent::ContentBlockStart { + index: 0, + content_block: crate::models::ContentBlockStart::RedactedThinking { + data: "ENC".to_string(), + }, + } + )); + assert!(!stream_event_has_actionable_content( + &StreamEvent::ContentBlockDelta { + index: 0, + delta: crate::models::Delta::ToolThoughtSignatureDelta { + signature: String::new(), + }, + } + )); + assert!(stream_event_has_actionable_content( + &StreamEvent::ContentBlockDelta { + index: 0, + delta: crate::models::Delta::ToolThoughtSignatureDelta { + signature: "SIG".to_string(), + }, + } + )); + assert!(!stream_event_has_actionable_content( + &StreamEvent::ContentBlockDelta { + index: 0, + delta: crate::models::Delta::ReasoningDetailsDelta { + details: Vec::new() + }, + } + )); + assert!(stream_event_has_actionable_content( + &StreamEvent::ContentBlockDelta { + index: 0, + delta: crate::models::Delta::ReasoningDetailsDelta { + details: vec![serde_json::json!({"type": "reasoning.encrypted"})], + }, + } + )); } /// Regression test for the OpenAI streaming batch tool_calls bug. diff --git a/crates/tui/src/utils.rs b/crates/tui/src/utils.rs index 5d0d3cad0c..07445e0d2a 100644 --- a/crates/tui/src/utils.rs +++ b/crates/tui/src/utils.rs @@ -892,10 +892,18 @@ pub fn estimate_message_chars(messages: &[Message]) -> usize { .sum::(); } } - ContentBlock::ToolUse { input, .. } => { + // Google's thought_signature rides every replayed tool call + // on the exact Google route; the token estimator counts it, + // so the char estimator must too. + ContentBlock::ToolUse { + input, + thought_signature, + .. + } => { let mut cw = CountingWriter::new(); let _ = serde_json::to_writer(&mut cw, input); total += cw.count(); + total += thought_signature.as_deref().map_or(0, str::len); } ContentBlock::ToolResult { content, .. } => total += content.len(), ContentBlock::ServerToolUse { .. } @@ -963,6 +971,33 @@ mod tests { ); } + #[test] + fn estimate_message_chars_counts_google_thought_signatures() { + use super::estimate_message_chars; + use crate::models::{ContentBlock, Message, Role}; + // The thought_signature replays verbatim on every tool call of the + // exact Google route and the compaction estimator counts it; the + // char estimator must not score it zero. + let tool_use = |thought_signature: Option| Message { + role: Role::Assistant, + content: vec![ContentBlock::ToolUse { + id: "call-1".to_string(), + name: "read".to_string(), + input: serde_json::json!({}), + caller: None, + thought_signature, + }], + }; + let with_signature = + estimate_message_chars(std::slice::from_ref(&tool_use(Some("THOUGHT-SIG".into())))); + let bare = estimate_message_chars(std::slice::from_ref(&tool_use(None))); + assert_eq!( + with_signature - bare, + "THOUGHT-SIG".len(), + "thought_signature bytes must count toward the estimate" + ); + } + #[test] fn display_path_contracts_home_prefix() { let h = home("/Users/alice"); From ee1080a97780892ed961a3fba6290642e2603572 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Thu, 8 Oct 2026 21:40:06 +0800 Subject: [PATCH 11/12] fix(tui): concatenate streamed reasoning details verbatim The incremental merge kept a compatibility branch for cumulative snapshots: a fragment that started with (or equaled) the stored text replaced or was deduped against it. OpenRouter's documented contract has no such shape on this route - "each reasoning detail chunk is sent as it becomes available", "the complete reasoning sequence is built by concatenating all chunks in order" - and content sniffing cannot tell the shapes apart: two consecutive fragments with the same content ("a", "a") stored "a" instead of "aa", silently dropping replay bytes the API requires unmodified. The buffer runs on the OpenRouter route only, and the one known cumulative reasoning_details producer (MiniMax's reasoning_split stream) captures nothing here - its display path keeps its own prefix handling - so the hedge protected no real producer. Fragments now concatenate unconditionally; missing fields (signature, id, format) still fill in. Pins: identical consecutive fragments, position-keyed entries, first-seen growth with concatenation. Signed-off-by: asto18089 --- crates/tui/src/client/chat.rs | 49 +++++++------- .../src/client/chat/tests/stream_decoder.rs | 66 ++++++++++++++----- crates/tui/src/core/engine/turn_loop.rs | 7 +- 3 files changed, 79 insertions(+), 43 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 28cdc8ba70..b8478d4f21 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -3799,10 +3799,15 @@ fn replayable_reasoning_details(details: &[Value]) -> Vec { /// documented incremental contract ("each reasoning detail chunk is sent as /// it becomes available"; "the complete reasoning sequence is built by /// concatenating all chunks in order"). Fragment text continues the stored -/// text — last-wins would truncate a replayed sequence the API requires -/// unmodified. A fragment that instead extends the stored text as a prefix -/// is a cumulative re-send and replaces it, matching the plaintext -/// `reasoning` path's prefix handling. Any other field the fragment carries +/// text unconditionally — last-wins would truncate a replayed sequence the +/// API requires unmodified, and so would sniffing a fragment that happens +/// to equal or extend the stored text as a "cumulative re-send": repeated +/// tokens are ordinary reasoning output, and content guessing cannot tell +/// the shapes apart. This buffer runs on the OpenRouter route only, where +/// the documented assembly rule is concatenation; cumulative +/// `reasoning_details` producers (e.g. MiniMax's reasoning_split stream) +/// never reach it — capture is OpenRouter-only, and their display path +/// keeps its own prefix handling. Any other field the fragment carries /// that the entry still lacks (a signature trailing the text, an id, a /// format) is filled in. fn merge_reasoning_detail_entry(stored: &mut Value, fragment: &Value) -> bool { @@ -3811,18 +3816,11 @@ fn merge_reasoning_detail_entry(stored: &mut Value, fragment: &Value) -> bool { && !incoming.is_empty() { let current = stored.get("text").and_then(Value::as_str).unwrap_or(""); - if incoming.starts_with(current) { - if incoming.len() != current.len() { - stored["text"] = Value::String(incoming.to_string()); - changed = true; - } - } else { - let mut merged = String::with_capacity(current.len() + incoming.len()); - merged.push_str(current); - merged.push_str(incoming); - stored["text"] = Value::String(merged); - changed = true; - } + let mut merged = String::with_capacity(current.len() + incoming.len()); + merged.push_str(current); + merged.push_str(incoming); + stored["text"] = Value::String(merged); + changed = true; } if let (Some(fields), Some(incoming_fields)) = (stored.as_object_mut(), fragment.as_object()) { for (field, value) in incoming_fields { @@ -3849,13 +3847,15 @@ fn merge_reasoning_detail_entry(stored: &mut Value, fragment: &Value) -> bool { /// reasoning sequence is built by concatenating all chunks in order" — so a /// fragment for a known key (the entry's `index`, or its position in the /// incoming array when the entry carries none; a `type` key would collide -/// two same-type entries into one) is merged into the stored entry rather -/// than replacing it. Fragment text continues the stored text unless it -/// extends that text as a prefix, which is treated as a cumulative re-send -/// from a gateway that repeats the growing array (the same dual handling as -/// the plaintext `reasoning` path), and fields the fragment carries that the -/// entry still lacks — a signature trailing the text, an id, a format — are -/// filled in. +/// two same-type entries into one) is concatenated onto the stored entry +/// rather than replacing it. The concatenation is unconditional: a fragment +/// that happens to repeat or extend the stored text is still a fragment, +/// and guessing "cumulative re-send" from content would silently drop +/// legitimate increments. Capture runs on the OpenRouter route only, so +/// cumulative producers like MiniMax's reasoning_split stream never reach +/// this buffer (their display path keeps its own prefix handling), and +/// fields the fragment carries that the entry still lacks — a signature +/// trailing the text, an id, a format — are filled in. #[derive(Default)] pub(super) struct ReasoningDetailsBuffer { order: Vec, @@ -3889,7 +3889,8 @@ impl ReasoningDetailsBuffer { changed } - /// Entries in first-seen order, merged to their most complete version. + /// Entries in first-seen order, fragment texts concatenated in arrival + /// order. fn snapshot(&self) -> Vec { self.order .iter() diff --git a/crates/tui/src/client/chat/tests/stream_decoder.rs b/crates/tui/src/client/chat/tests/stream_decoder.rs index 7a03dcf706..9fd272c1fb 100644 --- a/crates/tui/src/client/chat/tests/stream_decoder.rs +++ b/crates/tui/src/client/chat/tests/stream_decoder.rs @@ -1555,18 +1555,17 @@ fn details_delta_payloads(events: &[StreamEvent]) -> Vec> { } #[test] -fn openrouter_details_snapshots_merge_last_wins_in_first_seen_order() { - // OpenRouter repeats the growing array on each chunk: entry 0's text - // completes and entry 1 appears. The stored snapshot must hold the most - // complete version of each entry in first-seen order. +fn openrouter_details_entries_grow_in_first_seen_order() { + // A second entry appearing mid-stream must join the snapshot after the + // first-seen one, and both entries' fragments must concatenate — the + // snapshot grows with the stream instead of being replaced by it. let chunks = vec![ serde_json::json!({ "choices": [{ "index": 0, "delta": { - "reasoning": "al", "reasoning_details": [ - { "type": "reasoning.text", "index": 0, "text": "al" } + { "type": "reasoning.text", "index": 0, "text": "ab" } ] } }] @@ -1576,7 +1575,7 @@ fn openrouter_details_snapshots_merge_last_wins_in_first_seen_order() { "index": 0, "delta": { "reasoning_details": [ - { "type": "reasoning.text", "index": 0, "text": "alpha" }, + { "type": "reasoning.text", "index": 0, "text": "cd" }, { "type": "reasoning.encrypted", "index": 1, "data": "blob" } ] } @@ -1589,7 +1588,7 @@ fn openrouter_details_snapshots_merge_last_wins_in_first_seen_order() { assert_eq!(snapshots[0].len(), 1); let last = snapshots.last().expect("final snapshot"); assert_eq!(last.len(), 2, "entry count grows with the array"); - assert_eq!(last[0]["text"], "alpha", "last version of entry 0 wins"); + assert_eq!(last[0]["text"], "abcd", "fragments concatenate in order"); assert_eq!(last[0]["type"], "reasoning.text", "first-seen order kept"); assert_eq!(last[1]["type"], "reasoning.encrypted"); } @@ -1616,9 +1615,9 @@ fn openrouter_details_placeholder_entries_never_enter_the_snapshot() { #[test] fn openrouter_details_without_index_key_by_array_position() { // Entries without an `index` are keyed by their position in the - // incoming cumulative array. A `type` key would merge two same-type - // entries into one (losing the other), and a running counter would - // re-key every reappearance of an entry and grow the snapshot. + // incoming array. A `type` key would merge two same-type entries into + // one (losing the other), and a running counter would re-key every + // reappearance of an entry and grow the snapshot. let chunks = vec![ serde_json::json!({ "choices": [{ @@ -1636,8 +1635,8 @@ fn openrouter_details_without_index_key_by_array_position() { "index": 0, "delta": { "reasoning_details": [ - { "type": "reasoning.text", "text": "one" }, - { "type": "reasoning.text", "text": "two!" } + { "type": "reasoning.text", "text": "th" }, + { "type": "reasoning.text", "text": "!" } ] } }] @@ -1648,10 +1647,45 @@ fn openrouter_details_without_index_key_by_array_position() { assert_eq!(snapshots.len(), 2, "one snapshot per absorbing chunk"); let last = snapshots.last().expect("final snapshot"); assert_eq!(last.len(), 2, "both index-less entries survive"); - assert_eq!(last[0]["text"], "one"); + assert_eq!(last[0]["text"], "oneth", "each position concatenates"); + assert_eq!(last[1]["text"], "two!", "each position concatenates"); +} + +#[test] +fn openrouter_details_identical_fragments_concatenate() { + // The minimal content-sniffing trap: two consecutive fragments with the + // same content ("a", "a") must store "aa". Treating a fragment that + // equals (or extends) the stored text as a duplicate cumulative re-send + // would silently drop repeated tokens from a replayed sequence the API + // requires unmodified. + let chunks = vec![ + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "index": 0, "text": "a" } + ] + } + }] + }), + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "index": 0, "text": "a" } + ] + } + }] + }), + ]; + let events = decode_chunks_capturing_details(&chunks, true); + let snapshots = details_delta_payloads(&events); + let last = snapshots.last().expect("final snapshot"); assert_eq!( - last[1]["text"], "two!", - "last version of the second entry wins" + last[0]["text"], "aa", + "identical consecutive fragments must concatenate, not dedupe" ); } diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 6d47d66468..00c0c49819 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -5730,9 +5730,10 @@ impl Engine { current_thinking_state = Some(state); } Delta::ReasoningDetailsDelta { details } => { - // OpenRouter repeats the growing details array on - // successive chunks; the last snapshot is the most - // complete version and replaces any earlier one. + // Each decoder event carries the complete + // accumulated snapshot (every entry, fragment text + // concatenated in arrival order), so the latest + // event supersedes the previous one wholesale. current_thinking_details = Some(details); } Delta::ToolThoughtSignatureDelta { signature } => { From 46dd3f1abbb0e4d539aafb5e1718905d07ce1399 Mon Sep 17 00:00:00 2001 From: asto18089 Date: Mon, 12 Oct 2026 17:44:16 +0800 Subject: [PATCH 12/12] fix(tui): key streamed reasoning details by entry id The id-less-index fallback keyed entries by their position in the incoming array. OpenRouter documents `index` as optional and `id` as the unique identifier of a reasoning detail, so two distinct single-entry chunks both landed on position 0; the merge keeps the first entry's nonempty fields, so the second entry's id and data were silently lost (and distinct signed entries concatenated each other's text). The key is now the strongest identity the entry carries: `index`, else its non-empty `id`, else array position. Key spaces are prefixed apart so an id can never collide with an index or position key; same-id continuation fragments keep merging, and entries carrying neither field keep the pinned position keying. Pins: distinct-id encrypted entries stay separate in first-seen order, same-id fragments concatenate with a trailing signature filled in, distinct signed entries keep their own text and signature. The distinct-id pins are red on the previous head. Signed-off-by: asto18089 --- crates/tui/src/client/chat.rs | 25 +++- .../src/client/chat/tests/stream_decoder.rs | 134 +++++++++++++++++- 2 files changed, 149 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index b8478d4f21..81d7dfdf48 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -3845,12 +3845,18 @@ fn merge_reasoning_detail_entry(stored: &mut Value, fragment: &Value) -> bool { /// OpenRouter's documented streaming contract is incremental — "each /// reasoning detail chunk is sent as it becomes available", "the complete /// reasoning sequence is built by concatenating all chunks in order" — so a -/// fragment for a known key (the entry's `index`, or its position in the -/// incoming array when the entry carries none; a `type` key would collide -/// two same-type entries into one) is concatenated onto the stored entry -/// rather than replacing it. The concatenation is unconditional: a fragment -/// that happens to repeat or extend the stored text is still a fragment, -/// and guessing "cumulative re-send" from content would silently drop +/// fragment for a known key is concatenated onto the stored entry rather +/// than replacing it. The key is the strongest identity the entry carries: +/// its `index`, else its documented unique `id` ("Unique identifier for the +/// reasoning detail"; `index` is optional, so two distinct single-entry +/// chunks would otherwise both land on array position 0 and the merge keeps +/// the first entry's nonempty fields — the second entry would be silently +/// lost), else its position in the incoming array for entries carrying +/// neither. Key spaces are prefixed apart so an id can never collide with +/// an index or position key. A `type` key would collide two same-type +/// entries into one. The concatenation is unconditional: a fragment that +/// happens to repeat or extend the stored text is still a fragment, and +/// guessing "cumulative re-send" from content would silently drop /// legitimate increments. Capture runs on the OpenRouter route only, so /// cumulative producers like MiniMax's reasoning_split stream never reach /// this buffer (their display path keeps its own prefix handling), and @@ -3874,6 +3880,13 @@ impl ReasoningDetailsBuffer { .get("index") .and_then(Value::as_u64) .map(|index| index.to_string()) + .or_else(|| { + entry + .get("id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(|id| format!("id:{id}")) + }) .unwrap_or_else(|| format!("_arr{position}")); if !self.order.contains(&key) { self.order.push(key.clone()); diff --git a/crates/tui/src/client/chat/tests/stream_decoder.rs b/crates/tui/src/client/chat/tests/stream_decoder.rs index 9fd272c1fb..12e3876708 100644 --- a/crates/tui/src/client/chat/tests/stream_decoder.rs +++ b/crates/tui/src/client/chat/tests/stream_decoder.rs @@ -1614,10 +1614,10 @@ fn openrouter_details_placeholder_entries_never_enter_the_snapshot() { #[test] fn openrouter_details_without_index_key_by_array_position() { - // Entries without an `index` are keyed by their position in the - // incoming array. A `type` key would merge two same-type entries into - // one (losing the other), and a running counter would re-key every - // reappearance of an entry and grow the snapshot. + // Entries with neither an `index` nor an `id` are keyed by their + // position in the incoming array. A `type` key would merge two + // same-type entries into one (losing the other), and a running counter + // would re-key every reappearance of an entry and grow the snapshot. let chunks = vec![ serde_json::json!({ "choices": [{ @@ -1651,6 +1651,132 @@ fn openrouter_details_without_index_key_by_array_position() { assert_eq!(last[1]["text"], "two!", "each position concatenates"); } +#[test] +fn openrouter_details_distinct_ids_without_index_stay_separate() { + // OpenRouter documents `index` as optional and `id` as the unique + // identifier of a reasoning detail. Two single-entry chunks carrying + // distinct ids must stay distinct entries in first-seen order: keying + // id-less-index entries by array position alone collides both chunks + // at position 0, and the merge keeps the first entry's nonempty + // fields — the second entry's payload is silently lost from the + // replayed sequence. + let chunks = vec![ + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.encrypted", "id": "r1", "data": "payload-one" } + ] + } + }] + }), + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.encrypted", "id": "r2", "data": "payload-two" } + ] + } + }] + }), + ]; + let events = decode_chunks_capturing_details(&chunks, true); + let snapshots = details_delta_payloads(&events); + let last = snapshots.last().expect("final snapshot"); + assert_eq!(last.len(), 2, "distinct ids must not collide on position"); + assert_eq!(last[0]["id"], "r1", "first-seen order kept"); + assert_eq!(last[0]["data"], "payload-one"); + assert_eq!(last[1]["id"], "r2"); + assert_eq!(last[1]["data"], "payload-two", "second payload preserved"); +} + +#[test] +fn openrouter_details_fragments_with_same_id_concatenate() { + // Continuation fragments of the same index-less entry repeat the + // entry's `id`: they must keep merging into that one entry — text in + // arrival order, trailing metadata filled in — instead of growing the + // snapshot. + let chunks = vec![ + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "id": "s1", "text": "al" } + ] + } + }] + }), + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "id": "s1", "text": "pha" } + ] + } + }] + }), + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "id": "s1", "text": "", "signature": "SIG-tail" } + ] + } + }] + }), + ]; + let events = decode_chunks_capturing_details(&chunks, true); + let snapshots = details_delta_payloads(&events); + let last = snapshots.last().expect("final snapshot"); + assert_eq!(last.len(), 1, "same-id fragments are one entry"); + assert_eq!(last[0]["text"], "alpha", "fragments concatenate in order"); + assert_eq!( + last[0]["signature"], "SIG-tail", + "a trailing signature must fill the entry" + ); +} + +#[test] +fn openrouter_details_distinct_signed_entries_keep_their_own_text() { + // Distinct signed entries with ids must not concatenate each other's + // text: each entry keeps its own text and signature. + let chunks = vec![ + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "id": "a", "text": "first", "signature": "SA" } + ] + } + }] + }), + serde_json::json!({ + "choices": [{ + "index": 0, + "delta": { + "reasoning_details": [ + { "type": "reasoning.text", "id": "b", "text": "second", "signature": "SB" } + ] + } + }] + }), + ]; + let events = decode_chunks_capturing_details(&chunks, true); + let snapshots = details_delta_payloads(&events); + let last = snapshots.last().expect("final snapshot"); + assert_eq!(last.len(), 2, "distinct signed entries stay separate"); + assert_eq!(last[0]["text"], "first"); + assert_eq!(last[0]["signature"], "SA"); + assert_eq!(last[1]["text"], "second", "text must not cross entries"); + assert_eq!(last[1]["signature"], "SB"); +} + #[test] fn openrouter_details_identical_fragments_concatenate() { // The minimal content-sniffing trap: two consecutive fragments with the