Skip to content

fix(translate): re-encode non-ASCII thoughtSignature through base64 at JSON emit - #811

Open
steventohme wants to merge 2 commits into
mainfrom
fix-gemini-thoughtsignature-encoding
Open

fix(translate): re-encode non-ASCII thoughtSignature through base64 at JSON emit#811
steventohme wants to merge 2 commits into
mainfrom
fix-gemini-thoughtsignature-encoding

Conversation

@steventohme

Copy link
Copy Markdown
Collaborator

Summary

Fixes the second-turn 400 INVALID_ARGUMENT that blocks every multi-turn Claude Code session against any Gemini 3.x model. Found 2026-07-21 while onboarding gemini-3.6-flash + gemini-3.5-flash-lite; reproduces identically against the already-deployed gemini-3.1-pro-preview too.

Root cause

Same multi-SDK report class — Google GenAI SDK #711, langchain-google #1570, gemini-cli #8003, #8011 — all about Google's thought_signature bytes field and its base64 wire shape.

Upstream flow is OK end-to-end:

  1. Gemini's response carries thought_signature as a base64url-encoded ASCII string in the JSON.
  2. embedSignatureInID smugs those bytes into the tool-call id field — base64-encodes the bytes of the (already-ASCII) base64 string, surfaces as __thought__<base64> suffix.
  3. On the next request the Anthropic-side tool_use id carries the same suffix unchanged across the boundary.
  4. extractSignatureFromID does DecodeString once, returning the original raw bytes of the upstream signature — exactly what Google gave us, just unpacked from the carrier.

The bug is at the JSON emit boundary. Step 4 returns those raw bytes as a Go string. Then sse.WriteJSONString (called by pw.Str) does:

for _, c := range s {
    switch {
    case c < 0x20, c == '
', c == '
':
        writeJSONUnicodeEscape(w, c)
    default:
        w.WriteRune(c)   // Go's range over a non-UTF-8 string converts invalid UTF-8 sequences to U+FFFD
    }
}

A real Gemini thought_signature contains byte sequences that aren't valid UTF-8 — every such sequence silently gets replaced with U+FFFD. On the next turn Google sees a string of + accented/non-ASCII chars, fails to base64-decode it, and returns Invalid value at 'contents[1].parts[0].thought_signature' (TYPE_BYTES), Base64 decoding failed for ....

Fix

encodeSignatureForJSON re-encodes values containing non-ASCII bytes (c >= 0x80) through base64.RawURLEncoding at the three pw.Str(sig) emit sites. Plain-ASCII values (test fixtures like "ANTHROPIC_SIG", and also the upstream-delivered base64 values themselves which never trip the carrier round-trip) bypass the rewrap since they wire safely as-is.

The non-ASCII round-trip is therefore base64s once for the carrier, then base64s again for the JSON wire: at Google, the JSON-rest layer auto-decodes the outer base64 to bytes — net zero extra encodings on the contract. Same upstream value reaches Google on the second turn that was served on the first.

Tests

  • TestEncodeSignatureForJSON_PreservesNonASCIISignatureBytes — table-driven unit test on the helper covering the four cases (ASCII pass-through, base64url pass-through, non-ASCII raw bytes, mixed printable/non-ASCII).
  • TestPrepareGemini_ThoughtSignatureCarrierSurvivesMultiTurn — end-to-end repro through PrepareGemini. Builds a non-UTF-8 signature, embeds it in a tool-use id, exercises the full multi-turn translate path, asserts the emitted thought_signature JSON value base64-decodes back to the exact original bytes.
  • TestSanityCarrierRoundTripLocked — locks the carrier convention (delimiter string + decode path) so the regression test isn't silently testing the wrong shape.

Existing signature round-trip tests (TestPrepareGemini_FromAnthropic_SystemAndToolUseRoundTripsSignature, TestPrepareGemini_FromAnthropic_ToolUseSignatureSurvivesUnknownFieldStripping, TestSanitizeToolUseID_PreservesLongThoughtSignatureID) all still pass — they use ASCII fixtures that the helper correctly bypasses.

go build ./..., go vet ./..., and the full go test ./... (every package) pass.

Workaround for clients in the meantime

If you can't ship this immediately and need to unblock real multi-turn traffic against Gemini 3.x on your local branch, dropping the body through echo -n "$sig" | xxd -p | xxd -p -r | base64 -w0 (one decode-then-re-encode round-trip) before writing has been used as a manual workaround in test scripts.

🤖 Generated with Claude Code

…t JSON emit

