Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions crates/core/src/request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,25 @@ pub enum ContentBlock {
/// readable `thinking` text or carry it across a route/model switch.
#[serde(skip_serializing_if = "Option::is_none", default)]
state: Option<OpaqueReasoningState>,
/// 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<String>,
/// 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<Vec<serde_json::Value>>,
},
#[serde(rename = "tool_use")]
ToolUse {
Expand Down Expand Up @@ -225,6 +244,8 @@ impl ContentBlock {
thinking: thinking.into(),
signature: None,
state: None,
redacted_data: None,
reasoning_details: None,
}
}
}
Expand Down
105 changes: 97 additions & 8 deletions crates/tui/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -5936,6 +5936,8 @@ mod tests {
thinking: "Inspect the saved tool state".to_string(),
signature: None,
state: None,
redacted_data: None,
reasoning_details: None,
},
ContentBlock::ToolUse {
id: "call-k3-replay".to_string(),
Expand Down Expand Up @@ -7162,13 +7164,35 @@ 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,
reasoning_details: 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,
Expand Down Expand Up @@ -8934,6 +8958,8 @@ mod tests {
signature: None,
state: None,
thinking: "plan".to_string(),
redacted_data: None,
reasoning_details: None,
},
ContentBlock::Text {
text: "done".to_string(),
Expand Down Expand Up @@ -8974,6 +9000,8 @@ mod tests {
signature: None,
state: None,
thinking: "plan".to_string(),
redacted_data: None,
reasoning_details: None,
},
ContentBlock::Text {
text: "done".to_string(),
Expand Down Expand Up @@ -9025,6 +9053,8 @@ mod tests {
signature: None,
state: None,
thinking: "Need to call a tool".to_string(),
redacted_data: None,
reasoning_details: None,
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
Expand Down Expand Up @@ -9079,6 +9109,8 @@ mod tests {
signature: None,
state: None,
thinking: "Need to call a tool".to_string(),
redacted_data: None,
reasoning_details: None,
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
Expand Down Expand Up @@ -9152,6 +9184,8 @@ mod tests {
signature: None,
state: None,
thinking: "Internal explanation plan".to_string(),
redacted_data: None,
reasoning_details: None,
},
ContentBlock::Text {
text: "Final answer".to_string(),
Expand Down Expand Up @@ -9197,6 +9231,8 @@ mod tests {
signature: None,
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(),
Expand Down Expand Up @@ -10230,6 +10266,8 @@ mod tests {
&mut thinking_started,
&mut tool_indices,
&mut reasoning_detail_buffers,
&mut ReasoningDetailsBuffer::default(),
false,
true,
);

Expand Down Expand Up @@ -10354,6 +10392,8 @@ mod tests {
signature: None,
state: None,
thinking: "plan".to_string(),
redacted_data: None,
reasoning_details: None,
}],
};
let out = build_chat_messages(None, &[message], "some-non-deepseek-model");
Expand Down Expand Up @@ -10418,6 +10458,8 @@ mod tests {
&mut thinking_started,
&mut tool_indices,
&mut reasoning_detail_buffers,
&mut ReasoningDetailsBuffer::default(),
false,
false,
);

Expand Down Expand Up @@ -10479,6 +10521,8 @@ mod tests {
&mut thinking_started,
&mut tool_indices,
&mut reasoning_detail_buffers,
&mut ReasoningDetailsBuffer::default(),
false,
false,
);

Expand All @@ -10503,6 +10547,8 @@ mod tests {
signature: None,
state: None,
thinking: "Need to inspect the directory".to_string(),
redacted_data: None,
reasoning_details: None,
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
Expand Down Expand Up @@ -10546,6 +10592,8 @@ mod tests {
signature: None,
state: None,
thinking: "Need to search".to_string(),
redacted_data: None,
reasoning_details: None,
},
ContentBlock::ToolUse {
id: "tool-1".to_string(),
Expand Down Expand Up @@ -10639,6 +10687,8 @@ mod tests {
signature: None,
state: None,
thinking: "Need to list files".to_string(),
redacted_data: None,
reasoning_details: None,
},
ContentBlock::ToolUse {
id: "tool-ok".to_string(),
Expand Down Expand Up @@ -11387,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.
Expand Down
Loading
Loading