From b1626e09ef82ab48186a483d8fb222d8d3203fdc Mon Sep 17 00:00:00 2001 From: warelik Date: Mon, 24 Aug 2026 05:07:27 -0400 Subject: [PATCH 1/2] fix(signature): strip foreign Claude compat blobs PreserveEmptyThinkingBlocks forwarded opaque signatures as Claude. Compat now asks DecideSignatureCompatibilityForModel; client _cliproxy_replay_provenance is stripped first. gemini-cli keeps thoughtSignature without rerouting visible text into thinking. --- .../signature/claude_messages_sanitize.go | 52 +++++++- .../claude_messages_sanitize_compat_test.go | 117 +++++++++++++++++- .../claude/gemini-cli_claude_response.go | 78 +++++++++++- .../claude/gemini-cli_claude_response_test.go | 85 +++++++++++++ 4 files changed, 319 insertions(+), 13 deletions(-) create mode 100644 internal/translator/gemini-cli/claude/gemini-cli_claude_response_test.go diff --git a/internal/signature/claude_messages_sanitize.go b/internal/signature/claude_messages_sanitize.go index 3baea48ef..04783c6ab 100644 --- a/internal/signature/claude_messages_sanitize.go +++ b/internal/signature/claude_messages_sanitize.go @@ -14,8 +14,9 @@ type ClaudeMessagesSignatureSanitizeOptions struct { DropEmptyMessages bool DropToolSignatures bool DropEmptyThinkingPlaceholders bool - // PreserveEmptyThinkingBlocks preserves compatibility-mode thinking blocks - // together with their original signatures, including opaque signatures. + // PreserveEmptyThinkingBlocks preserves compatibility-mode thinking block + // shape. Signatures still go through DecideSignatureCompatibilityForModel; + // foreign or opaque values are cleared to an empty signature member. PreserveEmptyThinkingBlocks bool } @@ -124,10 +125,53 @@ func SanitizeClaudeMessagesSignaturesForTarget(payload []byte, opts ClaudeMessag continue } + // Replay provenance is added only internally by the executor after the + // sanitizer has already run. Any client-supplied marker is untrusted and + // must be stripped so it cannot bypass signature validation. + if part.Get("_cliproxy_replay_provenance").Exists() { + updated, _ := sjson.Delete(part.Raw, "_cliproxy_replay_provenance") + part = gjson.Parse(updated) + messageModified = true + } + rawSignature := part.Get("signature").String() if opts.PreserveEmptyThinkingBlocks { - report.Preserved++ - keptParts = append(keptParts, part.Raw) + // Compat mode keeps the block shape. The signature still has to be + // normalized, emulated, or stripped so an incompatible blob is not + // forwarded as a Claude signature. + decision := DecideSignatureCompatibilityForModel(targetProvider, opts.TargetModel, rawSignature, SignatureBlockKindClaudeThinking) + decision.Reason = fmt.Sprintf("messages[%d].content[%d]: %s", i, j, decision.Reason) + report.Decisions = append(report.Decisions, decision) + + switch decision.Action { + case SignatureActionPreserve: + report.Preserved++ + if decision.NormalizedSignature != "" && decision.NormalizedSignature != rawSignature { + updated, _ := sjson.Set(part.Raw, "signature", decision.NormalizedSignature) + keptParts = append(keptParts, updated) + messageModified = true + } else { + keptParts = append(keptParts, part.Raw) + } + case SignatureActionReplaceWithGeminiBypass: + report.ReplacedSignatures++ + updated, _ := sjson.Set(part.Raw, "signature", decision.ReplacementSignature) + keptParts = append(keptParts, updated) + messageModified = true + default: + // DropBlock, DropSignature, or NoCompatibleReplacement: keep the + // block shape for the compat endpoint and preserve empty placeholders + // with their required signature member. + if isEmptyClaudeThinkingPlaceholder(part) { + report.Preserved++ + keptParts = append(keptParts, part.Raw) + } else { + report.DroppedSignatures++ + updated, _ := sjson.Set(part.Raw, "signature", "") + keptParts = append(keptParts, updated) + } + messageModified = true + } continue } if targetProvider == SignatureProviderClaude && isEmptyClaudeThinkingPlaceholder(part) && !opts.DropEmptyThinkingPlaceholders { diff --git a/internal/signature/claude_messages_sanitize_compat_test.go b/internal/signature/claude_messages_sanitize_compat_test.go index 4de4c7dc1..0865fc537 100644 --- a/internal/signature/claude_messages_sanitize_compat_test.go +++ b/internal/signature/claude_messages_sanitize_compat_test.go @@ -1,6 +1,8 @@ package signature import ( + "bytes" + "encoding/base64" "testing" "github.com/tidwall/gjson" @@ -16,12 +18,99 @@ func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesEmptyThinkingInCompatMo withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", true) part := gjson.GetBytes(withCompat, "messages.0.content.0") - if part.Get("type").String() != "thinking" || part.Get("signature").String() != "" { - t.Fatalf("compat sanitizer dropped empty thinking: %s", withCompat) + if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() { + t.Fatalf("compat sanitizer dropped empty thinking or its signature member: %s", withCompat) } } -func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesOpaqueThinkingSignatureInCompatMode(t *testing.T) { +func TestSanitizeClaudeMessagesForClaudeUpstreamRetainsEmptySignatureOnGeminiPrefixInCompatMode(t *testing.T) { + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"gemini#EgI="}]}]}`) + + withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" { + t.Fatalf("compat sanitizer did not retain empty signature member on foreign-prefixed thinking block: %s", withCompat) + } +} + +func TestSanitizeClaudeMessagesForClaudeUpstreamRetainsEmptySignatureOnMislabeledClaudePrefixInCompatMode(t *testing.T) { + geminiSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34}) + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"claude#` + geminiSig + `"}]}]}`) + + withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" { + t.Fatalf("compat sanitizer did not retain empty signature member on mislabeled claude# block: %s", withCompat) + } +} + +func TestSanitizeClaudeMessagesForClaudeUpstreamRetainsEmptySignatureOnNestedClaudePrefixInCompatMode(t *testing.T) { + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"claude#vendor#EgI="}]}]}`) + + withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" { + t.Fatalf("compat sanitizer did not retain empty signature member on nested claude# block: %s", withCompat) + } +} + +func TestSanitizeClaudeMessagesForClaudeUpstreamRetainsEmptySignatureOnUnknownVendorPrefixInCompatMode(t *testing.T) { + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"vendor#EgI="}]}]}`) + + withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" { + t.Fatalf("compat sanitizer did not retain empty signature member on unknown-vendor block: %s", withCompat) + } +} + +func TestSanitizeClaudeMessagesForClaudeUpstreamNormalizesWhitespacePaddedShortSignatureInCompatMode(t *testing.T) { + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":" EgI= "}]}]}`) + + withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() { + t.Fatalf("compat sanitizer dropped the thinking block: %s", withCompat) + } + if got := part.Get("signature").String(); got != "" { + t.Fatalf("compat sanitizer forwarded short signature %q, want empty signature", got) + } +} + +func TestSanitizeClaudeMessagesForClaudeUpstreamRejectsGrokOpaqueERInCompatMode(t *testing.T) { + // Grok/xAI encrypted_content is uniformly distributed and can base64-encode + // to a string starting with 'E' or 'R', but it is not a valid Claude + // thinking signature and must be cleared before forwarding. + grokLike := bytes.Repeat([]byte{0x12, 0xff, 0x88, 0x77, 0x66, 0x55, 0x44, 0x33}, 4) + sig := base64.StdEncoding.EncodeToString(grokLike) + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + sig + `"}]}]}`) + + withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" { + t.Fatalf("compat sanitizer did not clear Grok-style E/R opaque signature: %s", withCompat) + } +} + +func TestSanitizeClaudeMessagesForClaudeUpstreamStripsClientReplayProvenanceMarkerInCompatMode(t *testing.T) { + // Client-supplied _cliproxy_replay_provenance must not bypass signature + // validation. The marker is stripped and the foreign signature is cleared. + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"opaque-foreign-sig","_cliproxy_replay_provenance":true}]}]}`) + + withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" { + t.Fatalf("compat sanitizer dropped the thinking block: %s", withCompat) + } + if part.Get("_cliproxy_replay_provenance").Exists() { + t.Fatalf("compat sanitizer did not strip client-supplied replay provenance marker: %s", withCompat) + } + if part.Get("signature").String() != "" { + t.Fatalf("compat sanitizer preserved foreign signature via client-supplied marker: %s", withCompat) + } +} + +func TestSanitizeClaudeMessagesForClaudeUpstreamStripsOpaqueThinkingSignatureInCompatMode(t *testing.T) { input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"opaque-deepseek-id"}]}]}`) withoutCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4") @@ -31,7 +120,25 @@ func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesOpaqueThinkingSignature withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "deepseek-v4", true) part := gjson.GetBytes(withCompat, "messages.0.content.0") - if part.Get("type").String() != "thinking" || part.Get("signature").String() != "opaque-deepseek-id" { - t.Fatalf("compat sanitizer dropped opaque signature: %s", withCompat) + if part.Get("type").String() != "thinking" || !part.Get("signature").Exists() || part.Get("signature").String() != "" { + t.Fatalf("compat sanitizer did not retain empty signature member on opaque-signature block: %s", withCompat) + } +} + +func TestSanitizeClaudeMessagesForClaudeUpstreamPreservesValidClaudeSignatureInCompatMode(t *testing.T) { + sig := testClaudeThinkingSignature() + input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + sig + `"}]}]}`) + + withCompat, _ := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4", true) + part := gjson.GetBytes(withCompat, "messages.0.content.0") + if part.Get("type").String() != "thinking" { + t.Fatalf("compat sanitizer dropped a valid Claude thinking block: %s", withCompat) + } + got := part.Get("signature").String() + if got == "" { + t.Fatalf("compat sanitizer stripped a valid Claude signature") + } + if part.Get("_cliproxy_replay_provenance").Exists() { + t.Fatalf("compat sanitizer leaked a provenance marker: %s", withCompat) } } diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go index 607d6b9fc..f5eb5b487 100644 --- a/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_response.go @@ -76,6 +76,22 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque appendEvent := func(event, payload string) { output = translatorcommon.AppendSSEEventString(output, event, payload, 3) } + appendSignatureDelta := func(signature string) { + if signature == "" { + return + } + if (*param).(*Params).ResponseType != 2 { + if (*param).(*Params).ResponseType != 0 { + appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex)) + (*param).(*Params).ResponseIndex++ + } + appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, (*param).(*Params).ResponseIndex)) + (*param).(*Params).ResponseType = 2 + } + data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, (*param).(*Params).ResponseIndex)), "delta.signature", signature) + appendEvent("content_block_delta", string(data)) + (*param).(*Params).HasContent = true + } // Initialize the streaming session with a message_start event // This is only sent for the very first response chunk to establish the streaming session @@ -107,6 +123,18 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque // Extract the different types of content from each part partTextResult := partResult.Get("text") functionCallResult := partResult.Get("functionCall") + thoughtSignatureResult := partResult.Get("thoughtSignature") + if !thoughtSignatureResult.Exists() { + thoughtSignatureResult = partResult.Get("thought_signature") + } + hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" + + // Signature-only part: emit as a thinking carrier. Do not treat a + // signature on visible text as thought — that reroutes the answer. + if hasThoughtSignature && !functionCallResult.Exists() && (!partTextResult.Exists() || partTextResult.String() == "") { + appendSignatureDelta(thoughtSignatureResult.String()) + continue + } // Handle text content (both regular content and thinking) if partTextResult.Exists() { @@ -117,6 +145,7 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String()) appendEvent("content_block_delta", string(data)) (*param).(*Params).HasContent = true + appendSignatureDelta(thoughtSignatureResult.String()) } else { // Transition from another state to thinking // First, close any existing content block @@ -136,9 +165,15 @@ func ConvertGeminiCLIResponseToClaude(_ context.Context, _ string, originalReque appendEvent("content_block_delta", string(data)) (*param).(*Params).ResponseType = 2 // Set state to thinking (*param).(*Params).HasContent = true + appendSignatureDelta(thoughtSignatureResult.String()) } } else { - // Process regular text content (user-visible output) + // Process regular text content (user-visible output). + // A thoughtSignature on visible text must not reroute the answer + // into a thinking block; emit a carrier thinking block first. + if hasThoughtSignature { + appendSignatureDelta(thoughtSignatureResult.String()) + } // Continue existing text block if already in content state if (*param).(*Params).ResponseType == 1 { data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String()) @@ -269,6 +304,7 @@ func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, orig parts := root.Get("response.candidates.0.content.parts") textBuilder := strings.Builder{} thinkingBuilder := strings.Builder{} + var thinkingSignature string toolIDCounter := 0 hasToolCall := false @@ -283,24 +319,52 @@ func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, orig } flushThinking := func() { - if thinkingBuilder.Len() == 0 { + if thinkingBuilder.Len() == 0 && thinkingSignature == "" { return } block := []byte(`{"type":"thinking","thinking":""}`) - block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String()) + if thinkingBuilder.Len() > 0 { + block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String()) + } + if thinkingSignature != "" { + block, _ = sjson.SetBytes(block, "signature", thinkingSignature) + } out, _ = sjson.SetRawBytes(out, "content.-1", block) thinkingBuilder.Reset() + thinkingSignature = "" } if parts.IsArray() { for _, part := range parts.Array() { + thoughtSignature := part.Get("thoughtSignature").String() + if thoughtSignature == "" { + thoughtSignature = part.Get("thought_signature").String() + } + + if thoughtSignature != "" && !part.Get("text").Exists() && !part.Get("functionCall").Exists() { + flushText() + thinkingSignature = thoughtSignature + flushThinking() + continue + } + if text := part.Get("text"); text.Exists() && text.String() != "" { if part.Get("thought").Bool() { flushText() thinkingBuilder.WriteString(text.String()) + if thoughtSignature != "" { + thinkingSignature = thoughtSignature + } continue } - flushThinking() + // Visible text stays text even when Gemini attached a signature. + if thoughtSignature != "" { + flushText() + thinkingSignature = thoughtSignature + flushThinking() + } else { + flushThinking() + } textBuilder.WriteString(text.String()) continue } @@ -323,6 +387,12 @@ func ConvertGeminiCLIResponseToClaudeNonStream(_ context.Context, _ string, orig out, _ = sjson.SetRawBytes(out, "content.-1", toolBlock) continue } + + if thoughtSignature != "" { + flushText() + thinkingSignature = thoughtSignature + flushThinking() + } } } diff --git a/internal/translator/gemini-cli/claude/gemini-cli_claude_response_test.go b/internal/translator/gemini-cli/claude/gemini-cli_claude_response_test.go new file mode 100644 index 000000000..c270a3724 --- /dev/null +++ b/internal/translator/gemini-cli/claude/gemini-cli_claude_response_test.go @@ -0,0 +1,85 @@ +package claude + +import ( + "context" + "strings" + "testing" + + "github.com/tidwall/gjson" +) + +func TestConvertGeminiCLIResponseToClaude_PreservesThoughtSignature(t *testing.T) { + ctx := context.Background() + var param any + raw := []byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"step one","thoughtSignature":"opaque-gemini-id"}]},"finishReason":"STOP"}],"usageMetadata":{"candidatesTokenCount":5,"promptTokenCount":10}}}`) + + out := ConvertGeminiCLIResponseToClaude(ctx, "gemini-cli", nil, nil, raw, ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 SSE chunk, got %d", len(out)) + } + + hasSig := false + for _, line := range strings.Split(string(out[0]), "\n") { + data := strings.TrimPrefix(line, "data: ") + if data == line { + continue + } + if gjson.Get(data, "type").String() == "content_block_delta" && + gjson.Get(data, "delta.type").String() == "signature_delta" { + if got := gjson.Get(data, "delta.signature").String(); got == "opaque-gemini-id" { + hasSig = true + } + } + } + if !hasSig { + t.Fatalf("expected a signature_delta with opaque-gemini-id, got %s", out[0]) + } +} + +func TestConvertGeminiCLIResponseToClaudeNonStream_PreservesThoughtSignature(t *testing.T) { + ctx := context.Background() + raw := []byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"step one","thoughtSignature":"opaque-gemini-id"}]},"finishReason":"STOP"}],"usageMetadata":{"candidatesTokenCount":5,"promptTokenCount":10}}}`) + + out := ConvertGeminiCLIResponseToClaudeNonStream(ctx, "gemini-cli", nil, nil, raw, nil) + sig := gjson.GetBytes(out, "content.0.signature").String() + if sig != "opaque-gemini-id" { + t.Fatalf("expected thinking signature to be preserved, got %q; response=%s", sig, out) + } +} + +func TestConvertGeminiCLIResponseToClaude_VisibleTextWithSignatureStaysText(t *testing.T) { + ctx := context.Background() + var param any + raw := []byte(`{"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"visible answer","thoughtSignature":"sig-visible"}]},"finishReason":"STOP"}],"usageMetadata":{"candidatesTokenCount":5,"promptTokenCount":10}}}`) + + out := ConvertGeminiCLIResponseToClaude(ctx, "gemini-cli", nil, nil, raw, ¶m) + if len(out) != 1 { + t.Fatalf("expected 1 SSE chunk, got %d", len(out)) + } + + joined := string(out[0]) + sawVisibleAsThinking := false + sawVisibleAsText := false + for _, line := range strings.Split(joined, "\n") { + data := strings.TrimPrefix(line, "data: ") + if data == line { + continue + } + if gjson.Get(data, "type").String() != "content_block_delta" { + continue + } + deltaType := gjson.Get(data, "delta.type").String() + if deltaType == "thinking_delta" && gjson.Get(data, "delta.thinking").String() == "visible answer" { + sawVisibleAsThinking = true + } + if deltaType == "text_delta" && gjson.Get(data, "delta.text").String() == "visible answer" { + sawVisibleAsText = true + } + } + if sawVisibleAsThinking { + t.Fatalf("visible text with thoughtSignature was rerouted into thinking: %s", joined) + } + if !sawVisibleAsText { + t.Fatalf("visible text with thoughtSignature was not emitted as text: %s", joined) + } +} From 30662b1c8ca77dc9aad0a407dabd60808777c2c4 Mon Sep 17 00:00:00 2001 From: warelik Date: Mon, 24 Aug 2026 06:30:49 -0400 Subject: [PATCH 2/2] test(executor): clear opaque restored signatures Compat restore keeps omitted thinking blocks. Cache-born EgI= is not a Claude envelope; sanitizer must empty it, not forward it. --- .../executor/claude_thinking_replay_test.go | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/internal/runtime/executor/claude_thinking_replay_test.go b/internal/runtime/executor/claude_thinking_replay_test.go index 14c928566..da6959d3e 100644 --- a/internal/runtime/executor/claude_thinking_replay_test.go +++ b/internal/runtime/executor/claude_thinking_replay_test.go @@ -172,8 +172,10 @@ func TestClaudeExecutorCompatThinkingReplayRestoresOmittedBlock(t *testing.T) { if got := content[0].Get("type").String(); got != "thinking" { t.Fatalf("restored first content type = %q, want thinking", got) } - if got := content[0].Get("signature").String(); got != "EgI=" { - t.Fatalf("restored signature = %q, want EgI=", got) + // Cache-born EgI= is not a Claude envelope. Restore the omitted block; + // sanitizer must clear the signature before the compat upstream sees it. + if got := content[0].Get("signature").String(); got != "" { + t.Fatalf("restored signature = %q, want empty", got) } } @@ -240,8 +242,8 @@ func TestClaudeExecutorCompatThinkingReplayRestoresOmittedBlockInStream(t *testi if len(content) != 2 || content[0].Get("type").String() != "thinking" { t.Fatalf("second streamed assistant content = %s, want restored thinking and tool_use", gjson.GetBytes(requestBodies[1], "messages.1.content").Raw) } - if got := content[0].Get("signature").String(); got != "EgI=" { - t.Fatalf("restored streamed signature = %q, want EgI=", got) + if got := content[0].Get("signature").String(); got != "" { + t.Fatalf("restored streamed signature = %q, want empty", got) } } @@ -359,12 +361,18 @@ func TestClaudeExecutorCompatThinkingReplayRestoresMultipleOmittedBlocks(t *test } firstContent := gjson.GetBytes(requestBodies[2], "messages.1.content").Array() secondContent := gjson.GetBytes(requestBodies[2], "messages.3.content").Array() - if len(firstContent) != 2 || firstContent[0].Get("type").String() != "thinking" || firstContent[0].Get("signature").String() != "EgI=" { + if len(firstContent) != 2 || firstContent[0].Get("type").String() != "thinking" { t.Fatalf("first omitted turn was not restored: %s", gjson.GetBytes(requestBodies[2], "messages.1.content").Raw) } - if len(secondContent) != 2 || secondContent[0].Get("type").String() != "thinking" || secondContent[0].Get("signature").String() != "EgM=" { + if got := firstContent[0].Get("signature").String(); got != "" { + t.Fatalf("first restored signature = %q, want empty", got) + } + if len(secondContent) != 2 || secondContent[0].Get("type").String() != "thinking" { t.Fatalf("second omitted turn was not restored: %s", gjson.GetBytes(requestBodies[2], "messages.3.content").Raw) } + if got := secondContent[0].Get("signature").String(); got != "" { + t.Fatalf("second restored signature = %q, want empty", got) + } } func internalcacheClearClaudeThinkingReplay(t *testing.T) {