Google's thought_signature field is typed bytes; the JSON-rest layer
auto-base64-decodes the string value on the wire. Upstream values
arrive base64url-encoded ASCII and pass through cleanly. Carrier
round-trips (embedSignatureInID on response -> ... ->
extractSignatureFromID on the next request) recover the original raw
bytes as a Go string whose UTF-8 representation includes U+FFFD
replacement chars (Go's `for _, c := range s` over a non-UTF-8 string
replaces every invalid byte sequence). When that string then went
through sse.WriteJSONString / pw.Str, Google's base64 decoder rejected
the result at the next turn. Found while onboarding
gemini-3.6-flash/3.5-flash-lite: every multi-turn Claude Code session
through any Gemini 3.x model had been silently failing this way.

Same report class as Google GenAI SDK #711, langchain-google #1570,
and gemini-cli #8003/#8011 — multi-SDK coverage of the same wire-shape
contract.

encodeSignatureForJSON re-encodes values containing non-ASCII bytes
through base64 at the three emit sites (OpenAI->Gemini tool-call
parts, Anthropic text parts, Anthropic tool-call parts) so the wire
shape survives. Plain ASCII values (the test fixtures + the upstream
base64 strings themselves) bypass the rewrap since they wire as-is.
Test coverage spans both the helper unit test and an end-to-end
multi-turn PrepareGemini test using a non-UTF-8 signature and
asserting the wire value base64-decodes back to the original bytes.
@claude

claude Bot commented Jul 21, 2026

Copy link
Copy Markdown

Claude finished @steventohme's task —— View job


  • Run git diff and analyze added comment lines
  • Flag verbose comment blocks (3+ lines)
  • Post review suggestions

Posted 4 comment-length suggestions (advisory, won't block merge).

@workweave-bot workweave-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Advisory only — comment-length nits. Won't block merge.

Comment thread internal/translate/emit_gemini.go Outdated
Comment thread internal/translate/thought_signature_carrier_external_test.go Outdated
Comment thread internal/translate/tool_call_id_internal_test.go Outdated
Comment thread internal/translate/tool_call_id_internal_test.go Outdated
@greptile-apps

greptile-apps Bot commented Jul 21, 2026

Copy link
Copy Markdown

T-Rex T-Rex Logs

What T-Rex did

  • Reproduced the ASCII raw bytes skip encoding issue in a focused carrier test that exercises ParseAnthropic and PrepareGemini, showing thoughtSignature as escaped raw controls instead of base64url and that base64url decoding fails at byte 0.
  • Validated that the failure is resolved in HEAD by running the after-state tests, with key tests passing at exit code 0 and consistent results across HEAD^ and HEAD.

View all artifacts

T-Rex Ran code and verified through T-Rex

Reviews (1): Last reviewed commit: "fix(translate): re-encode non-ASCII thou..." | Re-trigger Greptile

Comment thread internal/translate/emit_gemini.go Outdated
…ents

Fixed (greptile P1 'ASCII Raw Bytes Skip Encoding'):
- encodeSignatureForJSON now passes a value through only when it is already
  valid base64 (std/URL, padded/raw); anything else — including all-ASCII
  raw bytes like \x00\x01ABC — is re-encoded as base64url. The previous
  >=0x80 heuristic let ASCII raw bytes reach the wire unencoded.
- Added ascii-raw-bytes case to TestEncodeSignatureForJSON; padded ASCII
  test fixtures (ANTHROPIC_SIG_00, OPAQUE_GEMINI_SIG000, SIG_ROUNDTRIP000)
  to valid base64 lengths so wire-form pass-through stays asserted.

Applied from workweave-bot review (comment-length nits):
- emit_gemini.go: encodeSignatureForJSON doc comment 10 -> 5 lines
- thought_signature_carrier_external_test.go: test doc 9 -> 3 lines
- tool_call_id_internal_test.go: regression comment 10 -> 3 lines, moved-test
  note 5 -> 2 lines

Also fixes the CI gofmt failure in tool_call_id_internal_test.go.

Co-authored-by: Cursor <cursoragent@cursor.com>
@claude

claude Bot commented Jul 22, 2026

Copy link
Copy Markdown

Claude finished @steventohme's task —— View job


  • Run git diff and analyze added comment lines
  • Flag verbose comment blocks (3+ lines)
  • Post review suggestions (or stay silent if nothing to flag)

All added comment blocks are ≤ 5 lines and address genuine non-obvious WHYs. Prior suggestions from the first run were applied in 678345d. Nothing to flag.

View job

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants