mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-09 01:49:49 -10:00
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:
commit
b6373c23d9
35 files changed
+935
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1652,6 +1652,7 @@ mod dialect_seam_tests {
|
||||
id: Some(id.into()),
|
||||
encrypted_content: format!("opaque-{id}"),
|
||||
}),
|
||||
redacted_data: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -249,6 +249,7 @@
|
||||
thinking: "Inspect the saved tool state".to_string(),
|
||||
signature: None,
|
||||
state: None,
|
||||
redacted_data: None,
|
||||
},
|
||||
ContentBlock::ToolUse {
|
||||
execution_id: None,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¤t_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()
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -1066,6 +1066,7 @@ mod tests {
|
||||
ContentBlock::Thinking {
|
||||
signature: None,
|
||||
state: None,
|
||||
redacted_data: None,
|
||||
thinking: "skip".to_string(),
|
||||
},
|
||||
ContentBlock::Text {
|
||||
|
||||
@@ -16983,6 +16983,7 @@ impl RuntimeThreadManager {
|
||||
thinking,
|
||||
signature: None,
|
||||
state: None,
|
||||
redacted_data: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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![
|
||||
|
||||
@@ -539,6 +539,7 @@ mod tests {
|
||||
.to_string(),
|
||||
signature: None,
|
||||
state: None,
|
||||
redacted_data: None,
|
||||
},
|
||||
);
|
||||
mock.push_message_response(response);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -128,6 +128,7 @@ pub(crate) fn push_assistant_message(
|
||||
thinking,
|
||||
signature: None,
|
||||
state: None,
|
||||
redacted_data: None,
|
||||
});
|
||||
}
|
||||
if !text.is_empty() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user