diff --git a/crates/models/src/lib.rs b/crates/models/src/lib.rs index 706d82f3bc..6cb0ee3330 100644 --- a/crates/models/src/lib.rs +++ b/crates/models/src/lib.rs @@ -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 +where + D: serde::Deserializer<'de>, +{ + Ok(Option::::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 diff --git a/crates/protocol/src/request.rs b/crates/protocol/src/request.rs index 8b1d22fb3b..0d9522eccb 100644 --- a/crates/protocol/src/request.rs +++ b/crates/protocol/src/request.rs @@ -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, + /// Anthropic `redacted_thinking` payload (`data`): the encrypted + /// replacement for a whole thinking block that the provider withheld + /// for safety reasons. It must be replayed byte-exact — the API + /// decrypts it to restore the turn's reasoning — and is mutually + /// exclusive with readable `thinking` text and with `signature`. + /// Stored on the same variant (instead of a sibling one) so existing + /// sessions and matches keep compiling; the serde field is absent + /// for every non-redacted block. + #[serde(skip_serializing_if = "Option::is_none", default)] + redacted_data: Option, }, #[serde(rename = "tool_use")] ToolUse { @@ -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(), diff --git a/crates/tui/src/client/anthropic.rs b/crates/tui/src/client/anthropic.rs index e60803abfc..fbb01dc22c 100644 --- a/crates/tui/src/client/anthropic.rs +++ b/crates/tui/src/client/anthropic.rs @@ -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: Claude 4.7+ rejects temperature/top_p // entirely; earlier models reject the two together. Send at most one // (temperature wins), or neither for models that forbid them. @@ -480,9 +513,42 @@ fn decode_anthropic_message(mut value: Value) -> Result { 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 { @@ -783,8 +849,20 @@ fn content_block_to_anthropic(block: &ContentBlock) -> Option { ContentBlock::Thinking { thinking, signature, + redacted_data, .. } => { + // A redacted thinking block is provider-encrypted content with no + // readable text: replay it byte-exact so the API can decrypt it, + // exactly like a signed block. Anthropic requires the complete + // unmodified block in tool loops and decrypts it to restore the + // turn's reasoning. + if let Some(data) = redacted_data { + return Some(json!({ + "type": "redacted_thinking", + "data": data, + })); + } // Anthropic rejects unsigned thinking blocks on replay (and the // DeepSeek-era "(reasoning omitted)" placeholders mean nothing to // it), so only signed blocks are replayed — verbatim, signature @@ -1832,11 +1910,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, @@ -1879,6 +1959,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(); diff --git a/crates/tui/src/client/chat.rs b/crates/tui/src/client/chat.rs index 43ca46ff0a..2f0926e098 100644 --- a/crates/tui/src/client/chat.rs +++ b/crates/tui/src/client/chat.rs @@ -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(), diff --git a/crates/tui/src/client/prepared.rs b/crates/tui/src/client/prepared.rs index 018628d469..e8357c83be 100644 --- a/crates/tui/src/client/prepared.rs +++ b/crates/tui/src/client/prepared.rs @@ -1652,6 +1652,7 @@ mod dialect_seam_tests { id: Some(id.into()), encrypted_content: format!("opaque-{id}"), }), + redacted_data: None, }], }); } diff --git a/crates/tui/src/client/responses.rs b/crates/tui/src/client/responses.rs index 0d29e55253..0fceaa1e55 100644 --- a/crates/tui/src/client/responses.rs +++ b/crates/tui/src/client/responses.rs @@ -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, diff --git a/crates/tui/src/client/responses/tests.rs b/crates/tui/src/client/responses/tests.rs index d6a17cb648..b78d23929b 100644 --- a/crates/tui/src/client/responses/tests.rs +++ b/crates/tui/src/client/responses/tests.rs @@ -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 diff --git a/crates/tui/src/client/test_cases_02.rs b/crates/tui/src/client/test_cases_02.rs index c0c9f0eea8..cdf2bebef5 100644 --- a/crates/tui/src/client/test_cases_02.rs +++ b/crates/tui/src/client/test_cases_02.rs @@ -249,6 +249,7 @@ thinking: "Inspect the saved tool state".to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::ToolUse { execution_id: None, diff --git a/crates/tui/src/client/test_cases_04.rs b/crates/tui/src/client/test_cases_04.rs index fadcd5ccd9..0a9009927d 100644 --- a/crates/tui/src/client/test_cases_04.rs +++ b/crates/tui/src/client/test_cases_04.rs @@ -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 { diff --git a/crates/tui/src/client/test_cases_05.rs b/crates/tui/src/client/test_cases_05.rs index 78bcc5e950..09a6321bf8 100644 --- a/crates/tui/src/client/test_cases_05.rs +++ b/crates/tui/src/client/test_cases_05.rs @@ -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 { diff --git a/crates/tui/src/commands/session_export_regression_tests.rs b/crates/tui/src/commands/session_export_regression_tests.rs index ca4b4f668c..6a6c815e93 100644 --- a/crates/tui/src/commands/session_export_regression_tests.rs +++ b/crates/tui/src/commands/session_export_regression_tests.rs @@ -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 { 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, diff --git a/crates/tui/src/commands/session_structcopy_host_tests.rs b/crates/tui/src/commands/session_structcopy_host_tests.rs index 9cda9d70f4..fc21c543e3 100644 --- a/crates/tui/src/commands/session_structcopy_host_tests.rs +++ b/crates/tui/src/commands/session_structcopy_host_tests.rs @@ -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)), diff --git a/crates/tui/src/commands/session_structcopy_regression_tests.rs b/crates/tui/src/commands/session_structcopy_regression_tests.rs index d141ea0851..a81b9112e1 100644 --- a/crates/tui/src/commands/session_structcopy_regression_tests.rs +++ b/crates/tui/src/commands/session_structcopy_regression_tests.rs @@ -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, diff --git a/crates/tui/src/compaction.rs b/crates/tui/src/compaction.rs index 1b27b6c8f0..0a93d86b0e 100644 --- a/crates/tui/src/compaction.rs +++ b/crates/tui/src/compaction.rs @@ -3398,6 +3398,7 @@ mod tests { thinking: "retained reasoning ".repeat(2000), signature: None, state: None, + redacted_data: None, }, ); } @@ -4023,6 +4024,7 @@ mod tests { ContentBlock::Thinking { signature: None, state: None, + redacted_data: None, thinking: thinking.clone(), }, ContentBlock::ToolUse { diff --git a/crates/tui/src/conformance/stream_json.rs b/crates/tui/src/conformance/stream_json.rs index b7cc7922ed..f43809249b 100644 --- a/crates/tui/src/conformance/stream_json.rs +++ b/crates/tui/src/conformance/stream_json.rs @@ -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, diff --git a/crates/tui/src/core/engine/preview/tests.rs b/crates/tui/src/core/engine/preview/tests.rs index 6739f9c00b..8afafc3ac9 100644 --- a/crates/tui/src/core/engine/preview/tests.rs +++ b/crates/tui/src/core/engine/preview/tests.rs @@ -369,6 +369,7 @@ fn context_pressure_delta_matches_clone_and_push_reference() { 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, diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index bb73e843e5..dfb901961d 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -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; diff --git a/crates/tui/src/core/engine/tests/redacted_thinking.rs b/crates/tui/src/core/engine/tests/redacted_thinking.rs new file mode 100644 index 0000000000..a1b0da7bca --- /dev/null +++ b/crates/tui/src/core/engine/tests/redacted_thinking.rs @@ -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 { + Vec::new() + } + fn approval_requirement(&self) -> ApprovalRequirement { + ApprovalRequirement::Auto + } + async fn execute(&self, _: Value, _: &ToolContext) -> Result { + 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" + ); +} diff --git a/crates/tui/src/core/engine/tests/test_cases_13.rs b/crates/tui/src/core/engine/tests/test_cases_13.rs index 3e86456eb4..bff632b4a0 100644 --- a/crates/tui/src/core/engine/tests/test_cases_13.rs +++ b/crates/tui/src/core/engine/tests/test_cases_13.rs @@ -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 { diff --git a/crates/tui/src/core/engine/turn_loop.rs b/crates/tui/src/core/engine/turn_loop.rs index 474f2122f0..5efa7fb426 100644 --- a/crates/tui/src/core/engine/turn_loop.rs +++ b/crates/tui/src/core/engine/turn_loop.rs @@ -134,6 +134,12 @@ struct StreamOutcome { current_thinking: String, current_thinking_signature: Option, current_thinking_state: Option, + /// Thinking blocks already terminated by a `content_block_stop`, in wire + /// order. Adaptive/interleaved thinking emits several per assistant turn; + /// Anthropic requires the whole sequence replayed unmodified, so finished + /// blocks are parked here instead of being overwritten by the next one. + /// Redacted thinking blocks park here the moment they arrive. + completed_thinking: Vec, tool_uses: Vec, 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, + state: &mut Option, + completed: &mut Vec, +) { + 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( @@ -4366,6 +4400,12 @@ impl Engine { // thinking block; must be replayed verbatim in tool loops. let mut current_thinking_signature: Option = None; let mut current_thinking_state: Option = None; + // 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 = Vec::new(); let mut tool_uses: Vec = Vec::new(); let mut usage = Usage { input_tokens: 0, @@ -4851,9 +4891,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 { @@ -4861,6 +4908,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, @@ -5000,6 +5084,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 => {} } @@ -5147,6 +5241,7 @@ impl Engine { current_thinking, current_thinking_signature, current_thinking_state, + completed_thinking, tool_uses, usage, usage_reported, @@ -6259,6 +6354,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 { @@ -6291,6 +6389,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, .. } @@ -7208,6 +7309,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. diff --git a/crates/tui/src/core/engine/turn_loop/model_step.rs b/crates/tui/src/core/engine/turn_loop/model_step.rs index c063daf9df..cc09c9b083 100644 --- a/crates/tui/src/core/engine/turn_loop/model_step.rs +++ b/crates/tui/src/core/engine/turn_loop/model_step.rs @@ -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 = 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() ) }); diff --git a/crates/tui/src/purge.rs b/crates/tui/src/purge.rs index 50ce004bde..78ccda0e66 100644 --- a/crates/tui/src/purge.rs +++ b/crates/tui/src/purge.rs @@ -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(), diff --git a/crates/tui/src/rlm/turn.rs b/crates/tui/src/rlm/turn.rs index dc0ed09808..facb3b00dc 100644 --- a/crates/tui/src/rlm/turn.rs +++ b/crates/tui/src/rlm/turn.rs @@ -1066,6 +1066,7 @@ mod tests { ContentBlock::Thinking { signature: None, state: None, + redacted_data: None, thinking: "skip".to_string(), }, ContentBlock::Text { diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 553e9ae93c..2629dfd468 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -16983,6 +16983,7 @@ impl RuntimeThreadManager { thinking, signature: None, state: None, + redacted_data: None, }); } } diff --git a/crates/tui/src/runtime_threads/tests.rs b/crates/tui/src/runtime_threads/tests.rs index 4a412da577..9e1ca81609 100644 --- a/crates/tui/src/runtime_threads/tests.rs +++ b/crates/tui/src/runtime_threads/tests.rs @@ -7782,6 +7782,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, @@ -7915,6 +7916,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, diff --git a/crates/tui/src/tools/subagent/budget_handback_tests.rs b/crates/tui/src/tools/subagent/budget_handback_tests.rs index 50929e2205..e702ce187b 100644 --- a/crates/tui/src/tools/subagent/budget_handback_tests.rs +++ b/crates/tui/src/tools/subagent/budget_handback_tests.rs @@ -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( diff --git a/crates/tui/src/tui/auto_router.rs b/crates/tui/src/tui/auto_router.rs index 5790e68d97..3b6ff29184 100644 --- a/crates/tui/src/tui/auto_router.rs +++ b/crates/tui/src/tui/auto_router.rs @@ -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 { diff --git a/crates/tui/src/tui/history/tests.rs b/crates/tui/src/tui/history/tests.rs index d1afbd252c..f3dca54ba8 100644 --- a/crates/tui/src/tui/history/tests.rs +++ b/crates/tui/src/tui/history/tests.rs @@ -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, }, ], }; diff --git a/crates/tui/src/tui/session_picker.rs b/crates/tui/src/tui/session_picker.rs index fc3c8936b6..92816498e4 100644 --- a/crates/tui/src/tui/session_picker.rs +++ b/crates/tui/src/tui/session_picker.rs @@ -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![ diff --git a/crates/tui/src/tui/setup/fleet_draft.rs b/crates/tui/src/tui/setup/fleet_draft.rs index 90b383e6fb..0dabf18d61 100644 --- a/crates/tui/src/tui/setup/fleet_draft.rs +++ b/crates/tui/src/tui/setup/fleet_draft.rs @@ -539,6 +539,7 @@ mod tests { .to_string(), signature: None, state: None, + redacted_data: None, }, ); mock.push_message_response(response); diff --git a/crates/tui/src/tui/setup/model_draft.rs b/crates/tui/src/tui/setup/model_draft.rs index 7c73db294d..8ed74f527f 100644 --- a/crates/tui/src/tui/setup/model_draft.rs +++ b/crates/tui/src/tui/setup/model_draft.rs @@ -410,6 +410,7 @@ mod tests { thinking: r#"Maybe {"about":"A half-formed scratchpad draft."}"#.to_string(), signature: None, state: None, + redacted_data: None, }, ); mock.push_message_response(response); diff --git a/crates/tui/src/tui/ui/dispatch.rs b/crates/tui/src/tui/ui/dispatch.rs index 715d959cd9..0387006e0f 100644 --- a/crates/tui/src/tui/ui/dispatch.rs +++ b/crates/tui/src/tui/ui/dispatch.rs @@ -128,6 +128,7 @@ pub(crate) fn push_assistant_message( thinking, signature: None, state: None, + redacted_data: None, }); } if !text.is_empty() { diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index cd71392b4f..96f1ed13b6 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -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(), diff --git a/crates/tui/tests/integration/integration_mock_llm.rs b/crates/tui/tests/integration/integration_mock_llm.rs index 6f4bbb8233..c07103971b 100644 --- a/crates/tui/tests/integration/integration_mock_llm.rs +++ b/crates/tui/tests/integration/integration_mock_llm.rs @@ -77,6 +77,7 @@ fn assistant_thinking(thinking: &str, text: &str) -> Message { thinking: thinking.to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::Text { text: text.to_string(), diff --git a/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs b/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs index d6ffe7baec..55e0eeaac6 100644 --- a/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs +++ b/crates/tui/tests/integration/reasoning_content_replayed_after_tool_call.rs @@ -28,6 +28,7 @@ fn assistant_thinking_tool_call( thinking: thinking.to_string(), signature: None, state: None, + redacted_data: None, }, ContentBlock::ToolUse { execution_id: None,