471 lines
22 KiB
Go
471 lines
22 KiB
Go
package signature
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/tidwall/gjson"
|
|
"google.golang.org/protobuf/encoding/protowire"
|
|
)
|
|
|
|
func testClaudeThinkingSignature() string {
|
|
channelBlock := []byte{}
|
|
channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType)
|
|
channelBlock = protowire.AppendVarint(channelBlock, 12)
|
|
channelBlock = protowire.AppendTag(channelBlock, 2, protowire.VarintType)
|
|
channelBlock = protowire.AppendVarint(channelBlock, 2)
|
|
channelBlock = protowire.AppendTag(channelBlock, 6, protowire.BytesType)
|
|
channelBlock = protowire.AppendString(channelBlock, "claude-sonnet-4-6")
|
|
|
|
container := []byte{}
|
|
container = protowire.AppendTag(container, 1, protowire.BytesType)
|
|
container = protowire.AppendBytes(container, channelBlock)
|
|
|
|
payload := []byte{}
|
|
payload = protowire.AppendTag(payload, 2, protowire.BytesType)
|
|
payload = protowire.AppendBytes(payload, container)
|
|
payload = protowire.AppendTag(payload, 3, protowire.VarintType)
|
|
payload = protowire.AppendVarint(payload, 1)
|
|
return base64.StdEncoding.EncodeToString(payload)
|
|
}
|
|
|
|
// TestBase64AlphabetSet_MatchesEncoderAlphabets pins the charset lookup tables
|
|
// against the encoders they stand in for. A wrong table would silently accept
|
|
// bytes that are not valid base64, or reject a legal payload character.
|
|
func TestBase64AlphabetSet_MatchesEncoderAlphabets(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
set [256]bool
|
|
alphabet string
|
|
}{
|
|
{"grok unpadded std", grokEncryptedContentCharSet, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"},
|
|
{"gpt base64url", gptReasoningSignatureCharSet, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_="},
|
|
}
|
|
for _, tc := range cases {
|
|
allowed := map[byte]bool{}
|
|
for i := 0; i < len(tc.alphabet); i++ {
|
|
allowed[tc.alphabet[i]] = true
|
|
}
|
|
for c := 0; c < 256; c++ {
|
|
want := allowed[byte(c)]
|
|
if got := tc.set[c]; got != want {
|
|
t.Errorf("%s: byte 0x%02x (%q) accepted=%v, want %v", tc.name, c, string(rune(c)), got, want)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// replaySafeEnvelopeFixtures returns one fixture per self-describing provider
|
|
// envelope that carries replayable state. Every entry must survive the structural
|
|
// pre-filter, because losing one would silently reclassify that provider.
|
|
func replaySafeEnvelopeFixtures() map[string]struct {
|
|
sig string
|
|
want SignatureProvider
|
|
} {
|
|
return map[string]struct {
|
|
sig string
|
|
want SignatureProvider
|
|
}{
|
|
"claude single-layer E": {testClaudeThinkingSignature(), SignatureProviderClaude},
|
|
"claude double-layer R": {testUnpaddedAntigravityClaudeThinkingSignature(), SignatureProviderClaude},
|
|
"claude CAIS": {testClaudeCAISSignature("claude-fable-5"), SignatureProviderClaude},
|
|
"gemini protobuf field2": {testGeminiThoughtSignatureEnvelope(), SignatureProviderGemini},
|
|
"gpt fernet": {testGPTReasoningSignature(), SignatureProviderGPT},
|
|
}
|
|
}
|
|
|
|
// TestSelfDescribingSignatureFirstChars_CoversEveryKnownEnvelope guards the
|
|
// structural pre-filter. DetectSignatureProviderForBlock skips every provider
|
|
// validator when maybeSelfDescribingSignatureEnvelope returns false, so an
|
|
// envelope missing from selfDescribingSignatureFirstChars would silently fall
|
|
// through to the residual class. Adding a provider envelope without registering
|
|
// its base64 first character fails here.
|
|
func TestSelfDescribingSignatureFirstChars_CoversEveryKnownEnvelope(t *testing.T) {
|
|
for name, fixture := range replaySafeEnvelopeFixtures() {
|
|
if !maybeSelfDescribingSignatureEnvelope(fixture.sig) {
|
|
t.Errorf("%s: first char %q is not in selfDescribingSignatureFirstChars %q; register it or detection will skip this envelope",
|
|
name, string(fixture.sig[0]), selfDescribingSignatureFirstChars)
|
|
}
|
|
}
|
|
|
|
// The pre-filter must not be so wide that it stops filtering. Opaque xAI
|
|
// ciphertext is the shape it exists to reject.
|
|
for _, sig := range []string{
|
|
"K1ZAIbzDbO",
|
|
"jQDLUr+fD8RFP8nbkkfI",
|
|
"qcgG7jzxH3D6mlVLBBaKXaG3",
|
|
} {
|
|
if maybeSelfDescribingSignatureEnvelope(sig) {
|
|
t.Errorf("opaque ciphertext %q must not look like a self-describing envelope", sig)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGeminiASCIIUUIDIsGateIndependent documents why ascii_uuid is excluded from
|
|
// selfDescribingSignatureFirstChars. Its first byte is the first hex character of
|
|
// the UUID, so the base64 first character spreads over several values, and none of
|
|
// them need to be registered: the envelope is never replay-safe, so it resolves to
|
|
// SignatureProviderUnknown either way and Gemini model parts recover it through the
|
|
// bypass sentinel keyed on block kind.
|
|
func TestGeminiASCIIUUIDIsGateIndependent(t *testing.T) {
|
|
// First hex digit chosen to land on distinct base64 first characters.
|
|
for _, uuid := range []string{
|
|
"09743975-4bb0-4936-9e28-d5b0d21bdc48",
|
|
"49743975-4bb0-4936-9e28-d5b0d21bdc48",
|
|
"89743975-4bb0-4936-9e28-d5b0d21bdc48",
|
|
"a9743975-4bb0-4936-9e28-d5b0d21bdc48",
|
|
"e9743975-4bb0-4936-9e28-d5b0d21bdc48",
|
|
} {
|
|
sig := testGeminiThoughtSignature([]byte(uuid))
|
|
if got := DetectSignatureProvider(sig); got != SignatureProviderUnknown {
|
|
t.Errorf("uuid %q: DetectSignatureProvider = %q, want %q regardless of the pre-filter",
|
|
uuid[:8], got, SignatureProviderUnknown)
|
|
}
|
|
decision := DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall)
|
|
if decision.Action != SignatureActionReplaceWithGeminiBypass {
|
|
t.Errorf("uuid %q: action = %q, want %q", uuid[:8], decision.Action, SignatureActionReplaceWithGeminiBypass)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestDetectSignatureProviderForBlock_ClassifiesEveryKnownEnvelope pins the
|
|
// classification of each envelope so a reordering of the validator chain cannot
|
|
// silently reassign one provider's signatures to another.
|
|
func TestDetectSignatureProviderForBlock_ClassifiesEveryKnownEnvelope(t *testing.T) {
|
|
for name, fixture := range replaySafeEnvelopeFixtures() {
|
|
if got := DetectSignatureProvider(fixture.sig); got != fixture.want {
|
|
t.Errorf("%s: DetectSignatureProvider = %q, want %q", name, got, fixture.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGeminiEnvelopeNeverClaimsClaudeSignatures pins the invariant that keeps
|
|
// Claude and Gemini separable independently of probe order in
|
|
// DetectSignatureProviderForBlock. Gemini validates wire shape only and has no
|
|
// literal marker, so it is the weakest judge; Claude envelopes survive it solely
|
|
// because they carry extra top-level fields beyond the container and therefore
|
|
// fail Gemini's single-record shape. Loosening the Gemini envelope check would
|
|
// make probe order start mattering, and fails here first.
|
|
func TestGeminiEnvelopeNeverClaimsClaudeSignatures(t *testing.T) {
|
|
for name, sig := range map[string]string{
|
|
"single-layer E": testClaudeThinkingSignature(),
|
|
"single-layer E opaque": testClaudeThinkingSignatureWithOpaqueLen(64),
|
|
"double-layer R": testUnpaddedAntigravityClaudeThinkingSignature(),
|
|
"CAIS synthetic": testClaudeCAISSignature("claude-opus-5"),
|
|
"CAIS observed": observedFable5Sample,
|
|
} {
|
|
if isRecognizedGeminiProviderSignature(sig, SignatureBlockKindUnknown) {
|
|
t.Errorf("claude %s is claimed by the Gemini envelope check; probe order in DetectSignatureProviderForBlock is now load-bearing", name)
|
|
}
|
|
if got := DetectSignatureProvider(sig); got != SignatureProviderClaude {
|
|
t.Errorf("claude %s: DetectSignatureProvider = %q, want %q", name, got, SignatureProviderClaude)
|
|
}
|
|
if _, ok := CompatibleSignatureForProvider(SignatureProviderGemini, sig); ok {
|
|
t.Errorf("claude %s must not be replayable as a Gemini signature", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDetectSignatureProvider_UsesProviderPrefix(t *testing.T) {
|
|
claudeSig := "claude#" + testClaudeThinkingSignature()
|
|
if got := DetectSignatureProvider(claudeSig); got != SignatureProviderClaude {
|
|
t.Fatalf("DetectSignatureProvider(claude#...) = %q, want %q", got, SignatureProviderClaude)
|
|
}
|
|
|
|
geminiSig := "gemini#" + testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
|
if got := DetectSignatureProvider(geminiSig); got != SignatureProviderGemini {
|
|
t.Fatalf("DetectSignatureProvider(gemini#...) = %q, want %q", got, SignatureProviderGemini)
|
|
}
|
|
}
|
|
|
|
func TestDetectSignatureProvider_RejectsMisleadingClaudePrefix(t *testing.T) {
|
|
mislabeledGeminiSig := "claude#" + testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
|
if got := DetectSignatureProvider(mislabeledGeminiSig); got != SignatureProviderUnknown {
|
|
t.Fatalf("DetectSignatureProvider(mislabeled claude#Gemini) = %q, want %q", got, SignatureProviderUnknown)
|
|
}
|
|
}
|
|
|
|
func TestDetectSignatureProvider_Gemini3EPrefixDoesNotLookClaude(t *testing.T) {
|
|
// This byte shape base64-encodes with an E prefix but is a Gemini field-2
|
|
// envelope, not a Claude thinking-signature tree.
|
|
geminiSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
|
|
if !strings.HasPrefix(geminiSig, "E") {
|
|
t.Fatalf("test signature should start with E, got %q", geminiSig[:1])
|
|
}
|
|
if got := DetectSignatureProvider(geminiSig); got != SignatureProviderGemini {
|
|
t.Fatalf("DetectSignatureProvider(Gemini E-prefix) = %q, want %q", got, SignatureProviderGemini)
|
|
}
|
|
}
|
|
|
|
func TestCompatibleSignatureForProvider_ClaudeUsesProviderNativeEForm(t *testing.T) {
|
|
nativeSig := testClaudeThinkingSignature()
|
|
doubleEncoded := base64.StdEncoding.EncodeToString([]byte(nativeSig))
|
|
|
|
normalized, ok := CompatibleSignatureForProvider(SignatureProviderClaude, doubleEncoded)
|
|
if !ok {
|
|
t.Fatal("double-layer Claude signature should be compatible")
|
|
}
|
|
if normalized != nativeSig {
|
|
t.Fatalf("CompatibleSignatureForProvider(Claude) = %q, want provider-native %q", normalized, nativeSig)
|
|
}
|
|
}
|
|
|
|
func TestCompatibleAntigravityClaudeThinkingSignature_UsesDoubleLayerRForm(t *testing.T) {
|
|
nativeSig := testClaudeThinkingSignature()
|
|
expected := base64.StdEncoding.EncodeToString([]byte(nativeSig))
|
|
|
|
normalized, ok := CompatibleAntigravityClaudeThinkingSignature(nativeSig)
|
|
if !ok {
|
|
t.Fatal("Claude signature should be compatible with Antigravity Claude")
|
|
}
|
|
if normalized != expected {
|
|
t.Fatalf("CompatibleAntigravityClaudeThinkingSignature = %q, want %q", normalized, expected)
|
|
}
|
|
}
|
|
|
|
func TestCompatibleAntigravityClaudeThinkingSignature_RejectsGeminiEPrefix(t *testing.T) {
|
|
geminiSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34})
|
|
if !strings.HasPrefix(geminiSig, "E") {
|
|
t.Fatalf("test signature should start with E, got %q", geminiSig[:1])
|
|
}
|
|
if normalized, ok := CompatibleAntigravityClaudeThinkingSignature(geminiSig); ok || normalized != "" {
|
|
t.Fatalf("Gemini E-prefix signature normalized=%q ok=%v, want rejected", normalized, ok)
|
|
}
|
|
}
|
|
|
|
func TestDetectSignatureProvider_DoesNotClassifyArbitraryBase64AsGemini(t *testing.T) {
|
|
opaque := testGeminiThoughtSignature([]byte{0x45, 0x12})
|
|
if got := DetectSignatureProvider(opaque); got != SignatureProviderUnknown {
|
|
t.Fatalf("DetectSignatureProvider(arbitrary base64) = %q, want %q", got, SignatureProviderUnknown)
|
|
}
|
|
}
|
|
|
|
func TestGeminiASCIIUUIDSignatureUsesBypass(t *testing.T) {
|
|
plainUUID := "e24830a7-5cd6-42fe-998b-ee539e72b9c3"
|
|
sig := testGeminiThoughtSignature([]byte(plainUUID))
|
|
|
|
if got := DetectSignatureProvider(plainUUID); got != SignatureProviderUnknown {
|
|
t.Fatalf("DetectSignatureProvider(plain UUID) = %q, want %q", got, SignatureProviderUnknown)
|
|
}
|
|
if got := DetectSignatureProvider("gemini#" + plainUUID); got != SignatureProviderUnknown {
|
|
t.Fatalf("DetectSignatureProvider(gemini#plain UUID) = %q, want %q", got, SignatureProviderUnknown)
|
|
}
|
|
|
|
if got := DetectSignatureProvider(sig); got != SignatureProviderUnknown {
|
|
t.Fatalf("DetectSignatureProvider(UUID) = %q, want %q", got, SignatureProviderUnknown)
|
|
}
|
|
if got := DetectSignatureProvider("gemini#" + sig); got != SignatureProviderUnknown {
|
|
t.Fatalf("DetectSignatureProvider(gemini#UUID) = %q, want %q", got, SignatureProviderUnknown)
|
|
}
|
|
if got := DetectSignatureProviderForBlock(sig, SignatureBlockKindGeminiFunctionCall); got != SignatureProviderUnknown {
|
|
t.Fatalf("DetectSignatureProviderForBlock(UUID tool call) = %q, want %q", got, SignatureProviderUnknown)
|
|
}
|
|
if _, ok := CompatibleSignatureForProvider(SignatureProviderGemini, sig); ok {
|
|
t.Fatal("UUID signature should not be compatible")
|
|
}
|
|
if normalized, ok := CompatibleSignatureForProviderBlock(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall); ok || normalized != "" {
|
|
t.Fatalf("UUID tool-call signature normalized=%q ok=%v, want empty and false", normalized, ok)
|
|
}
|
|
decision := DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall)
|
|
if decision.Action != SignatureActionReplaceWithGeminiBypass {
|
|
t.Fatalf("function-call UUID action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
|
|
}
|
|
if decision.ReplacementSignature != GeminiSkipThoughtSignatureValidator {
|
|
t.Fatalf("function-call UUID replacement = %q, want %q", decision.ReplacementSignature, GeminiSkipThoughtSignatureValidator)
|
|
}
|
|
decision = DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiModelPart)
|
|
if decision.Action != SignatureActionReplaceWithGeminiBypass {
|
|
t.Fatalf("model-part UUID action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
|
|
}
|
|
}
|
|
|
|
func TestGeminiWrappedUUIDFunctionCallSignatureIsCompatible(t *testing.T) {
|
|
sig := testGemini3ThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3"))
|
|
|
|
if got := DetectSignatureProvider(sig); got != SignatureProviderGemini {
|
|
t.Fatalf("DetectSignatureProvider(wrapped UUID) = %q, want %q", got, SignatureProviderGemini)
|
|
}
|
|
if got := DetectSignatureProviderForBlock(sig, SignatureBlockKindGeminiFunctionCall); got != SignatureProviderGemini {
|
|
t.Fatalf("DetectSignatureProviderForBlock(wrapped UUID tool call) = %q, want %q", got, SignatureProviderGemini)
|
|
}
|
|
if normalized, ok := CompatibleSignatureForProviderBlock(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall); !ok || normalized != sig {
|
|
t.Fatalf("wrapped UUID tool-call signature normalized=%q ok=%v, want original and true", normalized, ok)
|
|
}
|
|
for _, blockKind := range []SignatureBlockKind{SignatureBlockKindGeminiFunctionCall, SignatureBlockKindGeminiModelPart} {
|
|
decision := DecideSignatureCompatibility(SignatureProviderGemini, sig, blockKind)
|
|
if !decision.Compatible || decision.Action != SignatureActionPreserve || decision.NormalizedSignature != sig {
|
|
t.Fatalf("wrapped UUID decision for %s = %+v, want preserved", blockKind, decision)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCompatibleSignatureForProvider_StripsGeminiPrefix(t *testing.T) {
|
|
sig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
|
normalized, ok := CompatibleSignatureForProvider(SignatureProviderGemini, "gemini#"+sig)
|
|
if !ok {
|
|
t.Fatal("gemini-prefixed signature should be compatible with Gemini")
|
|
}
|
|
if normalized != sig {
|
|
t.Fatalf("normalized = %q, want %q", normalized, sig)
|
|
}
|
|
}
|
|
|
|
func TestSplitSignatureProviderPrefix_UsesStrictProviderAliases(t *testing.T) {
|
|
gptSig := "gpt#" + testGPTReasoningSignature()
|
|
if got := DetectSignatureProvider(gptSig); got != SignatureProviderGPT {
|
|
t.Fatalf("DetectSignatureProvider(gpt#...) = %q, want %q", got, SignatureProviderGPT)
|
|
}
|
|
|
|
mislabeledPrefix := "claude-cache#" + testClaudeThinkingSignature()
|
|
if _, _, ok := SplitSignatureProviderPrefix(mislabeledPrefix); ok {
|
|
t.Fatal("claude-cache# should not be accepted as an explicit provider prefix")
|
|
}
|
|
if got := DetectSignatureProvider(mislabeledPrefix); got != SignatureProviderUnknown {
|
|
t.Fatalf("DetectSignatureProvider(claude-cache#...) = %q, want %q", got, SignatureProviderUnknown)
|
|
}
|
|
}
|
|
|
|
func TestDecideSignatureCompatibility_GeminiFunctionCallUsesBypass(t *testing.T) {
|
|
decision := DecideSignatureCompatibility(SignatureProviderGemini, "claude#"+testClaudeThinkingSignature(), SignatureBlockKindGeminiFunctionCall)
|
|
if decision.Action != SignatureActionReplaceWithGeminiBypass {
|
|
t.Fatalf("Action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass)
|
|
}
|
|
if decision.ReplacementSignature != GeminiSkipThoughtSignatureValidator {
|
|
t.Fatalf("ReplacementSignature = %q, want %q", decision.ReplacementSignature, GeminiSkipThoughtSignatureValidator)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeClaudeMessagesSignaturesForModel_NormalizesSameProviderClaude(t *testing.T) {
|
|
nativeSig := testClaudeThinkingSignature()
|
|
sig := "claude#" + nativeSig
|
|
input := []byte(`{"model":"claude-sonnet","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
|
|
expectedSig, err := NormalizeClaudeProviderNativeThinkingSignature(nativeSig)
|
|
if err != nil {
|
|
t.Fatalf("NormalizeClaudeProviderNativeThinkingSignature failed: %v", err)
|
|
}
|
|
|
|
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "claude-sonnet-4-5")
|
|
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
|
t.Fatalf("unexpected report: %+v", report)
|
|
}
|
|
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != expectedSig {
|
|
t.Fatalf("signature = %q, want normalized %q", got, expectedSig)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeClaudeMessagesSignaturesForModel_DropsClaudeThinkingForGemini(t *testing.T) {
|
|
sig := "claude#" + testClaudeThinkingSignature()
|
|
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
|
|
|
|
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gemini-3.5-flash")
|
|
if report.DroppedBlocks != 1 {
|
|
t.Fatalf("DroppedBlocks = %d, want 1; report=%+v", report.DroppedBlocks, report)
|
|
}
|
|
content := gjson.GetBytes(output, "messages.0.content").Array()
|
|
if len(content) != 1 {
|
|
t.Fatalf("content length = %d, want 1: %s", len(content), output)
|
|
}
|
|
if got := content[0].Get("text").String(); got != "answer" {
|
|
t.Fatalf("remaining text = %q, want answer", got)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeClaudeMessagesSignaturesForModel_PreservesGeminiThinkingForGemini(t *testing.T) {
|
|
nativeSig := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39})
|
|
sig := "gemini#" + nativeSig
|
|
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
|
|
|
|
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gemini-3.5-flash")
|
|
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
|
t.Fatalf("unexpected report: %+v", report)
|
|
}
|
|
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != nativeSig {
|
|
t.Fatalf("signature = %q, want normalized %q", got, nativeSig)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeClaudeMessagesSignaturesForModel_PreservesGPTForGPT(t *testing.T) {
|
|
sig := testGPTReasoningSignature()
|
|
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + sig + `"},{"type":"text","text":"answer"}]}]}`)
|
|
|
|
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gpt-5.2")
|
|
if report.Preserved != 1 || report.DroppedBlocks != 0 {
|
|
t.Fatalf("unexpected report: %+v", report)
|
|
}
|
|
if got := gjson.GetBytes(output, "messages.0.content.0.signature").String(); got != sig {
|
|
t.Fatalf("signature = %q, want preserved %q", got, sig)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeClaudeMessagesSignaturesForModel_DropsEmptyAssistantMessage(t *testing.T) {
|
|
sig := "claude#" + testClaudeThinkingSignature()
|
|
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop","signature":"` + sig + `"}]},{"role":"user","content":[{"type":"text","text":"next"}]}]}`)
|
|
|
|
output, report := SanitizeClaudeMessagesSignaturesForModel(input, "gpt-5.2")
|
|
if report.DroppedBlocks != 1 {
|
|
t.Fatalf("DroppedBlocks = %d, want 1", report.DroppedBlocks)
|
|
}
|
|
messages := gjson.GetBytes(output, "messages").Array()
|
|
if len(messages) != 1 {
|
|
t.Fatalf("messages length = %d, want 1: %s", len(messages), output)
|
|
}
|
|
if got := messages[0].Get("role").String(); got != "user" {
|
|
t.Fatalf("remaining role = %q, want user", got)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeClaudeMessagesForClaudeUpstream_DropsInvalidThinkingAndCleansToolUse(t *testing.T) {
|
|
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"drop me","signature":""},{"type":"text","text":"answer"},{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"git status"},"signature":"bad","thoughtSignature":"bad2","thought_signature":"bad3","model":"claude-sonnet-4-5","extra_content":{"google":{"thought_signature":"bad4"}}}]}]}`)
|
|
|
|
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-5")
|
|
if report.DroppedBlocks != 1 {
|
|
t.Fatalf("DroppedBlocks = %d, want 1; report=%+v", report.DroppedBlocks, report)
|
|
}
|
|
parts := gjson.GetBytes(output, "messages.0.content").Array()
|
|
if len(parts) != 2 {
|
|
t.Fatalf("content length = %d, want 2: %s", len(parts), output)
|
|
}
|
|
if parts[0].Get("type").String() != "text" {
|
|
t.Fatalf("first remaining part = %s, want text", parts[0].Raw)
|
|
}
|
|
toolUse := parts[1]
|
|
if toolUse.Get("type").String() != "tool_use" {
|
|
t.Fatalf("second remaining part = %s, want tool_use", toolUse.Raw)
|
|
}
|
|
if got := toolUse.Get("id").String(); got != "toolu_1" {
|
|
t.Fatalf("tool_use id = %q, want toolu_1", got)
|
|
}
|
|
for _, path := range []string{
|
|
"signature",
|
|
"thoughtSignature",
|
|
"thought_signature",
|
|
"model",
|
|
"extra_content",
|
|
} {
|
|
if toolUse.Get(path).Exists() {
|
|
t.Fatalf("tool_use.%s should be removed: %s", path, toolUse.Raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSanitizeClaudeMessagesForClaudeUpstream_NormalizesValidThinkingAndDropsEmptyMessage(t *testing.T) {
|
|
nativeSig := testClaudeThinkingSignature()
|
|
doubleEncoded := base64.StdEncoding.EncodeToString([]byte(nativeSig))
|
|
input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"keep","signature":"` + doubleEncoded + `"},{"type":"text","text":"answer"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"drop"}]},{"role":"user","content":[{"type":"text","text":"next"}]}]}`)
|
|
|
|
output, report := SanitizeClaudeMessagesForClaudeUpstream(input, "claude-sonnet-4-5")
|
|
if report.Preserved != 1 || report.DroppedBlocks != 1 {
|
|
t.Fatalf("unexpected report: %+v", report)
|
|
}
|
|
messages := gjson.GetBytes(output, "messages").Array()
|
|
if len(messages) != 2 {
|
|
t.Fatalf("messages length = %d, want 2: %s", len(messages), output)
|
|
}
|
|
if got := messages[0].Get("content.0.signature").String(); got != nativeSig {
|
|
t.Fatalf("signature = %q, want provider-native %q", got, nativeSig)
|
|
}
|
|
if got := messages[1].Get("role").String(); got != "user" {
|
|
t.Fatalf("remaining second role = %q, want user", got)
|
|
}
|
|
}
|