Merge pull request #7019 from asto18089/up/anthropic-redacted-thinking

fix(client): capture and replay Anthropic redacted thinking blocks

Merged locally because the PR conflicted with #7014 in one hunk of
crates/tui/src/client/anthropic.rs (build_anthropic_body): this PR's
non-native redacted_thinking withhold block sits directly above the
sampling-parameters comment that #7014 rewrote. Resolution keeps both:
the withhold block, then #7014's updated sampling comment.

PR checks on its last head (5851b95ecc): Lint, Test (ubuntu/macos/windows),
npm wrapper smoke all passing.

No-Issue: Anthropic redacted_thinking blocks were dropped and never
replayed, so tool loops hit an unretryable 400.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-18 01:11:51 -07:00
commit b6373c23d9
35 files changed
+935 -3

No files matched your search

+65
View File
@@ -473,6 +473,17 @@ pub fn auto_compact_default_for_model(model: &str) -> bool {
// === Streaming Structures ===
/// 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<String, D::Error>
where
D: serde::Deserializer<'de>,
{
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
}
#[allow(dead_code)]
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type")]
@@ -520,6 +531,23 @@ 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 {
/// 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")]
ToolUse {
id: String,
@@ -575,6 +603,43 @@ mod tests {
use super::*;
use std::any::TypeId;
#[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 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:?}"),
}
}
/// #6032: `model_supports_reasoning` consults the catalog before its
/// hand-maintained pile, so a literal arm that duplicates a catalog row is
/// unreachable. These 27 arms were exactly that and were deleted. They must
+69
View File
@@ -157,6 +157,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<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>,
},
#[serde(rename = "tool_use")]
ToolUse {
@@ -261,6 +271,7 @@ impl ContentBlock {
thinking: thinking.into(),
signature: None,
state: None,
redacted_data: None,
}
}
}
@@ -306,6 +317,64 @@ mod tests {
use super::*;
use serde_json::json;
#[test]
fn redacted_thinking_round_trips_through_persistence_serde() {
// Capture shape: the redacted payload rides the thinking variant and
// must survive the persisted-session round trip byte-exact.
let block = ContentBlock::Thinking {
thinking: String::new(),
signature: None,
state: None,
redacted_data: Some("ENC_REDACTED_PAYLOAD".to_string()),
};
let value = serde_json::to_value(&block).expect("thinking block serializes");
assert_eq!(
value,
json!({
"type": "thinking",
"thinking": "",
"redacted_data": "ENC_REDACTED_PAYLOAD",
}),
"the redacted payload must be on the persisted wire"
);
let round: ContentBlock =
serde_json::from_value(value).expect("redacted block deserializes");
assert_eq!(round, block, "round trip must be lossless");
}
#[test]
fn sessions_without_the_redacted_field_deserialize_unchanged() {
// A session persisted by an older build carries no `redacted_data`
// key; it must load unchanged with the field defaulting to `None`
// and must not gain the key on the way back out.
let value = json!({
"type": "thinking",
"thinking": "readable reasoning",
"signature": "sig-abc",
});
let block: ContentBlock =
serde_json::from_value(value).expect("legacy thinking block deserializes");
match &block {
ContentBlock::Thinking {
thinking,
signature,
state,
redacted_data,
} => {
assert_eq!(thinking, "readable reasoning");
assert_eq!(signature.as_deref(), Some("sig-abc"));
assert!(state.is_none());
assert!(redacted_data.is_none());
}
other => panic!("expected thinking block, got {other:?}"),
}
let serialized = serde_json::to_value(&block).expect("re-serializes");
assert!(
serialized.get("redacted_data").is_none(),
"a non-redacted block must not gain the serde field: {serialized}"
);
}
fn primary_turn() -> PrimaryTurnRequest {
PrimaryTurnRequest {
model: "deepseek-v4-flash".to_string(),
+317
View File
@@ -229,6 +229,39 @@ impl CodewhaleClient {
_ => {}
}
// 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)
{
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: every model from Claude 4.7 on (including the
// Claude 5 generation and Haiku 5.5) rejects non-default
// temperature/top_p; earlier models reject the two together. Send at
@@ -482,9 +515,42 @@ fn decode_anthropic_message(mut value: Value) -> Result<MessageResponse> {
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.
/// 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 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": "",
"redacted_data": data,
});
}
}
}
/// 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 {
@@ -799,8 +865,20 @@ fn content_block_to_anthropic(block: &ContentBlock) -> Option<Value> {
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
@@ -1884,11 +1962,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 {
execution_id: None,
@@ -1931,6 +2011,243 @@ 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 {
execution_id: None,
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 {
execution_id: None,
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 non_native_gateways_never_receive_redacted_thinking_blocks() {
// 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 {
role: Role::Assistant,
content: vec![
ContentBlock::Thinking {
thinking: String::new(),
signature: None,
state: None,
redacted_data: Some("ENC_REDACTED".to_string()),
},
ContentBlock::ToolUse {
execution_id: None,
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 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()),
}],
},
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 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 codewhale_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_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();
+5
View File
@@ -3778,6 +3778,7 @@ fn parse_chat_message_for_route(
content_blocks.push(ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: reasoning.to_string(),
});
}
@@ -3791,6 +3792,7 @@ fn parse_chat_message_for_route(
signature: None,
state: None,
thinking,
redacted_data: None,
});
}
if let Some(text) = mistral_text.filter(|s| !s.trim().is_empty()) {
@@ -4710,6 +4712,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(),
@@ -4810,6 +4813,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(),
@@ -7343,6 +7347,7 @@ mod mistral_reasoning_tests {
.to_string(),
signature: None,
state: None,
redacted_data: None,
},
ContentBlock::Text {
text: "I will inspect it now.".to_string(),
+1
View File
@@ -1652,6 +1652,7 @@ mod dialect_seam_tests {
id: Some(id.into()),
encrypted_content: format!("opaque-{id}"),
}),
redacted_data: None,
}],
});
}
+8
View File
@@ -707,7 +707,15 @@ impl CodewhaleClient {
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,
+3
View File
@@ -952,6 +952,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,
}],
},
);
@@ -1021,6 +1022,7 @@ fn chatgpt_replays_only_exact_grant_and_model_opaque_reasoning_state() {
thinking: SENTINEL.to_string(),
signature: None,
state: Some(state),
redacted_data: None,
}],
},
);
@@ -1215,6 +1217,7 @@ async fn generic_responses_captures_and_replays_opaque_reasoning() {
thinking: "readable-private-summary".into(),
signature: None,
state: Some(state),
redacted_data: None,
}],
});
let replay = client
+1
View File
@@ -249,6 +249,7 @@
thinking: "Inspect the saved tool state".to_string(),
signature: None,
state: None,
redacted_data: None,
},
ContentBlock::ToolUse {
execution_id: None,
+6
View File
@@ -701,6 +701,7 @@
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "plan".to_string(),
},
ContentBlock::Text {
@@ -741,6 +742,7 @@
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "plan".to_string(),
},
ContentBlock::Text {
@@ -792,6 +794,7 @@
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "Need to call a tool".to_string(),
},
ContentBlock::ToolUse {
@@ -848,6 +851,7 @@
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "Need to call a tool".to_string(),
},
ContentBlock::ToolUse {
@@ -923,6 +927,7 @@
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "Internal explanation plan".to_string(),
},
ContentBlock::Text {
@@ -968,6 +973,7 @@
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "I should explain step by step.".to_string(),
},
ContentBlock::Text {
+4
View File
@@ -633,6 +633,7 @@
content: vec![ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "plan".to_string(),
}],
};
@@ -781,6 +782,7 @@
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "Need to inspect the directory".to_string(),
},
ContentBlock::ToolUse {
@@ -826,6 +828,7 @@
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "Need to search".to_string(),
},
ContentBlock::ToolUse {
@@ -922,6 +925,7 @@
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "Need to list files".to_string(),
},
ContentBlock::ToolUse {
@@ -119,6 +119,7 @@ fn adapter_projects_authoritative_metadata_and_omits_hidden_payloads() {
thinking: "private chain of thought".to_string(),
signature: Some("signature-secret".to_string()),
state: None,
redacted_data: None,
},
ContentBlock::ToolUse {
execution_id: None,
@@ -705,6 +706,7 @@ fn baseline_golden_messages() -> Vec<Message> {
thinking: "private chain of thought".to_string(),
signature: Some("signature-secret".to_string()),
state: None,
redacted_data: None,
},
ContentBlock::ToolUse {
execution_id: None,
@@ -1001,6 +1003,7 @@ fn command_clipboard_export_preserves_structure_and_redacts_secrets() {
thinking: "private chain of thought".to_string(),
signature: Some("signature-secret".to_string()),
state: None,
redacted_data: None,
},
ContentBlock::ToolUse {
execution_id: None,
@@ -71,6 +71,7 @@ fn seed(app: &mut App) {
thinking: "hidden reasoning".into(),
signature: Some("hidden-signature".into()),
state: None,
redacted_data: None,
},
call("first-call"),
result("first-result", Some(false)),
@@ -53,6 +53,7 @@ fn seed_transcript(app: &mut App) {
thinking: "private chain of thought".to_string(),
signature: Some("signature-secret".to_string()),
state: None,
redacted_data: None,
},
ContentBlock::ToolUse {
execution_id: None,
+2
View File
@@ -3444,6 +3444,7 @@ mod tests {
thinking: "retained reasoning ".repeat(2000),
signature: None,
state: None,
redacted_data: None,
},
);
}
@@ -4069,6 +4070,7 @@ mod tests {
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: thinking.clone(),
},
ContentBlock::ToolUse {
@@ -69,6 +69,9 @@ fn block_start_json(block: &ContentBlockStart) -> Value {
ContentBlockStart::Thinking { thinking } => {
json!({ "type": "thinking", "thinking": thinking })
}
ContentBlockStart::RedactedThinking { data } => {
json!({ "type": "redacted_thinking", "data": data })
}
ContentBlockStart::ToolUse {
id,
name,
@@ -369,6 +369,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
@@ -383,6 +384,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 {
execution_id: None,
@@ -401,6 +403,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 {
execution_id: None,
+1
View File
@@ -73,6 +73,7 @@ include!("tests/test_cases_18.rs");
include!("tests/test_cases_19.rs");
mod admission_gates;
mod redacted_thinking;
mod runtime_state;
mod sse_turn_recovery;
mod tool_cancellation;
@@ -0,0 +1,219 @@
//! Redacted thinking capture end to end: a safeguard redacted block arrives
//! complete on its start event, must be parked in wire order next to the
//! readable thinking blocks, and must ride the follow-up request byte-exact.
use super::*;
use crate::llm_client::mock::{MockLlmClient, canned};
use crate::tools::spec::{ApprovalRequirement, ToolCapability, ToolSpec};
/// A no-op mutating-free tool that auto-approves, so the streamed call is
/// admitted and the follow-up request is issued.
struct RedactedFixtureTool;
#[async_trait::async_trait]
impl ToolSpec for RedactedFixtureTool {
fn name(&self) -> &str {
"redacted_fixture_read"
}
fn description(&self) -> &str {
"Fixture tool that closes a thinking-heavy turn."
}
fn input_schema(&self) -> Value {
json!({"type": "object"})
}
fn capabilities(&self) -> Vec<ToolCapability> {
Vec::new()
}
fn approval_requirement(&self) -> ApprovalRequirement {
ApprovalRequirement::Auto
}
async fn execute(&self, _: Value, _: &ToolContext) -> Result<ToolResult, ToolError> {
Ok(ToolResult::success("read"))
}
}
#[tokio::test]
async fn redacted_thinking_persists_in_wire_order_into_the_followup_request() {
let workspace = tempdir().unwrap();
let mock = Arc::new(MockLlmClient::new(vec![
vec![
canned::message_start("redacted-turn"),
// Block 0: readable signed thinking, finished by its stop event.
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(),
},
},
canned::block_stop(0),
// Block 1: the redacted block — complete at start, no deltas.
StreamEvent::ContentBlockStart {
index: 1,
content_block: ContentBlockStart::RedactedThinking {
data: "ENC_REDACTED_1".to_string(),
},
},
canned::block_stop(1),
// The tool call closes the turn and forces the follow-up request.
canned::tool_use_block_start(2, "toolu_redacted", "redacted_fixture_read"),
canned::tool_input_delta(2, "{}"),
canned::block_stop(2),
canned::message_delta("tool_use", None),
canned::message_stop(),
],
canned::simple_text_turn("done"),
]));
let (mut engine, _handle) = Engine::new_with_model_client(
deterministic_engine_config(workspace.path()),
&Config::default(),
mock.clone(),
);
engine.session.auto_approve = true;
let mut registry = crate::tools::ToolRegistry::new(ToolContext::new(workspace.path()));
registry.register(Arc::new(RedactedFixtureTool));
let tools = Some(registry.to_api_tools_with_cache(true));
let surface = test_tool_surface(&engine, registry, tools, AppMode::Agent);
let mut turn = crate::core::turn::TurnContext::new(4);
let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
assert_eq!(status, TurnOutcomeStatus::Completed, "{error:?}");
// The persisted assistant turn keeps the whole sequence in wire order.
let tool_call_turn = |message: &&Message| {
message.role == Role::Assistant
&& message
.content
.iter()
.any(|block| matches!(block, ContentBlock::ToolUse { .. }))
};
let persisted = engine
.session
.messages
.iter()
.rev()
.find(tool_call_turn)
.expect("the turn persists the tool-call assistant message");
let mut blocks = persisted.content.iter();
match (blocks.next(), blocks.next(), blocks.next(), blocks.next()) {
(
Some(ContentBlock::Thinking {
thinking,
signature,
state: None,
redacted_data: None,
..
}),
Some(ContentBlock::Thinking {
thinking: empty,
signature: None,
state: None,
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(),
"a redacted block carries no readable text"
);
assert_eq!(data, "ENC_REDACTED_1");
}
other => panic!("unexpected persisted assistant blocks: {other:?}"),
}
// The follow-up request replays the same sequence, redacted payload
// included byte-exact.
assert!(
mock.call_count() >= 2,
"the admitted tool call forces a follow-up request"
);
let requests = mock.captured_requests();
let followup = requests[1]
.messages
.iter()
.rev()
.find(tool_call_turn)
.expect("follow-up request carries the prior assistant turn");
let mut replayed = followup.content.iter();
match (replayed.next(), replayed.next(), replayed.next()) {
(
Some(ContentBlock::Thinking {
thinking,
signature,
..
}),
Some(ContentBlock::Thinking {
redacted_data: Some(data),
..
}),
Some(ContentBlock::ToolUse { .. }),
) => {
assert_eq!(thinking, "first block continued");
assert_eq!(signature.as_deref(), Some("sig-1"));
assert_eq!(data, "ENC_REDACTED_1", "payload replays byte-exact");
}
other => panic!("unexpected replayed assistant blocks: {other:?}"),
}
}
/// A stream that parks a redacted block and then dies without a completion
/// must not be silently re-issued: the parked block is received content, so
/// the death surfaces its error instead of double-billing for the turn.
#[tokio::test]
async fn stream_dying_after_a_redacted_block_is_not_treated_as_empty() {
let workspace = tempdir().unwrap();
let mock = Arc::new(MockLlmClient::new(vec![vec![
canned::message_start("redacted-then-die"),
StreamEvent::ContentBlockStart {
index: 0,
content_block: ContentBlockStart::RedactedThinking {
data: "ENC_REDACTED_DIE".to_string(),
},
},
canned::block_stop(0),
StreamEvent::Error {
error: json!({ "message": "Model not exist." }),
},
]]));
let (mut engine, _handle) = Engine::new_with_model_client(
deterministic_engine_config(workspace.path()),
&Config::default(),
mock.clone(),
);
let surface = test_tool_surface(
&engine,
crate::tools::ToolRegistry::new(ToolContext::new(workspace.path())),
None,
AppMode::Agent,
);
let mut turn = crate::core::turn::TurnContext::new(4);
let (status, error) = engine.run_turn(&mut turn, surface, None, None).await;
assert_eq!(status, TurnOutcomeStatus::Failed);
assert!(
error
.as_deref()
.is_some_and(|e| e.contains("Model not exist.")),
"{error:?}"
);
assert_eq!(
mock.call_count(),
1,
"a stream that captured a redacted block authorizes no silent re-run"
);
}
@@ -163,6 +163,7 @@ async fn session_update_preserves_reasoning_tool_only_turn() {
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "Need a tool before answering.".to_string(),
},
ContentBlock::ToolUse {
+185 -2
View File
@@ -134,6 +134,12 @@ struct StreamOutcome {
current_thinking: String,
current_thinking_signature: Option<String>,
current_thinking_state: Option<codewhale_models::OpaqueReasoningState>,
/// 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.
/// Redacted thinking blocks park here the moment they arrive.
completed_thinking: Vec<ContentBlock>,
tool_uses: Vec<ToolUseState>,
usage: Usage,
usage_reported: bool,
@@ -176,6 +182,34 @@ 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) or an
/// encrypted Responses state — the same guard the persistence site applies to
/// the still-open block, so parking cannot mint a block the old single-slot
/// persistence would have dropped.
fn flush_thinking_block(
thinking: &mut String,
signature: &mut Option<String>,
state: &mut Option<codewhale_models::OpaqueReasoningState>,
completed: &mut Vec<ContentBlock>,
) {
let placeholder_only = crate::client::is_reasoning_replay_placeholder(thinking);
if (!thinking.is_empty() && !placeholder_only) || state.is_some() {
completed.push(ContentBlock::Thinking {
thinking: std::mem::take(thinking),
signature: std::mem::take(signature),
state: state.take(),
redacted_data: None,
});
}
thinking.clear();
*signature = None;
*state = None;
}
/// Preserve text before either execution branch publishes it to the UI or history.
/// Disk failures retain the existing honest "could not be saved" context footer.
pub(super) async fn preserve_tool_output_before_fanout(
@@ -4388,6 +4422,12 @@ impl Engine {
// thinking block; must be replayed verbatim in tool loops.
let mut current_thinking_signature: Option<String> = None;
let mut current_thinking_state: Option<codewhale_models::OpaqueReasoningState> = None;
// Thinking blocks the stream already terminated (stop event seen or a
// new block started), in wire order: adaptive/interleaved thinking
// emits several per turn, and a redacted block parks here the moment
// it arrives. Anthropic replays the whole sequence unmodified, so a
// later block must never overwrite an earlier one.
let mut completed_thinking: Vec<ContentBlock> = Vec::new();
let mut tool_uses: Vec<ToolUseState> = Vec::new();
let mut usage = Usage {
input_tokens: 0,
@@ -4873,9 +4913,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
.send_stream_event(Event::ThinkingStarted {
@@ -4883,6 +4930,43 @@ 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.
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,
&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,
@@ -5022,6 +5106,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 => {}
}
@@ -5169,6 +5263,7 @@ impl Engine {
current_thinking,
current_thinking_signature,
current_thinking_state,
completed_thinking,
tool_uses,
usage,
usage_reported,
@@ -6281,6 +6376,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 {
@@ -6313,6 +6411,9 @@ fn stream_event_content_bytes(event: &StreamEvent) -> usize {
StreamEvent::ContentBlockStart { content_block, .. } => match content_block {
ContentBlockStart::Text { text } => text.len(),
ContentBlockStart::Thinking { thinking } => thinking.len(),
// The redacted payload is the block's whole captured body; it
// counts like the reasoning bytes it stands in for.
ContentBlockStart::RedactedThinking { data } => data.len(),
ContentBlockStart::ToolUse {
id, name, input, ..
}
@@ -7230,6 +7331,88 @@ mod tests {
assert!(stream_event_has_actionable_content(
&canned::tool_use_block_start(0, "call-1", "read_file")
));
// A redacted thinking block is provider-owned content the moment it
// arrives, even though it carries no readable text.
assert!(stream_event_has_actionable_content(
&StreamEvent::ContentBlockStart {
index: 0,
content_block: codewhale_models::ContentBlockStart::RedactedThinking {
data: "ENC".to_string(),
},
}
));
}
#[test]
fn flush_parks_finished_thinking_and_drops_placeholder_only_blocks() {
// Readable text parks; the slot clears so the next block starts
// clean.
let mut completed = Vec::new();
flush_thinking_block(
&mut "readable reasoning".to_string(),
&mut Some("sig-1".to_string()),
&mut None,
&mut completed,
);
assert_eq!(completed.len(), 1);
match &completed[0] {
ContentBlock::Thinking {
thinking,
signature,
redacted_data,
..
} => {
assert_eq!(thinking, "readable reasoning");
assert_eq!(signature.as_deref(), Some("sig-1"));
assert!(redacted_data.is_none());
}
other => panic!("expected a thinking block: {other:?}"),
}
// The wire-only "(reasoning omitted)" placeholder never persists —
// the same rule the persistence site applies to the open block.
completed.clear();
flush_thinking_block(
&mut "(reasoning omitted)".to_string(),
&mut None,
&mut None,
&mut completed,
);
assert!(
completed.is_empty(),
"placeholder-only blocks must not park: {completed:?}"
);
// A block with only Responses continuity state parks even though the
// readable text is empty.
flush_thinking_block(
&mut String::new(),
&mut None,
&mut Some(codewhale_models::OpaqueReasoningState {
provider: "openai-codex".to_string(),
api: "openai-responses".to_string(),
model: "gpt-5-codex".to_string(),
id: None,
encrypted_content: "enc".to_string(),
}),
&mut completed,
);
assert_eq!(completed.len(), 1, "state-only blocks persist");
}
#[test]
fn redacted_block_start_counts_its_payload_as_content_bytes() {
// The encrypted payload is the block's whole body: the max-content
// cap must see it like reasoning text.
assert_eq!(
stream_event_content_bytes(&StreamEvent::ContentBlockStart {
index: 0,
content_block: codewhale_models::ContentBlockStart::RedactedThinking {
data: "ENC".to_string(),
},
}),
3
);
}
/// Regression test for the OpenAI streaming batch tool_calls bug.
@@ -326,6 +326,7 @@ impl Engine {
current_thinking,
current_thinking_signature,
current_thinking_state,
mut completed_thinking,
mut tool_uses,
usage,
usage_reported,
@@ -626,11 +627,17 @@ impl Engine {
));
}
}
// `completed_thinking` is part of "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
&& !terminal_stream_error
&& 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),
@@ -686,6 +693,10 @@ impl Engine {
// variable is still empty at this point and will be
// rebuilt on the next round.
let mut resume_blocks: Vec<ContentBlock> = 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 =
@@ -697,6 +708,7 @@ impl Engine {
thinking: current_thinking.clone(),
signature: current_thinking_signature.clone(),
state: current_thinking_state.clone(),
redacted_data: None,
});
}
if !current_text_visible.is_empty() {
@@ -877,6 +889,12 @@ 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.
content_blocks.append(&mut completed_thinking);
let thinking_is_placeholder_only =
crate::client::is_reasoning_replay_placeholder(&current_thinking);
if (!current_thinking.is_empty() && !thinking_is_placeholder_only)
@@ -886,6 +904,7 @@ impl Engine {
thinking: current_thinking.clone(),
signature: current_thinking_signature.clone(),
state: current_thinking_state.clone(),
redacted_data: None,
});
}
@@ -986,8 +1005,9 @@ impl Engine {
ContentBlock::Thinking {
thinking,
state,
redacted_data,
..
} if !thinking.trim().is_empty() || state.is_some()
} if !thinking.trim().is_empty() || state.is_some() || redacted_data.is_some()
)
});
+1
View File
@@ -1117,6 +1117,7 @@ mod tests {
signature: None,
state: None,
thinking: "let me think...".to_string(),
redacted_data: None,
},
ContentBlock::Text {
text: "done".to_string(),
+1
View File
@@ -1066,6 +1066,7 @@ mod tests {
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "skip".to_string(),
},
ContentBlock::Text {
+1
View File
@@ -16983,6 +16983,7 @@ impl RuntimeThreadManager {
thinking,
signature: None,
state: None,
redacted_data: None,
});
}
}
+2
View File
@@ -7794,6 +7794,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 {
execution_id: None,
@@ -7927,6 +7928,7 @@ async fn seeded_session_records_carry_a_total_order() -> Result<()> {
thinking: "consider".to_string(),
signature: None,
state: None,
redacted_data: None,
},
ContentBlock::ToolUse {
execution_id: None,
@@ -1129,6 +1129,7 @@ fn fallback_partial_text_digests_thinking_and_tool_calls_without_text() {
thinking: "checking whether the ring slot write precedes the read".to_string(),
signature: None,
state: None,
redacted_data: None,
}]),
assistant_message(vec![tool_use("Read", json!({"path": "ring.rs"}))]),
assistant_message(vec![tool_use(
+1
View File
@@ -145,6 +145,7 @@ mod tests {
ContentBlock::Thinking {
signature: None,
state: None,
redacted_data: None,
thinking: "The user seems to be asking me to classify myself.".to_string(),
},
ContentBlock::Text {
+2
View File
@@ -2248,11 +2248,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,
},
],
};
+1
View File
@@ -2132,6 +2132,7 @@ mod tests {
thinking: "silent deliberation".to_string(),
signature: None,
state: None,
redacted_data: None,
}],
};
let saved = saved_session_with_messages(vec![
+1
View File
@@ -539,6 +539,7 @@ mod tests {
.to_string(),
signature: None,
state: None,
redacted_data: None,
},
);
mock.push_message_response(response);
+1
View File
@@ -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);
+1
View File
@@ -128,6 +128,7 @@ pub(crate) fn push_assistant_message(
thinking,
signature: None,
state: None,
redacted_data: None,
});
}
if !text.is_empty() {
+1
View File
@@ -7771,6 +7771,7 @@ fn restored_reasoning_and_answer_clear_prior_fold_ownership() {
.join("\n"),
signature: None,
state: None,
redacted_data: None,
},
codewhale_models::ContentBlock::Text {
text: "restored final answer".to_string(),
@@ -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(),
@@ -28,6 +28,7 @@ fn assistant_thinking_tool_call(
thinking: thinking.to_string(),
signature: None,
state: None,
redacted_data: None,
},
ContentBlock::ToolUse {
execution_id: None,