diff --git a/pkgs-set/pkgs/cliproxy-codex-namespace.patch b/pkgs-set/pkgs/cliproxy-codex-namespace.patch deleted file mode 100644 index 95c30bf..0000000 --- a/pkgs-set/pkgs/cliproxy-codex-namespace.patch +++ /dev/null @@ -1,73 +0,0 @@ -diff --git a/internal/runtime/executor/codex_executor.go b/internal/runtime/executor/codex_executor.go -index 457a210b..d5f1d1dc 100644 ---- a/internal/runtime/executor/codex_executor.go -+++ b/internal/runtime/executor/codex_executor.go -@@ -1144,6 +1144,7 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re - body, _ = sjson.DeleteBytes(body, "safety_identifier") - body, _ = sjson.DeleteBytes(body, "stream_options") - body = normalizeCodexInstructions(body) -+ body = sanitizeCodexResponseInputNamespaces(body) - if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) - } -@@ -1415,6 +1416,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au - body, _ = sjson.DeleteBytes(body, "stream_options") - body = helps.SetStringIfDifferent(body, "model", baseModel) - body = normalizeCodexInstructions(body) -+ body = sanitizeCodexResponseInputNamespaces(body) - if e.cfg == nil || e.cfg.DisableImageGeneration == config.DisableImageGenerationOff { - body = ensureImageGenerationTool(body, baseModel, auth, opts.Headers) - } -@@ -1567,6 +1569,25 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au - return &cliproxyexecutor.StreamResult{Headers: httpResp.Header.Clone(), Chunks: out}, nil - } - -+func sanitizeCodexResponseInputNamespaces(body []byte) []byte { -+ items := gjson.GetBytes(body, "input") -+ if !items.IsArray() { -+ return body -+ } -+ -+ for index, item := range items.Array() { -+ typeName := item.Get("type").String() -+ if typeName != "function_call" && typeName != "custom_tool_call" { -+ continue -+ } -+ updated, errDelete := sjson.DeleteBytes(body, fmt.Sprintf("input.%d.namespace", index)) -+ if errDelete == nil { -+ body = updated -+ } -+ } -+ return body -+} -+ - func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { - baseModel := thinking.ParseSuffix(req.Model).ModelName - -diff --git a/internal/runtime/executor/codex_executor_namespace_test.go b/internal/runtime/executor/codex_executor_namespace_test.go -new file mode 100644 -index 00000000..12fa7c67 ---- /dev/null -+++ b/internal/runtime/executor/codex_executor_namespace_test.go -@@ -0,0 +1,21 @@ -+package executor -+ -+import ( -+ "testing" -+ -+ "github.com/tidwall/gjson" -+) -+ -+func TestSanitizeCodexResponseInputNamespaces(t *testing.T) { -+ body := []byte(`{"input":[{"type":"function_call","namespace":"exec","name":"run"},{"type":"custom_tool_call","namespace":"tools","name":"lookup"},{"type":"message","namespace":"keep"}]}`) -+ -+ got := sanitizeCodexResponseInputNamespaces(body) -+ for _, path := range []string{"input.0.namespace", "input.1.namespace"} { -+ if gjson.GetBytes(got, path).Exists() { -+ t.Fatalf("%s was not removed: %s", path, got) -+ } -+ } -+ if namespace := gjson.GetBytes(got, "input.2.namespace").String(); namespace != "keep" { -+ t.Fatalf("message namespace = %q, want keep", namespace) -+ } -+} diff --git a/pkgs-set/pkgs/cliproxy-gemini-3.6-flash.patch b/pkgs-set/pkgs/cliproxy-gemini-3.6-flash.patch deleted file mode 100644 index 8b556c8..0000000 --- a/pkgs-set/pkgs/cliproxy-gemini-3.6-flash.patch +++ /dev/null @@ -1,30 +0,0 @@ ---- a/internal/registry/models/models.json -+++ b/internal/registry/models/models.json -@@ -2405,6 +2405,27 @@ - "high" - ] - } -+ }, -+ { -+ "id": "gemini-3.6-flash", -+ "object": "model", -+ "owned_by": "antigravity", -+ "type": "antigravity", -+ "display_name": "Gemini 3.6 Flash", -+ "name": "gemini-3.6-flash", -+ "description": "Gemini 3.6 Flash", -+ "context_length": 1048576, -+ "max_completion_tokens": 65535, -+ "thinking": { -+ "min": 1, -+ "max": 65535, -+ "dynamic_allowed": true, -+ "levels": [ -+ "low", -+ "medium", -+ "high" -+ ] -+ } - } - ], - "xai": [ diff --git a/pkgs-set/pkgs/cliproxy-opus-5.patch b/pkgs-set/pkgs/cliproxy-opus-5.patch deleted file mode 100644 index 71b7948..0000000 --- a/pkgs-set/pkgs/cliproxy-opus-5.patch +++ /dev/null @@ -1,26 +0,0 @@ ---- a/internal/registry/models/models.json -+++ b/internal/registry/models/models.json -@@ -1,6 +1,23 @@ - { - "claude": [ - { -+ "id": "claude-opus-5", -+ "object": "model", -+ "created": 1784923200, -+ "owned_by": "anthropic", -+ "type": "claude", -+ "display_name": "Claude Opus 5", -+ "description": "Anthropic's Claude Opus 5 model", -+ "context_length": 1000000, -+ "max_completion_tokens": 128000, -+ "thinking": { -+ "min": 1024, -+ "max": 128000, -+ "zero_allowed": true, -+ "levels": ["low", "medium", "high", "xhigh", "max"] -+ } -+ }, -+ { - "id": "claude-haiku-4-5-20251001", - "object": "model", - "created": 1759276800, diff --git a/pkgs-set/pkgs/cliproxy-pr-4525.patch b/pkgs-set/pkgs/cliproxy-pr-4525.patch deleted file mode 100644 index 82ff274..0000000 --- a/pkgs-set/pkgs/cliproxy-pr-4525.patch +++ /dev/null @@ -1,10647 +0,0 @@ -From b02edbf73133fbdbe1ccacf1477ef553fc10aa43 Mon Sep 17 00:00:00 2001 -From: sususu -Date: Fri, 24 Jul 2026 09:16:26 +0800 -Subject: [PATCH] fix(antigravity): preserve complete reasoning replay - ---- - .../antigravity_reasoning_replay_cache.go | 331 +++- - ...antigravity_reasoning_replay_cache_test.go | 488 ++++++ - .../runtime/executor/antigravity_executor.go | 188 ++- - .../antigravity_executor_signature_test.go | 177 ++ - .../executor/antigravity_reasoning_replay.go | 1259 ++++++++++++-- - ...antigravity_reasoning_replay_clear_test.go | 2 +- - .../antigravity_reasoning_replay_test.go | 1449 ++++++++++++++++- - internal/signature/gemini_sanitize.go | 87 +- - internal/signature/gemini_sanitize_test.go | 78 +- - internal/signature/gemini_validation.go | 66 +- - internal/signature/gemini_validation_test.go | 105 +- - .../signature/provider_compatibility_test.go | 29 +- - .../claude/antigravity_claude_request.go | 188 ++- - .../claude/antigravity_claude_request_test.go | 421 ++++- - .../claude/antigravity_claude_response.go | 222 ++- - .../antigravity_claude_response_test.go | 440 ++++- - .../claude/signature_validation.go | 184 ++- - .../claude/signature_validation_test.go | 84 + - .../gemini/antigravity_gemini_request_test.go | 39 +- - ...tigravity_openai-responses_request_test.go | 87 +- - .../gemini_openai-responses_request.go | 533 ++++-- - .../gemini_openai-responses_request_test.go | 605 ++++++- - .../gemini_openai-responses_response.go | 601 ++++++- - .../gemini_openai-responses_response_test.go | 879 +++++++++- - .../openai/responses/signature_carrier.go | 182 +++ - .../responses/signature_carrier_test.go | 157 ++ - internal/util/claude_tool_id.go | 44 + - internal/util/claude_tool_id_test.go | 21 + - 28 files changed, 8423 insertions(+), 523 deletions(-) - create mode 100644 internal/cache/antigravity_reasoning_replay_cache_test.go - create mode 100644 internal/translator/antigravity/claude/signature_validation_test.go - create mode 100644 internal/translator/gemini/openai/responses/signature_carrier.go - create mode 100644 internal/translator/gemini/openai/responses/signature_carrier_test.go - create mode 100644 internal/util/claude_tool_id_test.go - -diff --git a/internal/cache/antigravity_reasoning_replay_cache.go b/internal/cache/antigravity_reasoning_replay_cache.go -index a9f58c28d38..194d9142e0b 100644 ---- a/internal/cache/antigravity_reasoning_replay_cache.go -+++ b/internal/cache/antigravity_reasoning_replay_cache.go -@@ -1,8 +1,11 @@ - package cache - - import ( -+ "bytes" - "context" -+ "crypto/rand" - "encoding/json" -+ "fmt" - "sort" - "strings" - "sync" -@@ -28,22 +31,51 @@ const ( - AntigravityReasoningReplayCacheEvictBatchSize = 128 - - minAntigravityThoughtSignatureReplayLen = 16 -+ -+ // AntigravityReasoningReplayCacheMaxItemsPerEntry and MaxBytesPerEntry -+ // bound one logical conversation. Oversized chains are not partially cached, -+ // because dropping an arbitrary prefix would break native signature ordering. -+ AntigravityReasoningReplayCacheMaxItemsPerEntry = 4096 -+ AntigravityReasoningReplayCacheMaxBytesPerEntry = 16 << 20 -+ -+ // JSON encodes each normalized []byte item as base64. Leave enough room for -+ // that expansion while rejecting oversized Home values before unmarshalling. -+ antigravityReasoningReplayCacheMaxSerializedBytes = 24 << 20 - ) - - type antigravityReasoningReplayEntry struct { - Items [][]byte - Timestamp time.Time -+ Revision uint64 -+ Branch string -+ Deleted bool -+} -+ -+const antigravityReasoningReplayGenerationItemType = "cpa_antigravity_replay_generation" -+ -+// AntigravityReasoningReplaySnapshot identifies the exact replay state read for -+// one request. Its fields are intentionally opaque outside this package. -+type AntigravityReasoningReplaySnapshot struct { -+ raw []byte -+ items [][]byte -+ loaded bool -+ found bool -+ revision uint64 -+ branch string -+ evictionEpoch uint64 - } - - var ( -- antigravityReasoningReplayMu sync.Mutex -- antigravityReasoningReplayEntries = make(map[string]antigravityReasoningReplayEntry) -+ antigravityReasoningReplayMu sync.Mutex -+ antigravityReasoningReplayEntries = make(map[string]antigravityReasoningReplayEntry) -+ antigravityReasoningReplayNextRevision uint64 -+ antigravityReasoningReplayEvictionEpoch uint64 - ) - - type antigravityReasoningReplayKVClient interface { - KVGet(ctx context.Context, key string) ([]byte, bool, error) - KVSet(ctx context.Context, key string, value []byte, opts homekv.KVSetOptions) (bool, error) -- KVDel(ctx context.Context, keys ...string) (int64, error) -+ KVCompareAndSwap(ctx context.Context, key string, expected []byte, expectedExists bool, value []byte, ttl time.Duration) (bool, error) - KVExpire(ctx context.Context, key string, ttl time.Duration) (bool, error) - } - -@@ -79,7 +111,7 @@ func CacheAntigravityReasoningReplayItemsBestEffort(ctx context.Context, modelNa - log.Errorf("home kv best-effort antigravity reasoning replay set failed prefix=cpa:antigravity:*: %v", errClient) - return false - } -- raw, errMarshal := json.Marshal(normalized) -+ raw, errMarshal := marshalAntigravityReasoningReplayHomeValue(normalized, "") - if errMarshal != nil { - log.Errorf("home kv best-effort antigravity reasoning replay set failed prefix=cpa:antigravity:*: %v", errMarshal) - return false -@@ -96,9 +128,12 @@ func CacheAntigravityReasoningReplayItemsBestEffort(ctx context.Context, modelNa - now := time.Now() - antigravityReasoningReplayMu.Lock() - defer antigravityReasoningReplayMu.Unlock() -+ antigravityReasoningReplayNextRevision++ - antigravityReasoningReplayEntries[key] = antigravityReasoningReplayEntry{ - Items: normalized, - Timestamp: now, -+ Revision: antigravityReasoningReplayNextRevision, -+ Branch: newAntigravityReasoningReplayGeneration(), - } - if len(antigravityReasoningReplayEntries) > AntigravityReasoningReplayCacheMaxEntries { - evictOldestAntigravityReasoningReplayEntries(AntigravityReasoningReplayCacheEvictBatchSize) -@@ -126,27 +161,70 @@ func GetAntigravityReasoningReplayItems(modelName, sessionKey string) ([][]byte, - - // GetAntigravityReasoningReplayItemsRequired retrieves replay items for request-time paths. - func GetAntigravityReasoningReplayItemsRequired(ctx context.Context, modelName, sessionKey string) ([][]byte, bool, error) { -+ items, _, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(ctx, modelName, sessionKey) -+ return items, found, errGet -+} -+ -+// GetAntigravityReasoningReplayItemsWithSnapshotRequired retrieves replay items -+// and the exact cache state that guarded this request. -+func GetAntigravityReasoningReplayItemsWithSnapshotRequired(ctx context.Context, modelName, sessionKey string) ([][]byte, AntigravityReasoningReplaySnapshot, bool, error) { - key := antigravityReasoningReplayCacheKey(modelName, sessionKey) - if key == "" { -- return nil, false, nil -+ return nil, AntigravityReasoningReplaySnapshot{}, false, nil - } - client, homeMode, errClient := currentAntigravityReasoningReplayKVClient() - if homeMode { - if errClient != nil { -- return nil, false, errClient -+ return nil, AntigravityReasoningReplaySnapshot{}, false, errClient -+ } -+ kvKey := antigravityReasoningReplayKVKey(modelName, sessionKey) -+ var raw []byte -+ found := false -+ for attempt := 0; attempt < 4; attempt++ { -+ currentRaw, currentFound, errGet := client.KVGet(ctx, kvKey) -+ if errGet != nil { -+ return nil, AntigravityReasoningReplaySnapshot{loaded: true}, false, errGet -+ } -+ if currentFound { -+ raw = currentRaw -+ found = true -+ break -+ } -+ reservation := newAntigravityReasoningReplayTombstone() -+ swapped, errReserve := client.KVCompareAndSwap(ctx, kvKey, nil, false, reservation, AntigravityReasoningReplayCacheTTL) -+ if errReserve != nil { -+ return nil, AntigravityReasoningReplaySnapshot{loaded: true}, false, errReserve -+ } -+ if swapped { -+ raw = reservation -+ found = true -+ break -+ } -+ } -+ if !found { -+ return nil, AntigravityReasoningReplaySnapshot{loaded: true}, false, fmt.Errorf("could not fence absent antigravity reasoning replay state") -+ } -+ if len(raw) > antigravityReasoningReplayCacheMaxSerializedBytes { -+ return nil, AntigravityReasoningReplaySnapshot{loaded: true, found: true}, false, nil - } -- raw, found, errGet := client.KVGet(ctx, antigravityReasoningReplayKVKey(modelName, sessionKey)) -- if errGet != nil || !found { -- return nil, false, errGet -+ snapshot := AntigravityReasoningReplaySnapshot{raw: append([]byte(nil), raw...), loaded: true, found: true} -+ homeItems, deleted, _, branch, okDecode := decodeAntigravityReasoningReplayHomeValue(raw) -+ snapshot.branch = branch -+ if !okDecode || deleted || len(homeItems) == 0 { -+ return nil, snapshot, false, nil - } -- var homeItems [][]byte -- if errUnmarshal := json.Unmarshal(raw, &homeItems); errUnmarshal != nil { -- return nil, false, errUnmarshal -+ if len(homeItems) > AntigravityReasoningReplayCacheMaxItemsPerEntry { -+ return nil, snapshot, false, nil - } -- if _, errExpire := client.KVExpire(ctx, antigravityReasoningReplayKVKey(modelName, sessionKey), AntigravityReasoningReplayCacheTTL); errExpire != nil { -- return nil, false, errExpire -+ normalized, okNormalize := normalizeAntigravityReasoningReplayItems(homeItems) -+ if !okNormalize || len(normalized) != len(homeItems) { -+ return nil, snapshot, false, nil - } -- return cloneAntigravityReasoningReplayItems(homeItems), true, nil -+ snapshot.items = cloneAntigravityReasoningReplayItems(normalized) -+ if _, errExpire := client.KVExpire(ctx, kvKey, AntigravityReasoningReplayCacheTTL); errExpire != nil { -+ return nil, snapshot, false, errExpire -+ } -+ return normalized, snapshot, true, nil - } - - cacheCleanupOnce.Do(startCacheCleanup) -@@ -155,15 +233,131 @@ func GetAntigravityReasoningReplayItemsRequired(ctx context.Context, modelName, - defer antigravityReasoningReplayMu.Unlock() - entry, ok := antigravityReasoningReplayEntries[key] - if !ok { -- return nil, false, nil -+ return nil, AntigravityReasoningReplaySnapshot{loaded: true, evictionEpoch: antigravityReasoningReplayEvictionEpoch}, false, nil - } - if now.Sub(entry.Timestamp) > AntigravityReasoningReplayCacheTTL { -+ antigravityReasoningReplayEvictionEpoch++ - delete(antigravityReasoningReplayEntries, key) -- return nil, false, nil -+ return nil, AntigravityReasoningReplaySnapshot{loaded: true, evictionEpoch: antigravityReasoningReplayEvictionEpoch}, false, nil - } - entry.Timestamp = now - antigravityReasoningReplayEntries[key] = entry -- return cloneAntigravityReasoningReplayItems(entry.Items), true, nil -+ snapshot := AntigravityReasoningReplaySnapshot{loaded: true, found: true, revision: entry.Revision, branch: entry.Branch, evictionEpoch: antigravityReasoningReplayEvictionEpoch} -+ if entry.Deleted || len(entry.Items) == 0 { -+ return nil, snapshot, false, nil -+ } -+ snapshot.items = cloneAntigravityReasoningReplayItems(entry.Items) -+ return cloneAntigravityReasoningReplayItems(entry.Items), snapshot, true, nil -+} -+ -+// ReplaceAntigravityReasoningReplayItemsIfUnchanged publishes a completed chain -+// only when no newer request has changed the state read by this request. -+func ReplaceAntigravityReasoningReplayItemsIfUnchanged(ctx context.Context, modelName, sessionKey string, snapshot AntigravityReasoningReplaySnapshot, items [][]byte) (bool, error) { -+ key := antigravityReasoningReplayCacheKey(modelName, sessionKey) -+ if key == "" { -+ return false, nil -+ } -+ normalized, okNormalize := normalizeAntigravityReasoningReplayItems(items) -+ if !okNormalize { -+ return false, fmt.Errorf("invalid antigravity reasoning replay items") -+ } -+ if !snapshot.loaded { -+ return CacheAntigravityReasoningReplayItemsBestEffort(ctx, modelName, sessionKey, normalized), nil -+ } -+ client, homeMode, errClient := currentAntigravityReasoningReplayKVClient() -+ if homeMode { -+ if errClient != nil { -+ return false, errClient -+ } -+ kvKey := antigravityReasoningReplayKVKey(modelName, sessionKey) -+ expectedRaw := snapshot.raw -+ expectedFound := snapshot.found -+ branch := snapshot.branch -+ if branch == "" || !antigravityReasoningReplayItemsPrefix(snapshot.items, normalized) { -+ branch = newAntigravityReasoningReplayGeneration() -+ } -+ for attempt := 0; attempt < 4; attempt++ { -+ raw, errMarshal := marshalAntigravityReasoningReplayHomeValue(normalized, branch) -+ if errMarshal != nil { -+ return false, errMarshal -+ } -+ swapped, errCAS := client.KVCompareAndSwap(ctx, kvKey, expectedRaw, expectedFound, raw, AntigravityReasoningReplayCacheTTL) -+ if errCAS != nil || swapped { -+ return swapped, errCAS -+ } -+ currentRaw, currentFound, errGet := client.KVGet(ctx, kvKey) -+ if errGet != nil || !currentFound { -+ return false, errGet -+ } -+ if len(currentRaw) > antigravityReasoningReplayCacheMaxSerializedBytes { -+ return false, nil -+ } -+ currentItems, deleted, _, currentBranch, okDecode := decodeAntigravityReasoningReplayHomeValue(currentRaw) -+ if !okDecode || deleted || snapshot.branch == "" || currentBranch != snapshot.branch { -+ return false, nil -+ } -+ normalizedCurrent, okNormalizeCurrent := normalizeAntigravityReasoningReplayItems(currentItems) -+ if !okNormalizeCurrent || len(normalizedCurrent) != len(currentItems) || !antigravityReasoningReplayItemsPrefix(normalizedCurrent, normalized) { -+ return false, nil -+ } -+ expectedRaw = currentRaw -+ expectedFound = true -+ } -+ return false, nil -+ } -+ -+ cacheCleanupOnce.Do(startCacheCleanup) -+ now := time.Now() -+ antigravityReasoningReplayMu.Lock() -+ defer antigravityReasoningReplayMu.Unlock() -+ entry, found := antigravityReasoningReplayEntries[key] -+ matchesSnapshot := found == snapshot.found && ((found && entry.Revision == snapshot.revision) || (!found && snapshot.evictionEpoch == antigravityReasoningReplayEvictionEpoch)) -+ isDescendant := found && !entry.Deleted && snapshot.branch != "" && entry.Branch == snapshot.branch && antigravityReasoningReplayItemsPrefix(entry.Items, normalized) -+ if !matchesSnapshot && !isDescendant { -+ return false, nil -+ } -+ branch := snapshot.branch -+ if branch == "" || (matchesSnapshot && !antigravityReasoningReplayItemsPrefix(snapshot.items, normalized)) { -+ branch = newAntigravityReasoningReplayGeneration() -+ } -+ antigravityReasoningReplayNextRevision++ -+ antigravityReasoningReplayEntries[key] = antigravityReasoningReplayEntry{Items: normalized, Timestamp: now, Revision: antigravityReasoningReplayNextRevision, Branch: branch} -+ if len(antigravityReasoningReplayEntries) > AntigravityReasoningReplayCacheMaxEntries { -+ evictOldestAntigravityReasoningReplayEntries(AntigravityReasoningReplayCacheEvictBatchSize) -+ } -+ return true, nil -+} -+ -+// DeleteAntigravityReasoningReplayItemsIfUnchanged clears replay state only when -+// it still matches the state read for this request. -+func DeleteAntigravityReasoningReplayItemsIfUnchanged(ctx context.Context, modelName, sessionKey string, snapshot AntigravityReasoningReplaySnapshot) (bool, error) { -+ key := antigravityReasoningReplayCacheKey(modelName, sessionKey) -+ if key == "" { -+ return false, nil -+ } -+ if !snapshot.loaded { -+ return true, DeleteAntigravityReasoningReplayItemRequired(ctx, modelName, sessionKey) -+ } -+ client, homeMode, errClient := currentAntigravityReasoningReplayKVClient() -+ if homeMode { -+ if errClient != nil { -+ return false, errClient -+ } -+ return client.KVCompareAndSwap(ctx, antigravityReasoningReplayKVKey(modelName, sessionKey), snapshot.raw, snapshot.found, newAntigravityReasoningReplayTombstone(), AntigravityReasoningReplayCacheTTL) -+ } -+ cacheCleanupOnce.Do(startCacheCleanup) -+ antigravityReasoningReplayMu.Lock() -+ defer antigravityReasoningReplayMu.Unlock() -+ entry, found := antigravityReasoningReplayEntries[key] -+ if found != snapshot.found || (found && entry.Revision != snapshot.revision) || (!found && snapshot.evictionEpoch != antigravityReasoningReplayEvictionEpoch) { -+ return false, nil -+ } -+ antigravityReasoningReplayNextRevision++ -+ antigravityReasoningReplayEntries[key] = antigravityReasoningReplayEntry{Timestamp: time.Now(), Revision: antigravityReasoningReplayNextRevision, Branch: newAntigravityReasoningReplayGeneration(), Deleted: true} -+ if len(antigravityReasoningReplayEntries) > AntigravityReasoningReplayCacheMaxEntries { -+ evictOldestAntigravityReasoningReplayEntries(AntigravityReasoningReplayCacheEvictBatchSize) -+ } -+ return true, nil - } - - // DeleteAntigravityReasoningReplayItem removes one replay item after upstream rejects -@@ -185,19 +379,82 @@ func DeleteAntigravityReasoningReplayItemRequired(ctx context.Context, modelName - if errClient != nil { - return errClient - } -- _, errDel := client.KVDel(ctx, antigravityReasoningReplayKVKey(modelName, sessionKey)) -- return errDel -+ _, errSet := client.KVSet(ctx, antigravityReasoningReplayKVKey(modelName, sessionKey), newAntigravityReasoningReplayTombstone(), homekv.KVSetOptions{EX: AntigravityReasoningReplayCacheTTL}) -+ return errSet - } -+ cacheCleanupOnce.Do(startCacheCleanup) - antigravityReasoningReplayMu.Lock() -- delete(antigravityReasoningReplayEntries, key) -+ antigravityReasoningReplayNextRevision++ -+ antigravityReasoningReplayEntries[key] = antigravityReasoningReplayEntry{Timestamp: time.Now(), Revision: antigravityReasoningReplayNextRevision, Branch: newAntigravityReasoningReplayGeneration(), Deleted: true} -+ if len(antigravityReasoningReplayEntries) > AntigravityReasoningReplayCacheMaxEntries { -+ evictOldestAntigravityReasoningReplayEntries(AntigravityReasoningReplayCacheEvictBatchSize) -+ } - antigravityReasoningReplayMu.Unlock() - return nil - } - -+func newAntigravityReasoningReplayGeneration() string { -+ var nonce [16]byte -+ if _, errRead := rand.Read(nonce[:]); errRead != nil { -+ return fmt.Sprintf("fallback-%d", time.Now().UnixNano()) -+ } -+ return fmt.Sprintf("%x", nonce[:]) -+} -+ -+func marshalAntigravityReasoningReplayHomeValue(items [][]byte, branch string) ([]byte, error) { -+ if branch == "" { -+ branch = newAntigravityReasoningReplayGeneration() -+ } -+ marker := []byte(`{"type":"","generation":"","branch":""}`) -+ marker, _ = sjson.SetBytes(marker, "type", antigravityReasoningReplayGenerationItemType) -+ marker, _ = sjson.SetBytes(marker, "generation", newAntigravityReasoningReplayGeneration()) -+ marker, _ = sjson.SetBytes(marker, "branch", branch) -+ stored := make([][]byte, 0, len(items)+1) -+ stored = append(stored, marker) -+ stored = append(stored, items...) -+ return json.Marshal(stored) -+} -+ -+func decodeAntigravityReasoningReplayHomeValue(raw []byte) (items [][]byte, deleted bool, generation, branch string, ok bool) { -+ if errUnmarshal := json.Unmarshal(raw, &items); errUnmarshal != nil { -+ return nil, false, "", "", false -+ } -+ if len(items) == 0 || strings.TrimSpace(gjson.GetBytes(items[0], "type").String()) != antigravityReasoningReplayGenerationItemType { -+ return items, false, "", "", true -+ } -+ marker := gjson.ParseBytes(items[0]) -+ deleted = marker.Get("deleted").Bool() -+ generation = strings.TrimSpace(marker.Get("generation").String()) -+ branch = strings.TrimSpace(marker.Get("branch").String()) -+ return items[1:], deleted, generation, branch, true -+} -+ -+func antigravityReasoningReplayItemsPrefix(prefix, items [][]byte) bool { -+ if len(prefix) > len(items) { -+ return false -+ } -+ for index := range prefix { -+ if !bytes.Equal(prefix[index], items[index]) { -+ return false -+ } -+ } -+ return true -+} -+ -+func newAntigravityReasoningReplayTombstone() []byte { -+ marker := []byte(`{"type":"","generation":"","branch":"","deleted":true}`) -+ marker, _ = sjson.SetBytes(marker, "type", antigravityReasoningReplayGenerationItemType) -+ marker, _ = sjson.SetBytes(marker, "generation", newAntigravityReasoningReplayGeneration()) -+ marker, _ = sjson.SetBytes(marker, "branch", newAntigravityReasoningReplayGeneration()) -+ raw, _ := json.Marshal([][]byte{marker}) -+ return raw -+} -+ - // ClearAntigravityReasoningReplayCache clears all Antigravity reasoning replay state. - func ClearAntigravityReasoningReplayCache() { - antigravityReasoningReplayMu.Lock() - antigravityReasoningReplayEntries = make(map[string]antigravityReasoningReplayEntry) -+ antigravityReasoningReplayEvictionEpoch++ - antigravityReasoningReplayMu.Unlock() - } - -@@ -217,10 +474,18 @@ func antigravityReasoningReplayKVKey(modelName, sessionKey string) string { - } - - func normalizeAntigravityReasoningReplayItems(items [][]byte) ([][]byte, bool) { -+ if len(items) > AntigravityReasoningReplayCacheMaxItemsPerEntry { -+ return nil, false -+ } - normalized := make([][]byte, 0, len(items)) -+ totalBytes := 0 - for _, item := range items { - normalizedItem, ok := normalizeAntigravityReasoningReplayItem(item) - if ok { -+ totalBytes += len(normalizedItem) -+ if totalBytes > AntigravityReasoningReplayCacheMaxBytesPerEntry { -+ return nil, false -+ } - normalized = append(normalized, normalizedItem) - } - } -@@ -244,7 +509,7 @@ func normalizeAntigravityThoughtSignatureReplayItem(itemResult gjson.Result) ([] - if sig == "" { - sig = strings.TrimSpace(itemResult.Get("thought_signature").String()) - } -- if sig == "" || len(sig) < minAntigravityThoughtSignatureReplayLen { -+ if sig == "" || sig == "skip_thought_signature_validator" || len(sig) < minAntigravityThoughtSignatureReplayLen { - return nil, false - } - normalized := []byte(`{"type":"thought_signature"}`) -@@ -255,6 +520,18 @@ func normalizeAntigravityThoughtSignatureReplayItem(itemResult gjson.Result) ([] - if partIndex := itemResult.Get("partIndex"); partIndex.Type == gjson.Number { - normalized, _ = sjson.SetBytes(normalized, "partIndex", partIndex.Int()) - } -+ if targetKind := strings.TrimSpace(itemResult.Get("targetKind").String()); targetKind == "text" || targetKind == "thought" { -+ normalized, _ = sjson.SetBytes(normalized, "targetKind", targetKind) -+ } -+ if targetHash := strings.TrimSpace(itemResult.Get("targetHash").String()); targetHash != "" { -+ normalized, _ = sjson.SetBytes(normalized, "targetHash", targetHash) -+ } -+ if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Type == gjson.Number && targetOccurrence.Int() >= 0 { -+ normalized, _ = sjson.SetBytes(normalized, "targetOccurrence", targetOccurrence.Int()) -+ } -+ if contextHash := strings.TrimSpace(itemResult.Get("contextHash").String()); contextHash != "" { -+ normalized, _ = sjson.SetBytes(normalized, "contextHash", contextHash) -+ } - return normalized, true - } - -@@ -293,7 +570,7 @@ func normalizeAntigravityFunctionCallPartReplayItem(itemResult gjson.Result) ([] - normalized, _ = sjson.SetRawBytes(normalized, "args", []byte(args.Raw)) - } - sig := strings.TrimSpace(itemResult.Get("thoughtSignature").String()) -- if sig != "" { -+ if sig != "" && sig != "skip_thought_signature_validator" { - normalized, _ = sjson.SetBytes(normalized, "thoughtSignature", sig) - } - if contentIndex := itemResult.Get("contentIndex"); contentIndex.Type == gjson.Number { -@@ -302,6 +579,12 @@ func normalizeAntigravityFunctionCallPartReplayItem(itemResult gjson.Result) ([] - if partIndex := itemResult.Get("partIndex"); partIndex.Type == gjson.Number { - normalized, _ = sjson.SetBytes(normalized, "partIndex", partIndex.Int()) - } -+ if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Type == gjson.Number && targetOccurrence.Int() >= 0 { -+ normalized, _ = sjson.SetBytes(normalized, "targetOccurrence", targetOccurrence.Int()) -+ } -+ if contextHash := strings.TrimSpace(itemResult.Get("contextHash").String()); contextHash != "" { -+ normalized, _ = sjson.SetBytes(normalized, "contextHash", contextHash) -+ } - return normalized, true - } - -@@ -332,6 +615,7 @@ func evictOldestAntigravityReasoningReplayEntries(count int) { - count = len(candidates) - } - for i := 0; i < count; i++ { -+ antigravityReasoningReplayEvictionEpoch++ - delete(antigravityReasoningReplayEntries, candidates[i].key) - } - } -@@ -340,6 +624,7 @@ func purgeExpiredAntigravityReasoningReplayCache(now time.Time) { - antigravityReasoningReplayMu.Lock() - for key, entry := range antigravityReasoningReplayEntries { - if now.Sub(entry.Timestamp) > AntigravityReasoningReplayCacheTTL { -+ antigravityReasoningReplayEvictionEpoch++ - delete(antigravityReasoningReplayEntries, key) - } - } -diff --git a/internal/cache/antigravity_reasoning_replay_cache_test.go b/internal/cache/antigravity_reasoning_replay_cache_test.go -new file mode 100644 -index 00000000000..175d54110b8 ---- /dev/null -+++ b/internal/cache/antigravity_reasoning_replay_cache_test.go -@@ -0,0 +1,488 @@ -+package cache -+ -+import ( -+ "bytes" -+ "context" -+ "encoding/json" -+ "fmt" -+ "sync" -+ "testing" -+ "time" -+ -+ homekv "github.com/router-for-me/CLIProxyAPI/v7/internal/home" -+ "github.com/tidwall/gjson" -+) -+ -+type fakeAntigravityReasoningReplayKVClient struct { -+ mu sync.Mutex -+ values map[string][]byte -+ expireCount int -+} -+ -+func newFakeAntigravityReasoningReplayKVClient() *fakeAntigravityReasoningReplayKVClient { -+ return &fakeAntigravityReasoningReplayKVClient{values: make(map[string][]byte)} -+} -+ -+func (c *fakeAntigravityReasoningReplayKVClient) KVGet(_ context.Context, key string) ([]byte, bool, error) { -+ c.mu.Lock() -+ defer c.mu.Unlock() -+ value, ok := c.values[key] -+ return append([]byte(nil), value...), ok, nil -+} -+ -+func (c *fakeAntigravityReasoningReplayKVClient) KVSet(_ context.Context, key string, value []byte, _ homekv.KVSetOptions) (bool, error) { -+ c.mu.Lock() -+ defer c.mu.Unlock() -+ c.values[key] = append([]byte(nil), value...) -+ return true, nil -+} -+ -+func (c *fakeAntigravityReasoningReplayKVClient) KVCompareAndSwap(_ context.Context, key string, expected []byte, expectedExists bool, value []byte, _ time.Duration) (bool, error) { -+ c.mu.Lock() -+ defer c.mu.Unlock() -+ current, exists := c.values[key] -+ if exists != expectedExists || (exists && !bytes.Equal(current, expected)) { -+ return false, nil -+ } -+ c.values[key] = append([]byte(nil), value...) -+ return true, nil -+} -+ -+func (c *fakeAntigravityReasoningReplayKVClient) KVDel(_ context.Context, keys ...string) (int64, error) { -+ c.mu.Lock() -+ defer c.mu.Unlock() -+ var deleted int64 -+ for _, key := range keys { -+ if _, ok := c.values[key]; ok { -+ delete(c.values, key) -+ deleted++ -+ } -+ } -+ return deleted, nil -+} -+ -+func (c *fakeAntigravityReasoningReplayKVClient) KVExpire(_ context.Context, _ string, _ time.Duration) (bool, error) { -+ c.mu.Lock() -+ defer c.mu.Unlock() -+ c.expireCount++ -+ return true, nil -+} -+ -+func useFakeAntigravityReasoningReplayKVClient(t *testing.T, client *fakeAntigravityReasoningReplayKVClient, homeMode bool) { -+ t.Helper() -+ previous := currentAntigravityReasoningReplayKVClient -+ currentAntigravityReasoningReplayKVClient = func() (antigravityReasoningReplayKVClient, bool, error) { -+ return client, homeMode, nil -+ } -+ t.Cleanup(func() { -+ currentAntigravityReasoningReplayKVClient = previous -+ }) -+} -+ -+func antigravityReplayTestItem(signature string) []byte { -+ return []byte(`{"type":"thought_signature","contentIndex":1,"partIndex":0,"thoughtSignature":"` + signature + `"}`) -+} -+ -+func TestAntigravityReasoningReplayConditionalMutationRejectsStaleLocalSnapshot(t *testing.T) { -+ ClearAntigravityReasoningReplayCache() -+ t.Cleanup(ClearAntigravityReasoningReplayCache) -+ const model, session = "gemini-3.6-flash-high", "stale-local" -+ oldItem := antigravityReplayTestItem("old-local-signature-123456") -+ newItem := antigravityReplayTestItem("new-local-signature-123456") -+ staleItem := antigravityReplayTestItem("stale-local-signature-123456") -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{oldItem}) { -+ t.Fatal("initial cache write failed") -+ } -+ _, snapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errGet != nil || !found { -+ t.Fatalf("snapshot read failed: found=%v err=%v", found, errGet) -+ } -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{newItem}) { -+ t.Fatal("newer cache write failed") -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot, [][]byte{staleItem}); errSwap != nil || swapped { -+ t.Fatalf("stale replace = %v, %v; want false, nil", swapped, errSwap) -+ } -+ if deleted, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot); errDelete != nil || deleted { -+ t.Fatalf("stale delete = %v, %v; want false, nil", deleted, errDelete) -+ } -+ items, ok := GetAntigravityReasoningReplayItems(model, session) -+ if !ok || len(items) != 1 || !bytes.Contains(items[0], []byte("new-local-signature")) { -+ t.Fatalf("newer state was lost: %q, found=%v", items, ok) -+ } -+} -+ -+func TestAntigravityReasoningReplayNonPrefixReplaceRotatesLocalBranch(t *testing.T) { -+ ClearAntigravityReasoningReplayCache() -+ t.Cleanup(ClearAntigravityReasoningReplayCache) -+ const model, session = "gemini-3.6-flash-high", "non-prefix-local" -+ oldItem := antigravityReplayTestItem("non-prefix-old-signature-123456") -+ newItem := antigravityReplayTestItem("non-prefix-new-signature-123456") -+ latestItem := antigravityReplayTestItem("non-prefix-latest-signature-123456") -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{oldItem}) { -+ t.Fatal("old local write failed") -+ } -+ _, firstSnapshot, _, errFirstGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ _, staleSnapshot, _, errStaleGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errFirstGet != nil || errStaleGet != nil { -+ t.Fatalf("snapshot reads failed: %v, %v", errFirstGet, errStaleGet) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, firstSnapshot, [][]byte{newItem}); errSwap != nil || !swapped { -+ t.Fatalf("non-prefix local replace = %v, %v", swapped, errSwap) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{newItem, latestItem}); errSwap != nil || swapped { -+ t.Fatalf("stale local descendant crossed non-prefix reset: swapped=%v err=%v", swapped, errSwap) -+ } -+} -+ -+func TestAntigravityReasoningReplayConditionalReplaceAcceptsDescendantLocalChain(t *testing.T) { -+ ClearAntigravityReasoningReplayCache() -+ t.Cleanup(ClearAntigravityReasoningReplayCache) -+ const model, session = "gemini-3.6-flash-high", "descendant-local" -+ prefix := antigravityReplayTestItem("descendant-prefix-signature-123456") -+ middle := antigravityReplayTestItem("descendant-middle-signature-123456") -+ latest := antigravityReplayTestItem("descendant-latest-signature-123456") -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{prefix}) { -+ t.Fatal("prefix write failed") -+ } -+ _, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errGet != nil || !found { -+ t.Fatalf("prefix snapshot failed: found=%v err=%v", found, errGet) -+ } -+ _, firstSnapshot, _, errFirstGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errFirstGet != nil { -+ t.Fatal(errFirstGet) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, firstSnapshot, [][]byte{prefix, middle}); errSwap != nil || !swapped { -+ t.Fatalf("middle conditional write = %v, %v", swapped, errSwap) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{prefix, middle, latest}); errSwap != nil || !swapped { -+ t.Fatalf("descendant local replace = %v, %v; want true, nil", swapped, errSwap) -+ } -+ items, ok := GetAntigravityReasoningReplayItems(model, session) -+ if !ok || len(items) != 3 { -+ t.Fatalf("descendant local chain = %d items, found=%v", len(items), ok) -+ } -+} -+ -+func TestAntigravityReasoningReplayDescendantMergeRejectsResetBranchABA(t *testing.T) { -+ ClearAntigravityReasoningReplayCache() -+ t.Cleanup(ClearAntigravityReasoningReplayCache) -+ const model, session = "gemini-3.6-flash-high", "descendant-reset-aba" -+ prefix := antigravityReplayTestItem("reset-prefix-signature-123456") -+ middle := antigravityReplayTestItem("reset-middle-signature-123456") -+ staleLatest := antigravityReplayTestItem("reset-stale-signature-123456") -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{prefix}) { -+ t.Fatal("prefix write failed") -+ } -+ _, staleSnapshot, _, errStaleGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ _, firstSnapshot, _, errFirstGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errStaleGet != nil || errFirstGet != nil { -+ t.Fatalf("snapshot reads failed: %v, %v", errStaleGet, errFirstGet) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, firstSnapshot, [][]byte{prefix, middle}); errSwap != nil || !swapped { -+ t.Fatalf("middle write = %v, %v", swapped, errSwap) -+ } -+ _, currentSnapshot, _, errCurrentGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errCurrentGet != nil { -+ t.Fatal(errCurrentGet) -+ } -+ if deleted, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, currentSnapshot); errDelete != nil || !deleted { -+ t.Fatalf("branch reset = %v, %v", deleted, errDelete) -+ } -+ _, resetSnapshot, _, errResetGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errResetGet != nil { -+ t.Fatal(errResetGet) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, resetSnapshot, [][]byte{prefix}); errSwap != nil || !swapped { -+ t.Fatalf("new branch prefix write = %v, %v", swapped, errSwap) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{prefix, staleLatest}); errSwap != nil || swapped { -+ t.Fatalf("stale descendant crossed reset branch: swapped=%v err=%v", swapped, errSwap) -+ } -+} -+ -+func TestAntigravityReasoningReplayConditionalDeleteTombstoneBlocksStaleFirstWriter(t *testing.T) { -+ ClearAntigravityReasoningReplayCache() -+ t.Cleanup(ClearAntigravityReasoningReplayCache) -+ const model, session = "gemini-3.6-flash-high", "stale-first-writer" -+ _, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errGet != nil || found { -+ t.Fatalf("initial absent snapshot = found %v, err %v", found, errGet) -+ } -+ _, clearSnapshot, _, errClearGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errClearGet != nil { -+ t.Fatal(errClearGet) -+ } -+ if deleted, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, clearSnapshot); errDelete != nil || !deleted { -+ t.Fatalf("conditional empty clear = %v, %v; want true, nil", deleted, errDelete) -+ } -+ staleItem := antigravityReplayTestItem("stale-first-writer-signature-123456") -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{staleItem}); errSwap != nil || swapped { -+ t.Fatalf("stale first write = %v, %v; want false, nil", swapped, errSwap) -+ } -+} -+ -+func TestAntigravityReasoningReplayEvictedTombstoneStillBlocksStaleFirstWriter(t *testing.T) { -+ ClearAntigravityReasoningReplayCache() -+ t.Cleanup(ClearAntigravityReasoningReplayCache) -+ const model, session = "gemini-3.6-flash-high", "evicted-stale-first-writer" -+ _, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errGet != nil || found { -+ t.Fatalf("initial absent snapshot = found %v, err %v", found, errGet) -+ } -+ _, clearSnapshot, _, errClearGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errClearGet != nil { -+ t.Fatal(errClearGet) -+ } -+ if deleted, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, clearSnapshot); errDelete != nil || !deleted { -+ t.Fatalf("conditional clear = %v, %v", deleted, errDelete) -+ } -+ antigravityReasoningReplayMu.Lock() -+ evictOldestAntigravityReasoningReplayEntries(1) -+ antigravityReasoningReplayMu.Unlock() -+ staleItem := antigravityReplayTestItem("evicted-stale-first-writer-signature-123456") -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{staleItem}); errSwap != nil || swapped { -+ t.Fatalf("stale first writer crossed tombstone eviction: swapped=%v err=%v", swapped, errSwap) -+ } -+} -+ -+func TestAntigravityReasoningReplayEvictedLiveEntryStillBlocksStaleAbsentSnapshot(t *testing.T) { -+ ClearAntigravityReasoningReplayCache() -+ t.Cleanup(ClearAntigravityReasoningReplayCache) -+ const model = "gemini-3.6-flash-high" -+ _, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, "absent-before-live-eviction") -+ if errGet != nil || found { -+ t.Fatalf("initial absent snapshot = found %v, err %v", found, errGet) -+ } -+ liveItem := antigravityReplayTestItem("evicted-live-signature-123456") -+ if !CacheAntigravityReasoningReplayItems(model, "live-entry", [][]byte{liveItem}) { -+ t.Fatal("live entry write failed") -+ } -+ antigravityReasoningReplayMu.Lock() -+ evictOldestAntigravityReasoningReplayEntries(1) -+ antigravityReasoningReplayMu.Unlock() -+ staleItem := antigravityReplayTestItem("stale-after-live-eviction-123456") -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, "absent-before-live-eviction", staleSnapshot, [][]byte{staleItem}); errSwap != nil || swapped { -+ t.Fatalf("stale absent snapshot crossed live-entry eviction: swapped=%v err=%v", swapped, errSwap) -+ } -+} -+ -+func TestAntigravityReasoningReplayHomeAbsentSnapshotIsFenced(t *testing.T) { -+ client := newFakeAntigravityReasoningReplayKVClient() -+ useFakeAntigravityReasoningReplayKVClient(t, client, true) -+ const model, session = "gemini-3.6-flash-high", "home-absent-fence" -+ _, snapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errGet != nil || found || !snapshot.found || len(snapshot.raw) == 0 { -+ t.Fatalf("fenced Home miss = found %v snapshotFound %v raw %d err %v", found, snapshot.found, len(snapshot.raw), errGet) -+ } -+ key := antigravityReasoningReplayKVKey(model, session) -+ client.mu.Lock() -+ client.values[key] = []byte(`[[123]]`) -+ delete(client.values, key) -+ client.mu.Unlock() -+ item := antigravityReplayTestItem("home-absent-stale-signature-123456") -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot, [][]byte{item}); errSwap != nil || swapped { -+ t.Fatalf("stale Home absent snapshot crossed value expiry: swapped=%v err=%v", swapped, errSwap) -+ } -+} -+ -+func TestAntigravityReasoningReplayConditionalMutationRejectsStaleHomeSnapshot(t *testing.T) { -+ client := newFakeAntigravityReasoningReplayKVClient() -+ useFakeAntigravityReasoningReplayKVClient(t, client, true) -+ const model, session = "gemini-3.6-flash-high", "stale-home" -+ oldItem := antigravityReplayTestItem("old-home-signature-123456") -+ newItem := antigravityReplayTestItem("new-home-signature-123456") -+ staleItem := antigravityReplayTestItem("stale-home-signature-123456") -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{oldItem}) { -+ t.Fatal("initial Home write failed") -+ } -+ _, snapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errGet != nil || !found { -+ t.Fatalf("Home snapshot read failed: found=%v err=%v", found, errGet) -+ } -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{newItem}) { -+ t.Fatal("newer Home write failed") -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot, [][]byte{staleItem}); errSwap != nil || swapped { -+ t.Fatalf("stale Home replace = %v, %v; want false, nil", swapped, errSwap) -+ } -+ if deleted, errDelete := DeleteAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot); errDelete != nil || deleted { -+ t.Fatalf("stale Home delete = %v, %v; want false, nil", deleted, errDelete) -+ } -+ items, ok := GetAntigravityReasoningReplayItems(model, session) -+ if !ok || len(items) != 1 || !bytes.Contains(items[0], []byte("new-home-signature")) { -+ t.Fatalf("newer Home state was lost: %q, found=%v", items, ok) -+ } -+} -+ -+func TestAntigravityReasoningReplayNonPrefixReplaceRotatesHomeBranch(t *testing.T) { -+ client := newFakeAntigravityReasoningReplayKVClient() -+ useFakeAntigravityReasoningReplayKVClient(t, client, true) -+ const model, session = "gemini-3.6-flash-high", "non-prefix-home" -+ oldItem := antigravityReplayTestItem("non-prefix-home-old-123456") -+ newItem := antigravityReplayTestItem("non-prefix-home-new-123456") -+ latestItem := antigravityReplayTestItem("non-prefix-home-latest-123456") -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{oldItem}) { -+ t.Fatal("old Home write failed") -+ } -+ _, firstSnapshot, _, errFirstGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ _, staleSnapshot, _, errStaleGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errFirstGet != nil || errStaleGet != nil { -+ t.Fatalf("Home snapshot reads failed: %v, %v", errFirstGet, errStaleGet) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, firstSnapshot, [][]byte{newItem}); errSwap != nil || !swapped { -+ t.Fatalf("non-prefix Home replace = %v, %v", swapped, errSwap) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{newItem, latestItem}); errSwap != nil || swapped { -+ t.Fatalf("stale Home descendant crossed non-prefix reset: swapped=%v err=%v", swapped, errSwap) -+ } -+} -+ -+func TestAntigravityReasoningReplayConditionalReplaceAcceptsDescendantHomeChain(t *testing.T) { -+ client := newFakeAntigravityReasoningReplayKVClient() -+ useFakeAntigravityReasoningReplayKVClient(t, client, true) -+ const model, session = "gemini-3.6-flash-high", "descendant-home" -+ prefix := antigravityReplayTestItem("home-descendant-prefix-123456") -+ middle := antigravityReplayTestItem("home-descendant-middle-123456") -+ latest := antigravityReplayTestItem("home-descendant-latest-123456") -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{prefix}) { -+ t.Fatal("Home prefix write failed") -+ } -+ _, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errGet != nil || !found { -+ t.Fatalf("Home prefix snapshot failed: found=%v err=%v", found, errGet) -+ } -+ _, firstSnapshot, _, errFirstGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errFirstGet != nil { -+ t.Fatal(errFirstGet) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, firstSnapshot, [][]byte{prefix, middle}); errSwap != nil || !swapped { -+ t.Fatalf("Home middle conditional write = %v, %v", swapped, errSwap) -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{prefix, middle, latest}); errSwap != nil || !swapped { -+ t.Fatalf("descendant Home replace = %v, %v; want true, nil", swapped, errSwap) -+ } -+ items, ok := GetAntigravityReasoningReplayItems(model, session) -+ if !ok || len(items) != 3 { -+ t.Fatalf("descendant Home chain = %d items, found=%v", len(items), ok) -+ } -+} -+ -+func TestAntigravityReasoningReplayHomeGenerationRejectsSuccessfulValueABA(t *testing.T) { -+ client := newFakeAntigravityReasoningReplayKVClient() -+ useFakeAntigravityReasoningReplayKVClient(t, client, true) -+ const model, session = "gemini-3.6-flash-high", "home-aba" -+ itemA := antigravityReplayTestItem("home-aba-signature-a-123456") -+ itemB := antigravityReplayTestItem("home-aba-signature-b-123456") -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{itemA}) { -+ t.Fatal("initial A write failed") -+ } -+ _, staleSnapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errGet != nil || !found { -+ t.Fatalf("A snapshot read failed: found=%v err=%v", found, errGet) -+ } -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{itemB}) || !CacheAntigravityReasoningReplayItems(model, session, [][]byte{itemA}) { -+ t.Fatal("B to A rewrite failed") -+ } -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, staleSnapshot, [][]byte{itemB}); errSwap != nil || swapped { -+ t.Fatalf("stale A snapshot passed Home ABA guard: swapped=%v err=%v", swapped, errSwap) -+ } -+} -+ -+func TestAntigravityReasoningReplayHomeCASRetryRejectsOversizedValue(t *testing.T) { -+ client := newFakeAntigravityReasoningReplayKVClient() -+ useFakeAntigravityReasoningReplayKVClient(t, client, true) -+ const model, session = "gemini-3.6-flash-high", "oversized-home-cas" -+ prefix := antigravityReplayTestItem("oversized-home-prefix-123456") -+ latest := antigravityReplayTestItem("oversized-home-latest-123456") -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{prefix}) { -+ t.Fatal("Home prefix write failed") -+ } -+ _, snapshot, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errGet != nil || !found { -+ t.Fatalf("Home snapshot read failed: found=%v err=%v", found, errGet) -+ } -+ oversized, errMarshal := marshalAntigravityReasoningReplayHomeValue([][]byte{prefix}, snapshot.branch) -+ if errMarshal != nil { -+ t.Fatal(errMarshal) -+ } -+ oversized = append(oversized, bytes.Repeat([]byte(" "), antigravityReasoningReplayCacheMaxSerializedBytes-len(oversized)+1)...) -+ key := antigravityReasoningReplayKVKey(model, session) -+ client.values[key] = oversized -+ if swapped, errSwap := ReplaceAntigravityReasoningReplayItemsIfUnchanged(context.Background(), model, session, snapshot, [][]byte{prefix, latest}); errSwap != nil || swapped { -+ t.Fatalf("oversized Home CAS retry = swapped %v, err %v; want false, nil", swapped, errSwap) -+ } -+ if got := len(client.values[key]); got <= antigravityReasoningReplayCacheMaxSerializedBytes { -+ t.Fatalf("oversized value was unexpectedly replaced: %d", got) -+ } -+} -+ -+func TestAntigravityReasoningReplayLocalTombstonesStayWithinEntryBound(t *testing.T) { -+ ClearAntigravityReasoningReplayCache() -+ t.Cleanup(ClearAntigravityReasoningReplayCache) -+ for index := 0; index <= AntigravityReasoningReplayCacheMaxEntries; index++ { -+ if errDelete := DeleteAntigravityReasoningReplayItemRequired(context.Background(), "gemini-3.6-flash-high", fmt.Sprintf("tombstone-%d", index)); errDelete != nil { -+ t.Fatal(errDelete) -+ } -+ } -+ antigravityReasoningReplayMu.Lock() -+ entryCount := len(antigravityReasoningReplayEntries) -+ antigravityReasoningReplayMu.Unlock() -+ if entryCount > AntigravityReasoningReplayCacheMaxEntries { -+ t.Fatalf("local tombstone count = %d, max %d", entryCount, AntigravityReasoningReplayCacheMaxEntries) -+ } -+} -+ -+func TestAntigravityReasoningReplayHomeWritesRemainLegacyArrayReadable(t *testing.T) { -+ client := newFakeAntigravityReasoningReplayKVClient() -+ useFakeAntigravityReasoningReplayKVClient(t, client, true) -+ const model, session = "gemini-3.6-flash-high", "home-legacy-readable" -+ item := antigravityReplayTestItem("legacy-readable-signature-123456") -+ if !CacheAntigravityReasoningReplayItems(model, session, [][]byte{item}) { -+ t.Fatal("Home write failed") -+ } -+ raw := client.values[antigravityReasoningReplayKVKey(model, session)] -+ var legacyItems [][]byte -+ if errUnmarshal := json.Unmarshal(raw, &legacyItems); errUnmarshal != nil { -+ t.Fatalf("new Home value is not readable as legacy [][]byte: %v", errUnmarshal) -+ } -+ if len(legacyItems) != 2 || gjson.GetBytes(legacyItems[0], "type").String() != antigravityReasoningReplayGenerationItemType || !bytes.Contains(legacyItems[1], []byte("legacy-readable-signature")) { -+ t.Fatalf("legacy-readable Home array malformed: %q", legacyItems) -+ } -+} -+ -+func TestAntigravityReasoningReplayHomeReadNormalizesAndRejectsMixedInvalidChain(t *testing.T) { -+ client := newFakeAntigravityReasoningReplayKVClient() -+ useFakeAntigravityReasoningReplayKVClient(t, client, true) -+ const model, session = "gemini-3.6-flash-high", "home-validation" -+ key := antigravityReasoningReplayKVKey(model, session) -+ valid := []byte(`{"type":"function_call_part","name":"run","args":{"b":2,"a":1},"targetOccurrence":1,"thoughtSignature":"valid-home-signature-123456"}`) -+ raw, errMarshal := json.Marshal([][]byte{valid}) -+ if errMarshal != nil { -+ t.Fatal(errMarshal) -+ } -+ client.values[key] = raw -+ items, _, found, errGet := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session) -+ if errGet != nil || !found || len(items) != 1 { -+ t.Fatalf("valid Home read = %q, found=%v err=%v", items, found, errGet) -+ } -+ if !bytes.Contains(items[0], []byte(`"targetOccurrence":1`)) { -+ t.Fatalf("target occurrence was not normalized: %s", items[0]) -+ } -+ if client.expireCount != 1 { -+ t.Fatalf("valid Home read expire count = %d, want 1", client.expireCount) -+ } -+ -+ invalidRaw, errInvalidMarshal := json.Marshal([][]byte{valid, []byte(`{"type":"unknown"}`)}) -+ if errInvalidMarshal != nil { -+ t.Fatal(errInvalidMarshal) -+ } -+ client.values[key] = invalidRaw -+ if _, _, foundInvalid, errInvalid := GetAntigravityReasoningReplayItemsWithSnapshotRequired(context.Background(), model, session); errInvalid != nil || foundInvalid { -+ t.Fatalf("mixed invalid Home chain = found %v, err %v; want false, nil", foundInvalid, errInvalid) -+ } -+ if client.expireCount != 1 { -+ t.Fatalf("invalid Home read refreshed TTL: count=%d", client.expireCount) -+ } -+} -diff --git a/internal/runtime/executor/antigravity_executor.go b/internal/runtime/executor/antigravity_executor.go -index 20ec6d506a2..ee56cf9ed8e 100644 ---- a/internal/runtime/executor/antigravity_executor.go -+++ b/internal/runtime/executor/antigravity_executor.go -@@ -29,6 +29,7 @@ import ( - "github.com/router-for-me/CLIProxyAPI/v7/internal/misc" - "github.com/router-for-me/CLIProxyAPI/v7/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" -+ internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" - "github.com/router-for-me/CLIProxyAPI/v7/internal/thinking" - antigravityclaude "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/antigravity/claude" - "github.com/router-for-me/CLIProxyAPI/v7/internal/util" -@@ -296,12 +297,167 @@ func newAntigravityHTTPClient(ctx context.Context, cfg *config.Config, auth *cli - return client - } - -+func sanitizeAntigravityGeminiRequestSignatures(modelName string, rawJSON []byte) []byte { -+ if !antigravityUsesReasoningReplayCache(modelName) { -+ return rawJSON -+ } -+ rawJSON = internalsignature.SanitizeGeminiRequestThoughtSignatures(rawJSON, "request.contents") -+ return normalizeAntigravityGeminiFunctionResponseRoles(rawJSON) -+} -+ -+func normalizeAntigravityGeminiFunctionResponseRoles(rawJSON []byte) []byte { -+ rawJSON = repairAntigravityGeminiFunctionResponseNames(rawJSON) -+ contents := gjson.GetBytes(rawJSON, "request.contents") -+ if !contents.IsArray() { -+ return rawJSON -+ } -+ type functionRef struct { -+ id string -+ name string -+ } -+ out := rawJSON -+ var pending []functionRef -+ for contentIndex, content := range contents.Array() { -+ parts := content.Get("parts") -+ if !parts.IsArray() || len(parts.Array()) == 0 { -+ pending = nil -+ continue -+ } -+ var calls, responses []functionRef -+ var responseParts []json.RawMessage -+ hasOtherPart := false -+ parts.ForEach(func(_, part gjson.Result) bool { -+ switch { -+ case part.Get("functionCall").Exists(): -+ calls = append(calls, functionRef{id: part.Get("functionCall.id").String(), name: part.Get("functionCall.name").String()}) -+ case part.Get("functionResponse").Exists(): -+ responses = append(responses, functionRef{id: part.Get("functionResponse.id").String(), name: part.Get("functionResponse.name").String()}) -+ responseParts = append(responseParts, json.RawMessage(part.Raw)) -+ default: -+ hasOtherPart = true -+ } -+ return true -+ }) -+ if len(calls) > 0 && len(responses) == 0 { -+ pending = calls -+ continue -+ } -+ if len(responses) == 0 { -+ if hasOtherPart { -+ pending = nil -+ } -+ continue -+ } -+ if hasOtherPart || len(calls) > 0 { -+ pending = nil -+ continue -+ } -+ -+ if len(pending) == len(responses) { -+ ordered := make([]json.RawMessage, 0, len(responseParts)) -+ used := make([]bool, len(responses)) -+ for _, call := range pending { -+ matched := -1 -+ for responseIndex, response := range responses { -+ if used[responseIndex] { -+ continue -+ } -+ if (call.id != "" && response.id == call.id) || (call.id == "" && call.name != "" && response.name == call.name) { -+ matched = responseIndex -+ break -+ } -+ } -+ if matched < 0 { -+ ordered = nil -+ break -+ } -+ used[matched] = true -+ ordered = append(ordered, responseParts[matched]) -+ } -+ if len(ordered) == len(responseParts) { -+ if encoded, errMarshal := json.Marshal(ordered); errMarshal == nil { -+ if updated, errSet := sjson.SetRawBytes(out, fmt.Sprintf("request.contents.%d.parts", contentIndex), encoded); errSet == nil { -+ out = updated -+ } -+ } -+ } -+ } -+ pending = nil -+ if content.Get("role").String() != "model" { -+ if updated, errSet := sjson.SetBytes(out, fmt.Sprintf("request.contents.%d.role", contentIndex), "model"); errSet == nil { -+ out = updated -+ } -+ } -+ } -+ return out -+} -+ -+func repairAntigravityGeminiFunctionResponseNames(rawJSON []byte) []byte { -+ contents := gjson.GetBytes(rawJSON, "request.contents") -+ if !contents.IsArray() { -+ return rawJSON -+ } -+ callIDToName := make(map[string]string) -+ contents.ForEach(func(_, content gjson.Result) bool { -+ parts := content.Get("parts") -+ if !parts.IsArray() { -+ return true -+ } -+ parts.ForEach(func(_, part gjson.Result) bool { -+ fc := part.Get("functionCall") -+ if fc.Exists() { -+ id := strings.TrimSpace(fc.Get("id").String()) -+ name := strings.TrimSpace(fc.Get("name").String()) -+ if id != "" && name != "" && name != "unknown" { -+ callIDToName[id] = name -+ } -+ } -+ return true -+ }) -+ return true -+ }) -+ if len(callIDToName) == 0 { -+ return rawJSON -+ } -+ -+ out := rawJSON -+ contents.ForEach(func(contentIdx, content gjson.Result) bool { -+ parts := content.Get("parts") -+ if !parts.IsArray() { -+ return true -+ } -+ parts.ForEach(func(partIdx, part gjson.Result) bool { -+ fr := part.Get("functionResponse") -+ if fr.Exists() { -+ id := strings.TrimSpace(fr.Get("id").String()) -+ name := strings.TrimSpace(fr.Get("name").String()) -+ if id != "" && (name == "" || name == "unknown") { -+ if realName, ok := callIDToName[id]; ok { -+ path := fmt.Sprintf("request.contents.%d.parts.%d.functionResponse.name", contentIdx.Int(), partIdx.Int()) -+ if updated, errSet := sjson.SetBytes(out, path, realName); errSet == nil { -+ out = updated -+ } -+ } -+ } -+ } -+ return true -+ }) -+ return true -+ }) -+ return out -+} -+ - func validateAntigravityRequestSignatures(ctx context.Context, modelName string, from sdktranslator.Format, rawJSON []byte) ([]byte, error) { - if from.String() != "claude" { - return rawJSON, nil - } -- // Always strip thinking blocks with invalid signatures (empty or non-Claude-format). - before := countClaudeThinkingBlocks(rawJSON) -+ if antigravityUsesReasoningReplayCache(modelName) { -+ rawJSON = antigravityclaude.StripInvalidGeminiSignatureThinkingBlocks(rawJSON) -+ logAntigravitySignatureStrip(before, countClaudeThinkingBlocks(rawJSON), "provider_cleanup", "empty_or_non_gemini_signature") -+ return rawJSON, nil -+ } -+ // Claude models accept only Claude-format thinking signatures. - rawJSON = antigravityclaude.StripEmptySignatureThinkingBlocks(rawJSON) - logAntigravitySignatureStrip(before, countClaudeThinkingBlocks(rawJSON), "prefix_cleanup", "empty_or_non_claude_signature") - if cache.SignatureCacheEnabled() { -@@ -664,6 +820,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers) -+ translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated) - reporter.SetTranslatedReasoningEffort(translated, to.String()) - - useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) -@@ -885,6 +1042,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth * - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers) -+ translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated) - reporter.SetTranslatedReasoningEffort(translated, to.String()) - - useCredits := cliproxyauth.AntigravityCreditsRequested(ctx) && antigravityCreditsRetryEnabled(e.cfg) -@@ -909,6 +1067,15 @@ attemptLoop: - helps.MarkCreditsUsed(ctx) - } - } -+ replayScope := antigravityReasoningReplayScope{} -+ if antigravityUsesReasoningReplayCache(baseModel) { -+ var errReplay error -+ requestPayload, replayScope, errReplay = prepareAntigravityGeminiReasoningReplayPayload(ctx, baseModel, req, opts, requestPayload) -+ if errReplay != nil { -+ err = errReplay -+ return resp, err -+ } -+ } - httpReq, errReq := e.buildRequest(ctx, auth, token, baseModel, requestPayload, true, opts.Alt, baseURL) - if errReq != nil { - err = errReq -@@ -1028,6 +1195,10 @@ attemptLoop: - continue attemptLoop - } - } -+ if errClear := clearAntigravityReasoningReplayOnInvalidSignature(ctx, replayScope, httpResp.StatusCode, bodyBytes); errClear != nil { -+ err = errClear -+ return resp, err -+ } - err = newAntigravityStatusErr(httpResp.StatusCode, bodyBytes) - return resp, err - } -@@ -1036,6 +1207,7 @@ attemptLoop: - if useCredits { - clearAntigravityCreditsFailureState(auth) - } -+ replayAccumulator := newAntigravityReasoningReplayAccumulator(replayScope, requestPayload) - out := make(chan cliproxyexecutor.StreamChunk) - go func(resp *http.Response) { - defer close(out) -@@ -1049,6 +1221,9 @@ attemptLoop: - for scanner.Scan() { - line := scanner.Bytes() - helps.AppendAPIResponseChunk(ctx, e.cfg, line) -+ if replayAccumulator != nil { -+ replayAccumulator.ObserveSSELine(line) -+ } - - // Filter usage metadata for all models - // Only retain usage statistics in the terminal chunk -@@ -1070,6 +1245,9 @@ attemptLoop: - reporter.PublishFailure(ctx, errScan) - out <- cliproxyexecutor.StreamChunk{Err: errScan} - } else { -+ if replayAccumulator != nil { -+ replayAccumulator.Commit(ctx) -+ } - reporter.EnsurePublished(ctx) - } - }(httpResp) -@@ -1355,6 +1533,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya - requestedModel := helps.PayloadRequestedModel(opts, req.Model) - requestPath := helps.PayloadRequestPath(opts) - translated = helps.ApplyPayloadConfigWithRequest(e.cfg, baseModel, "antigravity", from.String(), "request", translated, originalTranslated, requestedModel, requestPath, opts.Headers) -+ translated = sanitizeAntigravityGeminiRequestSignatures(baseModel, translated) - translated, _ = sjson.DeleteBytes(translated, "request.stream") - reporter.SetTranslatedReasoningEffort(translated, to.String()) - -@@ -1524,9 +1703,6 @@ attemptLoop: - go func(resp *http.Response) { - defer close(out) - defer func() { -- if replayAccumulator != nil { -- replayAccumulator.Flush(ctx) -- } - if errClose := resp.Body.Close(); errClose != nil { - log.Errorf("antigravity executor: close response line error: %v", errClose) - } -@@ -1581,6 +1757,9 @@ attemptLoop: - case <-ctx.Done(): - } - } else { -+ if replayAccumulator != nil { -+ replayAccumulator.Commit(ctx) -+ } - reporter.EnsurePublished(ctx) - } - }(httpResp) -@@ -1686,6 +1865,7 @@ func (e *AntigravityExecutor) CountTokens(ctx context.Context, auth *cliproxyaut - if err != nil { - return cliproxyexecutor.Response{}, err - } -+ payload = sanitizeAntigravityGeminiRequestSignatures(baseModel, payload) - - payload = helps.DeleteJSONField(payload, "project") - payload = helps.DeleteJSONField(payload, "model") -diff --git a/internal/runtime/executor/antigravity_executor_signature_test.go b/internal/runtime/executor/antigravity_executor_signature_test.go -index c35190e4541..22fcc521b83 100644 ---- a/internal/runtime/executor/antigravity_executor_signature_test.go -+++ b/internal/runtime/executor/antigravity_executor_signature_test.go -@@ -5,16 +5,24 @@ import ( - "context" - "encoding/base64" - "fmt" -+ "io" -+ "net/http" -+ "net/http/httptest" - "strings" - "testing" - "time" - - "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" -+ "github.com/router-for-me/CLIProxyAPI/v7/internal/config" -+ internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" - cliproxyauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" -+ cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" - sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" - log "github.com/sirupsen/logrus" - "github.com/sirupsen/logrus/hooks/test" - "github.com/tidwall/gjson" -+ "github.com/tidwall/sjson" -+ "google.golang.org/protobuf/encoding/protowire" - ) - - func testGeminiSignaturePayload() string { -@@ -88,6 +96,175 @@ func assertSignatureDebugDoesNotLeak(t *testing.T, hook *test.Hook, forbidden st - } - } - -+func TestSanitizeAntigravityGeminiRequestSignaturesFinalizesParallelCalls(t *testing.T) { -+ inner := protowire.AppendTag(nil, 1, protowire.BytesType) -+ inner = protowire.AppendBytes(inner, []byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34}) -+ encoded := protowire.AppendTag(nil, 2, protowire.BytesType) -+ encoded = protowire.AppendBytes(encoded, inner) -+ nativeSignature := base64.StdEncoding.EncodeToString(encoded) -+ -+ tests := []struct { -+ name string -+ firstSignature string -+ secondSignature string -+ wantFirstSignature string -+ }{ -+ { -+ name: "synthetic", -+ wantFirstSignature: "skip_thought_signature_validator", -+ }, -+ { -+ name: "native", -+ firstSignature: nativeSignature, -+ secondSignature: "skip_thought_signature_validator", -+ wantFirstSignature: nativeSignature, -+ }, -+ } -+ for _, tt := range tests { -+ t.Run(tt.name, func(t *testing.T) { -+ payload := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}}},{"functionCall":{"name":"second","args":{}}}]},{"role":"user","parts":[{"functionResponse":{"name":"first","response":{"result":"ok"}}},{"functionResponse":{"name":"second","response":{"result":"ok"}}}]}]}}`) -+ if tt.firstSignature != "" { -+ payload, _ = sjson.SetBytes(payload, "request.contents.0.parts.0.thoughtSignature", tt.firstSignature) -+ } -+ if tt.secondSignature != "" { -+ payload, _ = sjson.SetBytes(payload, "request.contents.0.parts.1.thoughtSignature", tt.secondSignature) -+ } -+ -+ output := sanitizeAntigravityGeminiRequestSignatures("gemini-3.5-flash", payload) -+ if got := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != tt.wantFirstSignature { -+ t.Fatalf("first signature = %q, want %q; output=%s", got, tt.wantFirstSignature, output) -+ } -+ if signature := gjson.GetBytes(output, "request.contents.0.parts.1.thoughtSignature"); signature.Exists() { -+ t.Fatalf("second parallel call should remain unsigned; output=%s", output) -+ } -+ if got := gjson.GetBytes(output, "request.contents.1.role").String(); got != "model" { -+ t.Fatalf("functionResponse role = %q, want native Antigravity model role; output=%s", got, output) -+ } -+ }) -+ } -+} -+ -+func TestAntigravityExecutorCountTokensSanitizesGeminiToolHistory(t *testing.T) { -+ inner := protowire.AppendTag(nil, 1, protowire.BytesType) -+ inner = protowire.AppendBytes(inner, []byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34}) -+ encoded := protowire.AppendTag(nil, 2, protowire.BytesType) -+ encoded = protowire.AppendBytes(encoded, inner) -+ nativeSignature := base64.StdEncoding.EncodeToString(encoded) -+ -+ var upstreamBody []byte -+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { -+ if r.URL.Path != antigravityCountTokensPath { -+ t.Fatalf("path = %q, want %q", r.URL.Path, antigravityCountTokensPath) -+ } -+ body, errRead := io.ReadAll(r.Body) -+ if errRead != nil { -+ t.Fatalf("read countTokens body: %v", errRead) -+ } -+ upstreamBody = append([]byte(nil), body...) -+ w.Header().Set("Content-Type", "application/json") -+ _, _ = w.Write([]byte(`{"totalTokens":42}`)) -+ })) -+ defer server.Close() -+ -+ payload := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[{"type":"tool_use","id":"call-1","name":"read","input":{"file":"one"},"signature":"` + nativeSignature + `"},{"type":"tool_use","id":"call-2","name":"read","input":{"file":"two"},"signature":"skip_thought_signature_validator"}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-2","content":"two"},{"type":"tool_result","tool_use_id":"call-1","content":"one"}]}]}`) -+ exec := NewAntigravityExecutor(&config.Config{RequestRetry: 1}) -+ _, errCount := exec.CountTokens(context.Background(), testAntigravityAuth(server.URL), cliproxyexecutor.Request{ -+ Model: "gemini-3.6-flash-high", -+ Payload: payload, -+ }, cliproxyexecutor.Options{ -+ SourceFormat: sdktranslator.FormatClaude, -+ ResponseFormat: sdktranslator.FormatClaude, -+ OriginalRequest: payload, -+ }) -+ if errCount != nil { -+ t.Fatalf("CountTokens() error = %v", errCount) -+ } -+ if len(upstreamBody) == 0 { -+ t.Fatal("countTokens upstream body was not captured") -+ } -+ if got := gjson.GetBytes(upstreamBody, "request.contents.0.parts.0.thoughtSignature").String(); got != nativeSignature { -+ t.Fatalf("first call signature = %q, want native signature; body=%s", got, upstreamBody) -+ } -+ if signature := gjson.GetBytes(upstreamBody, "request.contents.0.parts.1.thoughtSignature"); signature.Exists() { -+ t.Fatalf("second sibling bypass was not removed: %s", upstreamBody) -+ } -+ if got := gjson.GetBytes(upstreamBody, "request.contents.1.role").String(); got != "model" { -+ t.Fatalf("functionResponse role = %q, want model; body=%s", got, upstreamBody) -+ } -+ if got := gjson.GetBytes(upstreamBody, "request.contents.1.parts.0.functionResponse.id").String(); got != "call-1" { -+ t.Fatalf("first functionResponse.id = %q, want call-1; body=%s", got, upstreamBody) -+ } -+ if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(upstreamBody); errPairing != nil { -+ t.Fatalf("countTokens tool history is invalid: %v; body=%s", errPairing, upstreamBody) -+ } -+} -+ -+func TestNormalizeAntigravityGeminiFunctionResponseRolesLeavesMixedUserContent(t *testing.T) { -+ payload := []byte(`{"request":{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"run","response":{"result":"ok"}}},{"text":"user follow-up"}]}]}}`) -+ output := normalizeAntigravityGeminiFunctionResponseRoles(payload) -+ if got := gjson.GetBytes(output, "request.contents.0.role").String(); got != "user" { -+ t.Fatalf("mixed functionResponse/user content role = %q, want user; output=%s", got, output) -+ } -+} -+ -+func TestNormalizeAntigravityGeminiFunctionResponseRolesOrdersParallelResponses(t *testing.T) { -+ payload := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"read","args":{"file":"one"}}},{"functionCall":{"id":"call-2","name":"read","args":{"file":"two"}}}]},{"role":" Model ","parts":[{"functionResponse":{"id":"call-2","name":"read","response":{"result":"two"}}},{"functionResponse":{"id":"call-1","name":"read","response":{"result":"one"}}}]}]}}`) -+ output := normalizeAntigravityGeminiFunctionResponseRoles(payload) -+ if got := gjson.GetBytes(output, "request.contents.1.role").String(); got != "model" { -+ t.Fatalf("functionResponse role = %q, want model; output=%s", got, output) -+ } -+ if got := gjson.GetBytes(output, "request.contents.1.parts.0.functionResponse.id").String(); got != "call-1" { -+ t.Fatalf("first functionResponse.id = %q, want call-1; output=%s", got, output) -+ } -+ if got := gjson.GetBytes(output, "request.contents.1.parts.1.functionResponse.id").String(); got != "call-2" { -+ t.Fatalf("second functionResponse.id = %q, want call-2; output=%s", got, output) -+ } -+ if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(output); errValidate != nil { -+ t.Fatalf("normalized parallel responses are invalid: %v; output=%s", errValidate, output) -+ } -+} -+ -+func TestNormalizeAntigravityGeminiFunctionResponseRolesDoesNotCrossEmptyContentBoundary(t *testing.T) { -+ for _, boundary := range []string{ -+ `{"role":"user","parts":[]}`, -+ `{"role":"user"}`, -+ `{"role":"user","parts":null}`, -+ } { -+ payload := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"read","args":{}}},{"functionCall":{"id":"call-2","name":"read","args":{}}}]},` + boundary + `,{"role":"user","parts":[{"functionResponse":{"id":"call-2","name":"read","response":{"result":"two"}}},{"functionResponse":{"id":"call-1","name":"read","response":{"result":"one"}}}]}]}}`) -+ output := normalizeAntigravityGeminiFunctionResponseRoles(payload) -+ if got := gjson.GetBytes(output, "request.contents.2.role").String(); got != "model" { -+ t.Fatalf("pure functionResponse role = %q, want model; output=%s", got, output) -+ } -+ if got := gjson.GetBytes(output, "request.contents.2.parts.0.functionResponse.id").String(); got != "call-2" { -+ t.Fatalf("response crossed content boundary %s and was reordered: first id=%q; output=%s", boundary, got, output) -+ } -+ if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(output); errValidate == nil { -+ t.Fatalf("responses crossing content boundary %s were accepted: %s", boundary, output) -+ } -+ } -+} -+ -+func TestAntigravityExecutor_GeminiTargetPreservesGeminiThinkingCarrier(t *testing.T) { -+ inner := protowire.AppendTag(nil, 1, protowire.BytesType) -+ inner = protowire.AppendBytes(inner, []byte{0x01, 0x0c, 0x39, 0xd6, 0xc7, 0x34}) -+ encoded := protowire.AppendTag(nil, 2, protowire.BytesType) -+ encoded = protowire.AppendBytes(encoded, inner) -+ validSignature := base64.StdEncoding.EncodeToString(encoded) -+ payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"text","text":"answer"},{"type":"thinking","thinking":"","signature":"` + validSignature + `"},{"type":"thinking","thinking":"","signature":"invalid"}]}]}`) -+ -+ output, err := validateAntigravityRequestSignatures(context.Background(), "gemini-3.6-flash-high", sdktranslator.FormatClaude, payload) -+ if err != nil { -+ t.Fatalf("validateAntigravityRequestSignatures() error = %v", err) -+ } -+ content := gjson.GetBytes(output, "messages.0.content").Array() -+ if len(content) != 2 { -+ t.Fatalf("content length = %d, want text plus valid Gemini carrier: %s", len(content), output) -+ } -+ if got := content[1].Get("signature").String(); got != validSignature { -+ t.Fatalf("preserved signature = %q, want Gemini carrier", got) -+ } -+} -+ - func TestAntigravityExecutor_StrictBypassStripsInvalidSignature(t *testing.T) { - previousCache := cache.SignatureCacheEnabled() - previousStrict := cache.SignatureBypassStrictMode() -diff --git a/internal/runtime/executor/antigravity_reasoning_replay.go b/internal/runtime/executor/antigravity_reasoning_replay.go -index 4f0ff6214a9..d0d5e5a0262 100644 ---- a/internal/runtime/executor/antigravity_reasoning_replay.go -+++ b/internal/runtime/executor/antigravity_reasoning_replay.go -@@ -1,23 +1,28 @@ - package executor - - import ( -+ "bytes" - "context" - "crypto/sha256" - "encoding/json" - "fmt" - "net/http" -+ "reflect" - "strings" - - internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" - "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor/helps" -+ internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" -+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" - ) - - type antigravityReasoningReplayScope struct { -- modelName string -- sessionKey string -+ modelName string -+ sessionKey string -+ cacheSnapshot internalcache.AntigravityReasoningReplaySnapshot - } - - func (s antigravityReasoningReplayScope) valid() bool { -@@ -44,20 +49,96 @@ func antigravityReasoningReplayScopeFromPayload(modelName string, payload []byte - } - - func antigravityReasoningReplayScopeFromRequest(ctx context.Context, modelName string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, payload []byte) antigravityReasoningReplayScope { -+ // Prefer an explicit downstream session over a provider sessionId synthesized -+ // from request text. This keeps identical prompts in separate client sessions -+ // from sharing an opaque Gemini reasoning chain. -+ if sessionKey := antigravityReasoningReplayClientSessionKey(ctx, req, opts); sessionKey != "" { -+ return antigravityReasoningReplayScope{modelName: modelName, sessionKey: sessionKey} -+ } - if scope := antigravityReasoningReplayScopeFromPayload(modelName, payload); scope.valid() { - return scope - } - if scope := antigravityReasoningReplayScopeFromPayload(modelName, req.Payload); scope.valid() { - return scope - } -+ _ = ctx -+ return antigravityReasoningReplayScope{} -+} -+ -+func antigravityReasoningReplayClientSessionKey(ctx context.Context, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) string { -+ for _, raw := range [][]byte{opts.OriginalRequest, req.Payload} { -+ if scope, ok := helps.ClaudeCodeExecutionScope(ctx, raw, opts.Headers); ok { -+ if lane := antigravityClaudeReplaySystemLane(raw); lane != "" { -+ return scope + ":context:" + lane -+ } -+ return scope -+ } -+ } -+ if value := strings.TrimSpace(opts.Headers.Get("Session-Id")); value != "" { -+ return "responses:" + value -+ } -+ for _, raw := range [][]byte{opts.OriginalRequest, req.Payload} { -+ if len(raw) == 0 { -+ continue -+ } -+ for _, path := range []string{"session_id", "metadata.session_id"} { -+ if value := strings.TrimSpace(gjson.GetBytes(raw, path).String()); value != "" { -+ return "responses:" + value -+ } -+ } -+ } - if value := metadataString(opts.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" { -- return antigravityReasoningReplayScope{modelName: modelName, sessionKey: "execution:" + value} -+ return "execution:" + value - } - if value := metadataString(req.Metadata, cliproxyexecutor.ExecutionSessionMetadataKey); value != "" { -- return antigravityReasoningReplayScope{modelName: modelName, sessionKey: "execution:" + value} -+ return "execution:" + value -+ } -+ for _, raw := range [][]byte{opts.OriginalRequest, req.Payload} { -+ if value := strings.TrimSpace(gjson.GetBytes(raw, "prompt_cache_key").String()); value != "" { -+ return "prompt-cache:" + value -+ } -+ } -+ return "" -+} -+ -+func antigravityClaudeReplaySystemLane(payload []byte) string { -+ system := gjson.GetBytes(payload, "system") -+ if !system.Exists() { -+ return "" -+ } -+ var value any -+ if errUnmarshal := json.Unmarshal([]byte(system.Raw), &value); errUnmarshal != nil { -+ return "" -+ } -+ value = antigravityClaudeReplayNormalizeSystem(value) -+ normalized, errMarshal := json.Marshal(value) -+ if errMarshal != nil { -+ return "" -+ } -+ sum := sha256.Sum256(normalized) -+ return fmt.Sprintf("%x", sum[:16]) -+} -+ -+func antigravityClaudeReplayNormalizeSystem(value any) any { -+ switch typed := value.(type) { -+ case map[string]any: -+ normalized := make(map[string]any, len(typed)) -+ for key, child := range typed { -+ if strings.EqualFold(strings.TrimSpace(key), "cache_control") { -+ continue -+ } -+ normalized[key] = antigravityClaudeReplayNormalizeSystem(child) -+ } -+ return normalized -+ case []any: -+ normalized := make([]any, len(typed)) -+ for index, child := range typed { -+ normalized[index] = antigravityClaudeReplayNormalizeSystem(child) -+ } -+ return normalized -+ default: -+ return value - } -- _ = ctx -- return antigravityReasoningReplayScope{} - } - - func antigravityReplaySessionIDFromPayload(payload []byte) string { -@@ -83,13 +164,21 @@ func antigravityReasoningReplayPendingModelContentIndex(payload []byte) (content - } - last := arr[len(arr)-1] - if strings.EqualFold(strings.TrimSpace(last.Get("role").String()), "model") { -- ci := len(arr) - 1 - parts := last.Get("parts") -- base := 0 -+ hasFunctionResponse := false - if parts.IsArray() { -- base = len(parts.Array()) -+ parts.ForEach(func(_, part gjson.Result) bool { -+ hasFunctionResponse = hasFunctionResponse || part.Get("functionResponse").Exists() -+ return !hasFunctionResponse -+ }) -+ } -+ if !hasFunctionResponse { -+ base := 0 -+ if parts.IsArray() { -+ base = len(parts.Array()) -+ } -+ return len(arr) - 1, base - } -- return ci, base - } - return len(arr), 0 - } -@@ -103,22 +192,31 @@ func antigravityReasoningReplayResolveContentIndex(payload []byte, cached int) i - if cached >= 0 && cached < len(arr) { - return cached - } -- for i := len(arr) - 1; i >= 0; i-- { -- if strings.EqualFold(strings.TrimSpace(arr[i].Get("role").String()), "model") { -- return i -- } -- } -- if len(arr) == 0 { -- return 0 -- } -- return len(arr) - 1 -+ return -1 - } - - func prepareAntigravityGeminiReasoningReplayPayload(ctx context.Context, modelName string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, payload []byte) ([]byte, antigravityReasoningReplayScope, error) { - if !antigravityUsesReasoningReplayCache(modelName) { - return payload, antigravityReasoningReplayScope{}, nil - } -- return applyAntigravityReasoningReplayCache(ctx, modelName, req, opts, payload) -+ updated, scope, replayApplied, errReplay := applyAntigravityReasoningReplayCache(ctx, modelName, req, opts, payload) -+ if errReplay != nil { -+ return payload, scope, errReplay -+ } -+ updated = normalizeAntigravityGeminiFunctionResponseRoles(updated) -+ if antigravityPayloadHasClaudeToolProvenanceID(updated) { -+ return payload, scope, statusErr{code: http.StatusBadRequest, msg: "antigravity executor: missing Claude tool provenance; start a new session or restore replay state"} -+ } -+ if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(updated); errPairing != nil { -+ originalPairingValid := internalsignature.ValidateGeminiFunctionCallPairing(payload) == nil -+ if replayApplied && originalPairingValid && scope.valid() { -+ if _, errDelete := internalcache.DeleteAntigravityReasoningReplayItemsIfUnchanged(ctx, scope.modelName, scope.sessionKey, scope.cacheSnapshot); errDelete != nil { -+ return payload, scope, errDelete -+ } -+ } -+ return payload, scope, statusErr{code: http.StatusBadRequest, msg: fmt.Sprintf("antigravity executor: invalid Gemini function call history: %v", errPairing)} -+ } -+ return updated, scope, nil - } - - func clearAntigravityReasoningReplayOnInvalidSignature(ctx context.Context, scope antigravityReasoningReplayScope, statusCode int, body []byte) error { -@@ -132,46 +230,79 @@ func clearAntigravityReasoningReplayOnInvalidSignature(ctx context.Context, scop - if !strings.Contains(bodyText, "thoughtsignature") && !strings.Contains(bodyText, "thought_signature") && !strings.Contains(bodyText, "signature") { - return nil - } -- return internalcache.DeleteAntigravityReasoningReplayItemRequired(ctx, scope.modelName, scope.sessionKey) -+ _, errDelete := internalcache.DeleteAntigravityReasoningReplayItemsIfUnchanged(ctx, scope.modelName, scope.sessionKey, scope.cacheSnapshot) -+ return errDelete - } - --func applyAntigravityReasoningReplayCache(ctx context.Context, modelName string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, payload []byte) ([]byte, antigravityReasoningReplayScope, error) { -+func applyAntigravityReasoningReplayCache(ctx context.Context, modelName string, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, payload []byte) ([]byte, antigravityReasoningReplayScope, bool, error) { - scope := antigravityReasoningReplayScopeFromRequest(ctx, modelName, req, opts, payload) - if !scope.valid() { -- return payload, scope, nil -+ return payload, scope, false, nil - } -- items, ok, err := internalcache.GetAntigravityReasoningReplayItemsRequired(ctx, scope.modelName, scope.sessionKey) -+ items, snapshot, ok, err := internalcache.GetAntigravityReasoningReplayItemsWithSnapshotRequired(ctx, scope.modelName, scope.sessionKey) -+ scope.cacheSnapshot = snapshot - if err != nil || !ok || len(items) == 0 { -- return payload, scope, err -+ return payload, scope, false, err - } -- items = filterAntigravityReasoningReplayItemsForRequest(payload, items) -- if len(items) == 0 { -- return payload, scope, nil -+ updated := payload -+ changed := false -+ var toolSchemas map[string]any -+ if opts.SourceFormat.String() == "claude" { -+ toolSchemas = antigravityReplayToolSchemasFromRequests(opts.OriginalRequest, req.Payload) -+ } -+ for _, item := range items { -+ eligible := filterAntigravityReasoningReplayItemsForRequestWithSchemas(updated, [][]byte{item}, toolSchemas) -+ if len(eligible) != 1 { -+ continue -+ } -+ next, applied := insertAntigravityReasoningReplayItemsWithSchemas(updated, eligible, toolSchemas) -+ if !applied { -+ continue -+ } -+ updated = next -+ changed = true - } -- updated, okApply := insertAntigravityReasoningReplayItems(payload, items) -- if !okApply { -- return payload, scope, nil -+ if !changed { -+ return payload, scope, false, nil - } -- return updated, scope, nil -+ return updated, scope, true, nil - } - - func filterAntigravityReasoningReplayItemsForRequest(payload []byte, items [][]byte) [][]byte { -- existing := antigravityExistingToolCallKeys(payload) -+ return filterAntigravityReasoningReplayItemsForRequestWithSchemas(payload, items, nil) -+} -+ -+func filterAntigravityReasoningReplayItemsForRequestWithSchemas(payload []byte, items [][]byte, toolSchemas map[string]any) [][]byte { - filtered := make([][]byte, 0, len(items)) - for _, item := range items { - itemResult := gjson.ParseBytes(item) - switch strings.TrimSpace(itemResult.Get("type").String()) { - case "function_call_part": -- keys := antigravityReplayToolCallKeys(itemResult) -- if len(keys) == 0 { -- continue -- } -- if antigravityAnyKeyExists(existing, keys) { -- if !antigravityNeedsSignatureReplayForExistingFunctionCall(payload, itemResult) { -+ signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String()) -+ if ci, pi, foundCall := antigravityFunctionCallPartLocationForReplayWithSchemas(payload, itemResult, toolSchemas); foundCall { -+ part := gjson.GetBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d", ci, pi)) -+ currentID := strings.TrimSpace(part.Get("functionCall.id").String()) -+ nativeID := strings.TrimSpace(itemResult.Get("call_id").String()) -+ needsNativeRestore := currentID != nativeID || !bytes.Equal(antigravityCanonicalReplayJSON([]byte(part.Get("functionCall.args").Raw)), antigravityCanonicalReplayJSON([]byte(itemResult.Get("args").Raw))) -+ if !needsNativeRestore && (signature == "" || antigravityHasNativeThoughtSignature(part.Get("thoughtSignature").String())) { - continue - } -+ break -+ } -+ callID := strings.TrimSpace(itemResult.Get("call_id").String()) -+ if callID == "" { -+ continue -+ } -+ responseIndex, foundResponse := antigravityFunctionResponseContentIndex(payload, callID) -+ if !foundResponse { -+ continue -+ } -+ contextMatches := antigravityReplayItemContextMatches(payload, itemResult, responseIndex) -+ if !contextMatches && responseIndex > 0 { -+ previousRole := gjson.GetBytes(payload, fmt.Sprintf("request.contents.%d.role", responseIndex-1)).String() -+ contextMatches = strings.EqualFold(strings.TrimSpace(previousRole), "model") && antigravityReplayItemContextMatches(payload, itemResult, responseIndex-1) - } -- if !antigravityRequestHasMatchingFunctionResponse(payload, itemResult) { -+ if !contextMatches { - continue - } - case "thought_signature": -@@ -234,6 +365,9 @@ func antigravityFunctionCallKey(name, argsRaw, callID string) string { - if name == "" { - return "" - } -+ if strings.TrimSpace(argsRaw) != "" { -+ argsRaw = string(antigravityCanonicalReplayJSON([]byte(argsRaw))) -+ } - h := sha256.Sum256([]byte(strings.Join([]string{name, argsRaw, callID}, "\x00"))) - return fmt.Sprintf("fc:%x", h[:8]) - } -@@ -248,20 +382,15 @@ func antigravityAnyKeyExists(existing map[string]bool, keys []string) bool { - } - - func antigravityNeedsSignatureReplayForExistingFunctionCall(payload []byte, itemResult gjson.Result) bool { -- callID := strings.TrimSpace(itemResult.Get("call_id").String()) -- if callID == "" { -- callID = strings.TrimSpace(itemResult.Get("id").String()) -- } -- sig := strings.TrimSpace(itemResult.Get("thoughtSignature").String()) -- if callID == "" || sig == "" { -+ if strings.TrimSpace(itemResult.Get("thoughtSignature").String()) == "" { - return false - } -- ci, pi, ok := antigravityFunctionCallPartLocation(payload, callID) -+ ci, pi, ok := antigravityFunctionCallPartLocationForReplay(payload, itemResult) - if !ok { - return false - } - pathSig := fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", ci, pi) -- return strings.TrimSpace(gjson.GetBytes(payload, pathSig).String()) == "" -+ return !antigravityHasNativeThoughtSignature(gjson.GetBytes(payload, pathSig).String()) - } - - func antigravityRequestHasMatchingFunctionResponse(payload []byte, itemResult gjson.Result) bool { -@@ -326,6 +455,86 @@ func antigravityFunctionCallPartLocation(payload []byte, callID string) (content - return -1, -1, false - } - -+func antigravityFunctionCallPartLocationForReplay(payload []byte, itemResult gjson.Result) (contentIndex int, partIndex int, ok bool) { -+ return antigravityFunctionCallPartLocationForReplayWithSchemas(payload, itemResult, nil) -+} -+ -+func antigravityFunctionCallPartLocationForReplayWithSchemas(payload []byte, itemResult gjson.Result, toolSchemas map[string]any) (contentIndex int, partIndex int, ok bool) { -+ name := strings.TrimSpace(itemResult.Get("name").String()) -+ args := itemResult.Get("args") -+ if name == "" || !args.Exists() { -+ return -1, -1, false -+ } -+ callID := strings.TrimSpace(itemResult.Get("call_id").String()) -+ if callID == "" { -+ callID = strings.TrimSpace(itemResult.Get("id").String()) -+ } -+ candidateIDs := []string{callID} -+ if stableID := util.GeminiClaudeToolUseID(callID, name, args.Raw); stableID != "" && stableID != callID { -+ candidateIDs = append(candidateIDs, stableID) -+ } -+ for _, candidateID := range candidateIDs { -+ if candidateID == "" { -+ continue -+ } -+ ci, pi, found := antigravityFunctionCallPartLocation(payload, candidateID) -+ if !found { -+ continue -+ } -+ if antigravityReplayItemContextMatches(payload, itemResult, ci) { -+ fc := gjson.GetBytes(payload, fmt.Sprintf("request.contents.%d.parts.%d.functionCall", ci, pi)) -+ if antigravityFunctionCallMatchesReplayItem(fc, itemResult, toolSchemas) { -+ return ci, pi, true -+ } -+ } -+ return -1, -1, false -+ } -+ contents := gjson.GetBytes(payload, "request.contents") -+ if !contents.IsArray() { -+ return -1, -1, false -+ } -+ contentArr := contents.Array() -+ cachedCI := int(itemResult.Get("contentIndex").Int()) -+ if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Exists() { -+ if cachedCI < 0 || cachedCI >= len(contentArr) || !antigravityReplayItemContextMatches(payload, itemResult, cachedCI) { -+ return -1, -1, false -+ } -+ wantedOccurrence := int(targetOccurrence.Int()) -+ occurrence := 0 -+ for pi, part := range contentArr[cachedCI].Get("parts").Array() { -+ fc := part.Get("functionCall") -+ if !fc.Exists() || (util.IsGeminiClaudeToolUseID(fc.Get("id").String()) && fc.Get("id").String() != util.GeminiClaudeToolUseID(callID, name, args.Raw)) || !antigravityFunctionCallMatchesReplayItem(fc, itemResult, toolSchemas) { -+ continue -+ } -+ if occurrence == wantedOccurrence { -+ return cachedCI, pi, true -+ } -+ occurrence++ -+ } -+ return -1, -1, false -+ } -+ -+ matches := make([][2]int, 0, 1) -+ for ci, content := range contentArr { -+ if !antigravityReplayItemContextMatches(payload, itemResult, ci) { -+ continue -+ } -+ for pi, part := range content.Get("parts").Array() { -+ fc := part.Get("functionCall") -+ if !fc.Exists() || (util.IsGeminiClaudeToolUseID(fc.Get("id").String()) && fc.Get("id").String() != util.GeminiClaudeToolUseID(callID, name, args.Raw)) { -+ continue -+ } -+ if antigravityFunctionCallMatchesReplayItem(fc, itemResult, toolSchemas) { -+ matches = append(matches, [2]int{ci, pi}) -+ } -+ } -+ } -+ if len(matches) == 1 { -+ return matches[0][0], matches[0][1], true -+ } -+ return -1, -1, false -+} -+ - func insertAntigravityModelFunctionCallBeforeContent(payload []byte, beforeIndex int, name, callID, thoughtSig string, args gjson.Result) ([]byte, bool) { - contents := gjson.GetBytes(payload, "request.contents") - if !contents.IsArray() { -@@ -343,9 +552,10 @@ func insertAntigravityModelFunctionCallBeforeContent(payload []byte, beforeIndex - fc["args"] = args.Value() - } - part := map[string]any{"functionCall": fc} -- if thoughtSig != "" { -- part["thoughtSignature"] = thoughtSig -+ if thoughtSig == "" { -+ thoughtSig = "skip_thought_signature_validator" - } -+ part["thoughtSignature"] = thoughtSig - newContent := map[string]any{ - "role": "model", - "parts": []any{part}, -@@ -365,15 +575,365 @@ func insertAntigravityModelFunctionCallBeforeContent(payload []byte, beforeIndex - return updated, true - } - -+func appendAntigravityFunctionCallToModelContent(payload []byte, contentIndex int, name, callID, thoughtSig string, args gjson.Result) ([]byte, bool) { -+ contentPath := fmt.Sprintf("request.contents.%d", contentIndex) -+ if !strings.EqualFold(strings.TrimSpace(gjson.GetBytes(payload, contentPath+".role").String()), "model") || !gjson.GetBytes(payload, contentPath+".parts").IsArray() { -+ return payload, false -+ } -+ fc := map[string]any{"name": name} -+ if callID != "" { -+ fc["id"] = callID -+ } -+ if args.Exists() { -+ fc["args"] = args.Value() -+ } -+ part := map[string]any{"functionCall": fc} -+ if thoughtSig == "" { -+ hasFunctionCall := false -+ gjson.GetBytes(payload, contentPath+".parts").ForEach(func(_, existingPart gjson.Result) bool { -+ hasFunctionCall = existingPart.Get("functionCall").Exists() -+ return !hasFunctionCall -+ }) -+ if !hasFunctionCall { -+ thoughtSig = "skip_thought_signature_validator" -+ } -+ } -+ if thoughtSig != "" { -+ part["thoughtSignature"] = thoughtSig -+ } -+ updated, errSet := sjson.SetBytes(payload, contentPath+".parts.-1", part) -+ if errSet != nil { -+ return payload, false -+ } -+ return updated, true -+} -+ -+func antigravityRemoveThoughtSignatureFromOtherParts(payload []byte, contentIndex int, signature, keepPartPath string) []byte { -+ signature = strings.TrimSpace(signature) -+ partsPath := fmt.Sprintf("request.contents.%d.parts", contentIndex) -+ parts := gjson.GetBytes(payload, partsPath) -+ if signature == "" || !parts.IsArray() { -+ return payload -+ } -+ out := payload -+ for partIndex, part := range parts.Array() { -+ partPath := fmt.Sprintf("%s.%d", partsPath, partIndex) -+ if partPath == keepPartPath || antigravityNativePartThoughtSignature(part) != signature { -+ continue -+ } -+ for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} { -+ out, _ = sjson.DeleteBytes(out, partPath+"."+field) -+ } -+ } -+ return out -+} -+ - func antigravityRequestHasThoughtSignatureAt(payload []byte, itemResult gjson.Result) bool { -- ci := int(itemResult.Get("contentIndex").Int()) -- pi := int(itemResult.Get("partIndex").Int()) -- partPath, ok := antigravityExistingReplayPartPath(payload, ci, pi) -+ partPath, ok := antigravityThoughtSignatureReplayPartPath(payload, itemResult) - if !ok { - return false - } -- path := partPath + ".thoughtSignature" -- return strings.TrimSpace(gjson.GetBytes(payload, path).String()) != "" -+ return antigravityHasNativeThoughtSignature(gjson.GetBytes(payload, partPath+".thoughtSignature").String()) -+} -+ -+func antigravityHasNativeThoughtSignature(signature string) bool { -+ signature = strings.TrimSpace(signature) -+ return signature != "" && signature != "skip_thought_signature_validator" -+} -+ -+func antigravityReplayPartFingerprint(part gjson.Result) (kind, fingerprint string) { -+ if part.Get("functionCall").Exists() || part.Get("functionResponse").Exists() { -+ return "", "" -+ } -+ text := part.Get("text") -+ if !text.Exists() { -+ return "", "" -+ } -+ kind = "text" -+ if part.Get("thought").Bool() { -+ kind = "thought" -+ } -+ sum := sha256.Sum256([]byte(kind + "\x00" + text.String())) -+ return kind, fmt.Sprintf("%x", sum[:]) -+} -+ -+func antigravityReplayPartOccurrence(parts []gjson.Result, targetPartIndex int, targetKind, targetHash string) int { -+ occurrence := 0 -+ for partIndex := 0; partIndex < targetPartIndex && partIndex < len(parts); partIndex++ { -+ kind, fingerprint := antigravityReplayPartFingerprint(parts[partIndex]) -+ if kind == targetKind && fingerprint == targetHash { -+ occurrence++ -+ } -+ } -+ return occurrence -+} -+ -+func antigravityReplayContextFingerprint(payload []byte, beforeContentIndex int) string { -+ contents := gjson.GetBytes(payload, "request.contents") -+ if !contents.IsArray() || beforeContentIndex < 0 { -+ return "" -+ } -+ contentArr := contents.Array() -+ if beforeContentIndex > len(contentArr) { -+ return "" -+ } -+ var context strings.Builder -+ for _, path := range []string{"request.systemInstruction", "request.tools", "request.toolConfig"} { -+ if value := gjson.GetBytes(payload, path); value.Exists() { -+ context.WriteString(path) -+ context.WriteByte('\x00') -+ context.Write(antigravityCanonicalReplayJSON([]byte(value.Raw))) -+ context.WriteByte('\x00') -+ } -+ } -+ for ci := 0; ci < beforeContentIndex; ci++ { -+ content := contentArr[ci] -+ context.WriteString(strings.ToLower(strings.TrimSpace(content.Get("role").String()))) -+ context.WriteByte('\x00') -+ parts := content.Get("parts") -+ if !parts.IsArray() { -+ continue -+ } -+ parts.ForEach(func(_, part gjson.Result) bool { -+ normalized := []byte(part.Raw) -+ for _, signaturePath := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} { -+ normalized, _ = sjson.DeleteBytes(normalized, signaturePath) -+ } -+ context.Write(antigravityCanonicalReplayJSON(normalized)) -+ context.WriteByte('\x00') -+ return true -+ }) -+ } -+ if context.Len() == 0 { -+ return "" -+ } -+ sum := sha256.Sum256([]byte(context.String())) -+ return fmt.Sprintf("%x", sum[:]) -+} -+ -+func antigravityReplayToolSchemasFromRequests(rawRequests ...[]byte) map[string]any { -+ toolSchemas := make(map[string]any) -+ for _, raw := range rawRequests { -+ if len(raw) == 0 { -+ continue -+ } -+ nameMap := util.SanitizedFunctionNameMap(raw) -+ tools := gjson.GetBytes(raw, "tools") -+ if !tools.IsArray() { -+ continue -+ } -+ for _, tool := range tools.Array() { -+ candidates := []gjson.Result{tool} -+ if function := tool.Get("function"); function.Exists() { -+ candidates = append(candidates, function) -+ } -+ for _, candidate := range candidates { -+ name := strings.TrimSpace(candidate.Get("name").String()) -+ if name == "" { -+ continue -+ } -+ var schema gjson.Result -+ for _, path := range []string{"input_schema", "parameters", "parametersJsonSchema"} { -+ if value := candidate.Get(path); value.Exists() && value.IsObject() { -+ schema = value -+ break -+ } -+ } -+ if !schema.Exists() { -+ continue -+ } -+ var schemaValue any -+ if json.Unmarshal([]byte(schema.Raw), &schemaValue) != nil { -+ continue -+ } -+ for _, schemaName := range []string{name, util.MapSanitizedFunctionName(nameMap, name)} { -+ if schemaName == "" { -+ continue -+ } -+ if _, exists := toolSchemas[schemaName]; !exists { -+ toolSchemas[schemaName] = schemaValue -+ } -+ } -+ } -+ } -+ } -+ return toolSchemas -+} -+ -+func antigravityReplayJSONValue(result gjson.Result) (any, bool) { -+ raw := result.Raw -+ if result.Type == gjson.String { -+ raw = result.String() -+ } -+ var value any -+ if strings.TrimSpace(raw) == "" || json.Unmarshal([]byte(raw), &value) != nil { -+ return nil, false -+ } -+ return value, true -+} -+ -+func antigravityNormalizeReplayToolValue(value, schema any) any { -+ schemaObject, _ := schema.(map[string]any) -+ switch typed := value.(type) { -+ case map[string]any: -+ normalized := make(map[string]any, len(typed)) -+ properties, _ := schemaObject["properties"].(map[string]any) -+ for key, child := range typed { -+ childSchema := properties[key] -+ normalizedChild := antigravityNormalizeReplayToolValue(child, childSchema) -+ if propertySchema, ok := childSchema.(map[string]any); ok { -+ if defaultValue, hasDefault := propertySchema["default"]; hasDefault && reflect.DeepEqual(normalizedChild, antigravityNormalizeReplayToolValue(defaultValue, childSchema)) { -+ continue -+ } -+ } -+ normalized[key] = normalizedChild -+ } -+ return normalized -+ case []any: -+ itemSchema := schemaObject["items"] -+ normalized := make([]any, len(typed)) -+ for index, child := range typed { -+ normalized[index] = antigravityNormalizeReplayToolValue(child, itemSchema) -+ } -+ return normalized -+ default: -+ return value -+ } -+} -+ -+func antigravityFunctionCallMatchesReplayItem(functionCall, itemResult gjson.Result, toolSchemas map[string]any) bool { -+ name := strings.TrimSpace(itemResult.Get("name").String()) -+ if name == "" || strings.TrimSpace(functionCall.Get("name").String()) != name { -+ return false -+ } -+ currentArgs := functionCall.Get("args") -+ nativeArgs := itemResult.Get("args") -+ if !currentArgs.Exists() || !nativeArgs.Exists() { -+ return false -+ } -+ if bytes.Equal(antigravityCanonicalReplayJSON([]byte(currentArgs.Raw)), antigravityCanonicalReplayJSON([]byte(nativeArgs.Raw))) { -+ return true -+ } -+ schema, okSchema := toolSchemas[name] -+ if !okSchema { -+ return false -+ } -+ currentValue, okCurrent := antigravityReplayJSONValue(currentArgs) -+ nativeValue, okNative := antigravityReplayJSONValue(nativeArgs) -+ if !okCurrent || !okNative { -+ return false -+ } -+ return reflect.DeepEqual(antigravityNormalizeReplayToolValue(currentValue, schema), antigravityNormalizeReplayToolValue(nativeValue, schema)) -+} -+ -+func antigravityPayloadHasClaudeToolProvenanceID(payload []byte) bool { -+ contents := gjson.GetBytes(payload, "request.contents") -+ if !contents.IsArray() { -+ return false -+ } -+ for _, content := range contents.Array() { -+ for _, part := range content.Get("parts").Array() { -+ for _, path := range []string{"functionCall.id", "functionResponse.id"} { -+ if util.IsGeminiClaudeToolUseID(part.Get(path).String()) { -+ return true -+ } -+ } -+ } -+ } -+ return false -+} -+ -+func antigravityCanonicalReplayJSON(raw []byte) []byte { -+ var value any -+ if json.Unmarshal(raw, &value) != nil { -+ return bytes.TrimSpace(raw) -+ } -+ canonical, errMarshal := json.Marshal(value) -+ if errMarshal != nil { -+ return bytes.TrimSpace(raw) -+ } -+ return canonical -+} -+ -+func antigravityReplayItemContextMatches(payload []byte, itemResult gjson.Result, contentIndex int) bool { -+ expected := strings.TrimSpace(itemResult.Get("contextHash").String()) -+ return expected == "" || expected == antigravityReplayContextFingerprint(payload, contentIndex) -+} -+ -+func antigravitySetReplayItemContextHash(item []byte, payload []byte, contentIndex int) []byte { -+ if contextHash := antigravityReplayContextFingerprint(payload, contentIndex); contextHash != "" { -+ item, _ = sjson.SetBytes(item, "contextHash", contextHash) -+ } -+ return item -+} -+ -+func antigravityThoughtSignatureReplayPartPath(payload []byte, itemResult gjson.Result) (string, bool) { -+ ci := int(itemResult.Get("contentIndex").Int()) -+ contents := gjson.GetBytes(payload, "request.contents") -+ if !contents.IsArray() { -+ return "", false -+ } -+ contentArr := contents.Array() -+ if ci < 0 || ci >= len(contentArr) || !strings.EqualFold(strings.TrimSpace(contentArr[ci].Get("role").String()), "model") { -+ return "", false -+ } -+ if !antigravityReplayItemContextMatches(payload, itemResult, ci) { -+ return "", false -+ } -+ parts := contentArr[ci].Get("parts") -+ if !parts.IsArray() { -+ return "", false -+ } -+ partArr := parts.Array() -+ targetKind := strings.TrimSpace(itemResult.Get("targetKind").String()) -+ targetHash := strings.TrimSpace(itemResult.Get("targetHash").String()) -+ if targetHash != "" { -+ if targetOccurrence := itemResult.Get("targetOccurrence"); targetOccurrence.Exists() { -+ wanted := int(targetOccurrence.Int()) -+ occurrence := 0 -+ for pi, part := range partArr { -+ kind, fingerprint := antigravityReplayPartFingerprint(part) -+ if fingerprint != targetHash || (targetKind != "" && kind != targetKind) { -+ continue -+ } -+ if occurrence == wanted { -+ return fmt.Sprintf("request.contents.%d.parts.%d", ci, pi), true -+ } -+ occurrence++ -+ } -+ return "", false -+ } -+ pi := int(itemResult.Get("partIndex").Int()) -+ if pi >= 0 && pi < len(partArr) { -+ kind, fingerprint := antigravityReplayPartFingerprint(partArr[pi]) -+ if fingerprint == targetHash && (targetKind == "" || kind == targetKind) { -+ return fmt.Sprintf("request.contents.%d.parts.%d", ci, pi), true -+ } -+ } -+ for pi, part := range partArr { -+ kind, fingerprint := antigravityReplayPartFingerprint(part) -+ if fingerprint == targetHash && (targetKind == "" || kind == targetKind) { -+ return fmt.Sprintf("request.contents.%d.parts.%d", ci, pi), true -+ } -+ } -+ return "", false -+ } -+ -+ pi := int(itemResult.Get("partIndex").Int()) -+ if pi >= 0 && pi < len(partArr) && partArr[pi].Type != gjson.Null { -+ if kind, _ := antigravityReplayPartFingerprint(partArr[pi]); kind != "" { -+ return fmt.Sprintf("request.contents.%d.parts.%d", ci, pi), true -+ } -+ } -+ // Legacy cache entries may point at a streamed signature-only part after -+ // multiple text chunks. Attach them to the last semantic part in the same -+ // model content, never to a different turn. -+ for candidate := len(partArr) - 1; candidate >= 0; candidate-- { -+ if kind, _ := antigravityReplayPartFingerprint(partArr[candidate]); kind != "" { -+ return fmt.Sprintf("request.contents.%d.parts.%d", ci, candidate), true -+ } -+ } -+ return "", false - } - - func antigravityExistingReplayPartPath(payload []byte, contentIndex int, partIndex int) (string, bool) { -@@ -404,26 +964,30 @@ func antigravityReplayPartWritePath(payload []byte, contentIndex int, partIndex - } - - func insertAntigravityReasoningReplayItems(payload []byte, items [][]byte) ([]byte, bool) { -+ return insertAntigravityReasoningReplayItemsWithSchemas(payload, items, nil) -+} -+ -+func insertAntigravityReasoningReplayItemsWithSchemas(payload []byte, items [][]byte, toolSchemas map[string]any) ([]byte, bool) { - out := payload - changed := false - for _, item := range items { - itemResult := gjson.ParseBytes(item) - switch strings.TrimSpace(itemResult.Get("type").String()) { - case "thought_signature": -- ci := antigravityReasoningReplayResolveContentIndex(out, int(itemResult.Get("contentIndex").Int())) -- pi := int(itemResult.Get("partIndex").Int()) - sig := strings.TrimSpace(itemResult.Get("thoughtSignature").String()) - if sig == "" { - continue - } -- partPath, exists := antigravityExistingReplayPartPath(out, ci, pi) -- if exists { -- path := partPath + ".thoughtSignature" -- if strings.TrimSpace(gjson.GetBytes(out, path).String()) != "" { -- continue -- } -+ partPath, exists := antigravityThoughtSignatureReplayPartPath(out, itemResult) -+ if !exists { -+ continue -+ } -+ path := partPath + ".thoughtSignature" -+ if antigravityHasNativeThoughtSignature(gjson.GetBytes(out, path).String()) { -+ continue - } -- path := antigravityReplayPartWritePath(out, ci, pi) + ".thoughtSignature" -+ ci := int(itemResult.Get("contentIndex").Int()) -+ out = antigravityRemoveThoughtSignatureFromOtherParts(out, ci, sig, partPath) - updated, err := sjson.SetBytes(out, path, sig) - if err != nil { - continue -@@ -431,7 +995,7 @@ func insertAntigravityReasoningReplayItems(payload []byte, items [][]byte) ([]by - out = updated - changed = true - case "function_call_part": -- updated, ok := mergeAntigravityFunctionCallPartReplay(out, itemResult) -+ updated, ok := mergeAntigravityFunctionCallPartReplayWithSchemas(out, itemResult, toolSchemas) - if ok { - out = updated - changed = true -@@ -441,7 +1005,118 @@ func insertAntigravityReasoningReplayItems(payload []byte, items [][]byte) ([]by - return out, changed - } - -+func antigravityNativeFunctionCallJSON(itemResult gjson.Result, fallbackID string) ([]byte, bool) { -+ name := strings.TrimSpace(itemResult.Get("name").String()) -+ args := itemResult.Get("args") -+ if name == "" || !args.Exists() { -+ return nil, false -+ } -+ functionCall := []byte(`{"name":""}`) -+ functionCall, _ = sjson.SetBytes(functionCall, "name", name) -+ callID := strings.TrimSpace(itemResult.Get("call_id").String()) -+ if callID == "" { -+ callID = fallbackID -+ } -+ if callID != "" { -+ functionCall, _ = sjson.SetBytes(functionCall, "id", callID) -+ } -+ if args.Type == gjson.String { -+ if parsed := gjson.Parse(args.String()); parsed.Exists() { -+ functionCall, _ = sjson.SetRawBytes(functionCall, "args", []byte(parsed.Raw)) -+ } else { -+ functionCall, _ = sjson.SetBytes(functionCall, "args", args.String()) -+ } -+ } else { -+ functionCall, _ = sjson.SetRawBytes(functionCall, "args", []byte(args.Raw)) -+ } -+ return functionCall, true -+} -+ -+func antigravityFunctionResponsesCanRestoreID(payload []byte, currentID, nativeName string) bool { -+ if currentID == "" { -+ return true -+ } -+ contents := gjson.GetBytes(payload, "request.contents") -+ if !contents.IsArray() { -+ return false -+ } -+ valid := true -+ contents.ForEach(func(_, content gjson.Result) bool { -+ content.Get("parts").ForEach(func(_, part gjson.Result) bool { -+ response := part.Get("functionResponse") -+ if !response.Exists() || strings.TrimSpace(response.Get("id").String()) != currentID { -+ return true -+ } -+ name := strings.TrimSpace(response.Get("name").String()) -+ valid = name == "" || name == "unknown" || name == nativeName -+ return valid -+ }) -+ return valid -+ }) -+ return valid -+} -+ -+func restoreAntigravityNativeFunctionCallReplay(payload []byte, contentIndex, partIndex int, itemResult gjson.Result, allowLegacyIDRestore bool) ([]byte, bool) { -+ partPath := fmt.Sprintf("request.contents.%d.parts.%d", contentIndex, partIndex) -+ currentCall := gjson.GetBytes(payload, partPath+".functionCall") -+ if !currentCall.Exists() { -+ return payload, false -+ } -+ currentID := strings.TrimSpace(currentCall.Get("id").String()) -+ nativeID := strings.TrimSpace(itemResult.Get("call_id").String()) -+ nativeName := strings.TrimSpace(itemResult.Get("name").String()) -+ restoreIdentity := currentID == nativeID || util.IsGeminiClaudeToolUseID(currentID) || allowLegacyIDRestore -+ if !restoreIdentity { -+ signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String()) -+ if signature == "" || antigravityHasNativeThoughtSignature(gjson.GetBytes(payload, partPath+".thoughtSignature").String()) { -+ return payload, false -+ } -+ payload = antigravityRemoveThoughtSignatureFromOtherParts(payload, contentIndex, signature, partPath) -+ updated, errSet := sjson.SetBytes(payload, partPath+".thoughtSignature", signature) -+ return updated, errSet == nil -+ } -+ if currentID != nativeID && !antigravityFunctionResponsesCanRestoreID(payload, currentID, nativeName) { -+ return payload, false -+ } -+ nativeCall, okCall := antigravityNativeFunctionCallJSON(itemResult, currentID) -+ if !okCall { -+ return payload, false -+ } -+ out, errSet := sjson.SetRawBytes(payload, partPath+".functionCall", nativeCall) -+ if errSet != nil { -+ return payload, false -+ } -+ for _, field := range []string{"thoughtSignature", "thought_signature", "extra_content.google.thought_signature"} { -+ out, _ = sjson.DeleteBytes(out, partPath+"."+field) -+ } -+ if signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String()); signature != "" { -+ out = antigravityRemoveThoughtSignatureFromOtherParts(out, contentIndex, signature, partPath) -+ out, _ = sjson.SetBytes(out, partPath+".thoughtSignature", signature) -+ } -+ if currentID != "" && nativeID != "" && currentID != nativeID { -+ contents := gjson.GetBytes(out, "request.contents") -+ contents.ForEach(func(contentKey, content gjson.Result) bool { -+ content.Get("parts").ForEach(func(partKey, part gjson.Result) bool { -+ response := part.Get("functionResponse") -+ if !response.Exists() || strings.TrimSpace(response.Get("id").String()) != currentID { -+ return true -+ } -+ responsePath := fmt.Sprintf("request.contents.%d.parts.%d.functionResponse", contentKey.Int(), partKey.Int()) -+ out, _ = sjson.SetBytes(out, responsePath+".id", nativeID) -+ out, _ = sjson.SetBytes(out, responsePath+".name", nativeName) -+ return true -+ }) -+ return true -+ }) -+ } -+ return out, !bytes.Equal(out, payload) -+} -+ - func mergeAntigravityFunctionCallPartReplay(payload []byte, itemResult gjson.Result) ([]byte, bool) { -+ return mergeAntigravityFunctionCallPartReplayWithSchemas(payload, itemResult, nil) -+} -+ -+func mergeAntigravityFunctionCallPartReplayWithSchemas(payload []byte, itemResult gjson.Result, toolSchemas map[string]any) ([]byte, bool) { - name := strings.TrimSpace(itemResult.Get("name").String()) - args := itemResult.Get("args") - callID := strings.TrimSpace(itemResult.Get("call_id").String()) -@@ -449,24 +1124,37 @@ func mergeAntigravityFunctionCallPartReplay(payload []byte, itemResult gjson.Res - if name == "" || !args.Exists() { - return payload, false - } -+ if ci, pi, exists := antigravityFunctionCallPartLocationForReplayWithSchemas(payload, itemResult, toolSchemas); exists { -+ _, allowLegacyIDRestore := toolSchemas[name] -+ return restoreAntigravityNativeFunctionCallReplay(payload, ci, pi, itemResult, allowLegacyIDRestore) -+ } - if callID != "" { -- if ci, pi, exists := antigravityFunctionCallPartLocation(payload, callID); exists { -- if sig != "" { -- pathSig := fmt.Sprintf("request.contents.%d.parts.%d.thoughtSignature", ci, pi) -- if strings.TrimSpace(gjson.GetBytes(payload, pathSig).String()) == "" { -- if updated, err := sjson.SetBytes(payload, pathSig, sig); err == nil { -- return updated, true -- } -- } -- } -+ if antigravityPayloadHasFunctionCallID(payload, callID) { -+ // The ID is present but its semantic payload did not match above. Never -+ // replay or reinsert an opaque signature onto that changed call. - return payload, false - } - if frIndex, ok := antigravityFunctionResponseContentIndex(payload, callID); ok { -- return insertAntigravityModelFunctionCallBeforeContent(payload, frIndex, name, callID, sig, args) -+ parallelModelIndex := frIndex - 1 -+ if parallelModelIndex >= 0 && strings.EqualFold(strings.TrimSpace(gjson.GetBytes(payload, fmt.Sprintf("request.contents.%d.role", parallelModelIndex)).String()), "model") && antigravityReplayItemContextMatches(payload, itemResult, parallelModelIndex) { -+ if updated, appended := appendAntigravityFunctionCallToModelContent(payload, parallelModelIndex, name, callID, sig, args); appended { -+ return updated, true -+ } -+ } -+ if antigravityReplayItemContextMatches(payload, itemResult, frIndex) { -+ return insertAntigravityModelFunctionCallBeforeContent(payload, frIndex, name, callID, sig, args) -+ } - } -+ } else { -+ // Without a native call ID, only an exact semantic match is safe. Never -+ // put an opaque signature on a different call at the old numeric slot. -+ return payload, false - } - - ci := antigravityReasoningReplayResolveContentIndex(payload, int(itemResult.Get("contentIndex").Int())) -+ if ci < 0 || !antigravityReplayItemContextMatches(payload, itemResult, ci) { -+ return payload, false -+ } - pi := int(itemResult.Get("partIndex").Int()) - out := payload - changed := false -@@ -496,7 +1184,8 @@ func mergeAntigravityFunctionCallPartReplay(payload []byte, itemResult gjson.Res - } - - pathSig := partPath + ".thoughtSignature" -- if sig != "" && strings.TrimSpace(gjson.GetBytes(out, pathSig).String()) == "" { -+ if sig != "" && !antigravityHasNativeThoughtSignature(gjson.GetBytes(out, pathSig).String()) { -+ out = antigravityRemoveThoughtSignatureFromOtherParts(out, ci, sig, partPath) - if updated, err := sjson.SetBytes(out, pathSig, sig); err == nil { - out = updated - changed = true -@@ -524,12 +1213,30 @@ func mergeAntigravityFunctionCallPartReplay(payload []byte, itemResult gjson.Res - return out, changed - } - -+type antigravityPendingThoughtSignature struct { -+ signature string -+ targetKind string -+} -+ - type antigravityReasoningReplayAccumulator struct { -- scope antigravityReasoningReplayScope -- items [][]byte -- seenFC map[string]bool -- contentIndex int -- nextPartIndex int -+ scope antigravityReasoningReplayScope -+ requestPayload []byte -+ items [][]byte -+ seenFC map[string]bool -+ seenSignatures map[string]bool -+ segmentOccurrences map[string]int -+ functionCallOccurrences map[string]int -+ contentIndex int -+ nextPartIndex int -+ visibleText strings.Builder -+ thoughtText strings.Builder -+ visiblePartIndex int -+ thoughtPartIndex int -+ lastResponseKind string -+ pendingSignatures []antigravityPendingThoughtSignature -+ itemBytes int -+ overflow bool -+ terminal bool - } - - func newAntigravityReasoningReplayAccumulator(scope antigravityReasoningReplayScope, requestPayload []byte) *antigravityReasoningReplayAccumulator { -@@ -537,11 +1244,144 @@ func newAntigravityReasoningReplayAccumulator(scope antigravityReasoningReplaySc - return nil - } - contentIndex, basePartIndex := antigravityReasoningReplayPendingModelContentIndex(requestPayload) -+ items := antigravityReasoningReplayItemsFromRequest(requestPayload) -+ seenSignatures := make(map[string]bool, len(items)) -+ for _, item := range items { -+ itemResult := gjson.ParseBytes(item) -+ if signature := strings.TrimSpace(itemResult.Get("thoughtSignature").String()); signature != "" { -+ seenSignatures[signature] = true -+ } -+ } -+ itemBytes := 0 -+ for _, item := range items { -+ itemBytes += len(item) -+ } -+ segmentOccurrences := make(map[string]int) -+ functionCallOccurrences := make(map[string]int) -+ if parts := gjson.GetBytes(requestPayload, fmt.Sprintf("request.contents.%d.parts", contentIndex)); parts.IsArray() { -+ parts.ForEach(func(_, part gjson.Result) bool { -+ if fc := part.Get("functionCall"); fc.Exists() { -+ key := antigravityFunctionCallKey(fc.Get("name").String(), fc.Get("args").Raw, "") -+ if key != "" { -+ functionCallOccurrences[key]++ -+ } -+ return true -+ } -+ if kind, fingerprint := antigravityReplayPartFingerprint(part); fingerprint != "" { -+ segmentOccurrences[kind+"\x00"+fingerprint]++ -+ } -+ return true -+ }) -+ } - return &antigravityReasoningReplayAccumulator{ -- scope: scope, -- seenFC: make(map[string]bool), -- contentIndex: contentIndex, -- nextPartIndex: basePartIndex, -+ scope: scope, -+ requestPayload: append([]byte(nil), requestPayload...), -+ items: items, -+ seenFC: make(map[string]bool), -+ seenSignatures: seenSignatures, -+ segmentOccurrences: segmentOccurrences, -+ functionCallOccurrences: functionCallOccurrences, -+ contentIndex: contentIndex, -+ nextPartIndex: basePartIndex, -+ visiblePartIndex: -1, -+ thoughtPartIndex: -1, -+ itemBytes: itemBytes, -+ overflow: len(items) > internalcache.AntigravityReasoningReplayCacheMaxItemsPerEntry || itemBytes > internalcache.AntigravityReasoningReplayCacheMaxBytesPerEntry, -+ } -+} -+ -+func antigravityReasoningReplayItemsFromRequest(payload []byte) [][]byte { -+ contents := gjson.GetBytes(payload, "request.contents") -+ if !contents.IsArray() { -+ return nil -+ } -+ items := make([][]byte, 0) -+ contents.ForEach(func(contentKey, content gjson.Result) bool { -+ if !strings.EqualFold(strings.TrimSpace(content.Get("role").String()), "model") { -+ return true -+ } -+ ci := int(contentKey.Int()) -+ parts := content.Get("parts") -+ if !parts.IsArray() { -+ return true -+ } -+ partArr := parts.Array() -+ functionCallOccurrences := make(map[string]int) -+ for pi, part := range partArr { -+ signature := antigravityNativePartThoughtSignature(part) -+ if !antigravityHasNativeThoughtSignature(signature) { -+ signature = "" -+ } -+ if fc := part.Get("functionCall"); fc.Exists() { -+ key := antigravityFunctionCallKey(fc.Get("name").String(), fc.Get("args").Raw, "") -+ occurrence := functionCallOccurrences[key] -+ if key != "" { -+ functionCallOccurrences[key] = occurrence + 1 -+ } -+ if item := buildAntigravityFunctionCallPartItem(ci, pi, occurrence, fc, signature); len(item) > 0 { -+ items = append(items, antigravitySetReplayItemContextHash(item, payload, ci)) -+ } -+ continue -+ } -+ if signature == "" { -+ continue -+ } -+ targetPart := part -+ targetPI := pi -+ kind, fingerprint := antigravityReplayPartFingerprint(targetPart) -+ if fingerprint == "" && pi > 0 { -+ targetPI = pi - 1 -+ targetPart = partArr[targetPI] -+ kind, fingerprint = antigravityReplayPartFingerprint(targetPart) -+ } -+ if fingerprint == "" { -+ continue -+ } -+ item := buildAntigravityThoughtSignatureItem(ci, targetPI, signature, kind, fingerprint) -+ item, _ = sjson.SetBytes(item, "targetOccurrence", antigravityReplayPartOccurrence(partArr, targetPI, kind, fingerprint)) -+ items = append(items, antigravitySetReplayItemContextHash(item, payload, ci)) -+ } -+ return true -+ }) -+ return items -+} -+ -+func (a *antigravityReasoningReplayAccumulator) appendItem(item []byte) { -+ if a == nil || len(item) == 0 || a.overflow { -+ return -+ } -+ if len(a.items)+1 > internalcache.AntigravityReasoningReplayCacheMaxItemsPerEntry || a.itemBytes+len(item) > internalcache.AntigravityReasoningReplayCacheMaxBytesPerEntry { -+ a.overflow = true -+ return -+ } -+ a.items = append(a.items, item) -+ a.itemBytes += len(item) -+} -+ -+func (a *antigravityReasoningReplayAccumulator) attachDetachedSignatureToLastFunctionCall(signature string) { -+ if a == nil || signature == "" { -+ return -+ } -+ for itemIndex := len(a.items) - 1; itemIndex >= 0; itemIndex-- { -+ item := gjson.ParseBytes(a.items[itemIndex]) -+ if item.Get("type").String() != "function_call_part" { -+ continue -+ } -+ if strings.TrimSpace(item.Get("thoughtSignature").String()) != "" { -+ return -+ } -+ updated, errSet := sjson.SetBytes(a.items[itemIndex], "thoughtSignature", signature) -+ if errSet != nil { -+ return -+ } -+ delta := len(updated) - len(a.items[itemIndex]) -+ if a.itemBytes+delta > internalcache.AntigravityReasoningReplayCacheMaxBytesPerEntry { -+ a.overflow = true -+ return -+ } -+ a.items[itemIndex] = updated -+ a.itemBytes += delta -+ return - } - } - -@@ -557,6 +1397,9 @@ func (a *antigravityReasoningReplayAccumulator) ObserveSSELine(line []byte) { - } - - func (a *antigravityReasoningReplayAccumulator) observeResponsePayload(payload []byte) { -+ if finishReason := strings.TrimSpace(gjson.GetBytes(payload, "response.candidates.0.finishReason").String()); finishReason != "" { -+ a.terminal = true -+ } - parts := gjson.GetBytes(payload, "response.candidates.0.content.parts") - if !parts.IsArray() { - return -@@ -564,42 +1407,168 @@ func (a *antigravityReasoningReplayAccumulator) observeResponsePayload(payload [ - parts.ForEach(func(_, part gjson.Result) bool { - pi := a.nextPartIndex - a.nextPartIndex++ -- sig := antigravityNativePartThoughtSignature(part) -+ signature := antigravityNativePartThoughtSignature(part) -+ if !antigravityHasNativeThoughtSignature(signature) { -+ signature = "" -+ } - if fc := part.Get("functionCall"); fc.Exists() { -+ if a.lastResponseKind == "text" || a.lastResponseKind == "thought" { -+ a.flushPendingThoughtSignaturesForKind(a.lastResponseKind) -+ } -+ if signature != "" { -+ remainingPending := a.pendingSignatures[:0] -+ for _, pending := range a.pendingSignatures { -+ if pending.targetKind != "" { -+ remainingPending = append(remainingPending, pending) -+ } -+ } -+ a.pendingSignatures = remainingPending -+ } -+ if signature == "" { -+ for pendingIndex := len(a.pendingSignatures) - 1; pendingIndex >= 0; pendingIndex-- { -+ if a.pendingSignatures[pendingIndex].targetKind == "" { -+ signature = a.pendingSignatures[pendingIndex].signature -+ a.pendingSignatures = append(a.pendingSignatures[:pendingIndex], a.pendingSignatures[pendingIndex+1:]...) -+ break -+ } -+ } -+ } - keys := antigravityReplayToolCallKeysFromPart(fc) -- for _, k := range keys { -- if a.seenFC[k] { -+ for _, key := range keys { -+ dedupeKey := key + "\x00" + signature -+ if signature == "" { -+ dedupeKey = fmt.Sprintf("%s\x00part:%d", key, pi) -+ } -+ if a.seenFC[dedupeKey] { - return true - } -+ a.seenFC[dedupeKey] = true - } -- for _, k := range keys { -- a.seenFC[k] = true -+ occurrenceKey := antigravityFunctionCallKey(fc.Get("name").String(), fc.Get("args").Raw, "") -+ occurrence := a.functionCallOccurrences[occurrenceKey] -+ if occurrenceKey != "" { -+ a.functionCallOccurrences[occurrenceKey] = occurrence + 1 - } -- item := buildAntigravityFunctionCallPartItem(a.contentIndex, pi, fc, sig) -+ item := buildAntigravityFunctionCallPartItem(a.contentIndex, pi, occurrence, fc, signature) - if len(item) > 0 { -- a.items = append(a.items, item) -+ a.appendItem(antigravitySetReplayItemContextHash(item, a.requestPayload, a.contentIndex)) -+ if signature != "" { -+ a.seenSignatures[signature] = true -+ } - } -+ a.lastResponseKind = "function_call" - return true - } -- if sig != "" { -- item := buildAntigravityThoughtSignatureItem(a.contentIndex, pi, sig) -- a.items = append(a.items, item) -+ -+ targetKind := "" -+ if part.Get("thought").Bool() { -+ targetKind = "thought" -+ } -+ text := part.Get("text") -+ hasSemanticText := text.Exists() && text.String() != "" -+ signatureOnly := signature != "" && !hasSemanticText -+ if signatureOnly && a.lastResponseKind == "function_call" { -+ if !a.seenSignatures[signature] { -+ a.attachDetachedSignatureToLastFunctionCall(signature) -+ a.seenSignatures[signature] = true -+ } -+ return true -+ } -+ if hasSemanticText { -+ if targetKind != "thought" { -+ targetKind = "text" -+ } -+ if signature != "" { -+ remainingPending := a.pendingSignatures[:0] -+ for _, pending := range a.pendingSignatures { -+ unboundPrefix := pending.targetKind == "" -+ if pending.targetKind == targetKind { -+ unboundPrefix = (targetKind == "text" && a.visibleText.Len() == 0) || (targetKind == "thought" && a.thoughtText.Len() == 0) -+ } -+ if unboundPrefix { -+ if pending.signature == signature { -+ delete(a.seenSignatures, signature) -+ } -+ continue -+ } -+ remainingPending = append(remainingPending, pending) -+ } -+ a.pendingSignatures = remainingPending -+ for _, pending := range a.pendingSignatures { -+ if pending.targetKind == targetKind && pending.signature != signature { -+ a.flushPendingThoughtSignaturesForKind(targetKind) -+ break -+ } -+ } -+ } -+ if a.lastResponseKind != "" && a.lastResponseKind != targetKind && (a.lastResponseKind == "text" || a.lastResponseKind == "thought") { -+ a.flushPendingThoughtSignaturesForKind(a.lastResponseKind) -+ } -+ if targetKind == "thought" { -+ if a.thoughtText.Len() == 0 { -+ a.thoughtPartIndex = pi -+ } -+ a.thoughtText.WriteString(text.String()) -+ } else { -+ if a.visibleText.Len() == 0 { -+ a.visiblePartIndex = pi -+ } -+ a.visibleText.WriteString(text.String()) -+ } -+ a.lastResponseKind = targetKind -+ } -+ acceptedSignature := false -+ if signature != "" && !a.seenSignatures[signature] { -+ if targetKind == "" { -+ targetKind = a.lastResponseKind -+ } -+ unmatchedDetachedCarrier := signatureOnly && a.lastResponseKind == targetKind && ((targetKind == "text" && a.visibleText.Len() == 0) || (targetKind == "thought" && a.thoughtText.Len() == 0)) -+ if unmatchedDetachedCarrier { -+ a.seenSignatures[signature] = true -+ } else if len(a.pendingSignatures)+len(a.items)+1 > internalcache.AntigravityReasoningReplayCacheMaxItemsPerEntry || a.itemBytes+len(signature) > internalcache.AntigravityReasoningReplayCacheMaxBytesPerEntry { -+ a.overflow = true -+ a.seenSignatures[signature] = true -+ } else { -+ a.pendingSignatures = append(a.pendingSignatures, antigravityPendingThoughtSignature{signature: signature, targetKind: targetKind}) -+ a.seenSignatures[signature] = true -+ acceptedSignature = true -+ } -+ } -+ if acceptedSignature && (signatureOnly || hasSemanticText) { -+ switch targetKind { -+ case "text": -+ if a.visibleText.Len() > 0 { -+ a.flushPendingThoughtSignaturesForKind("text") -+ } -+ case "thought": -+ if a.thoughtText.Len() > 0 { -+ a.flushPendingThoughtSignaturesForKind("thought") -+ } -+ } - } - return true - }) - } - --func buildAntigravityThoughtSignatureItem(contentIndex, partIndex int, signature string) []byte { -- return []byte(fmt.Sprintf(`{"type":"thought_signature","thoughtSignature":%q,"contentIndex":%d,"partIndex":%d}`, -+func buildAntigravityThoughtSignatureItem(contentIndex, partIndex int, signature, targetKind, targetHash string) []byte { -+ item := []byte(fmt.Sprintf(`{"type":"thought_signature","thoughtSignature":%q,"contentIndex":%d,"partIndex":%d}`, - signature, contentIndex, partIndex)) -+ if targetKind != "" { -+ item, _ = sjson.SetBytes(item, "targetKind", targetKind) -+ } -+ if targetHash != "" { -+ item, _ = sjson.SetBytes(item, "targetHash", targetHash) -+ } -+ return item - } - --func buildAntigravityFunctionCallPartItem(contentIndex, partIndex int, fc gjson.Result, signature string) []byte { -+func buildAntigravityFunctionCallPartItem(contentIndex, partIndex, targetOccurrence int, fc gjson.Result, signature string) []byte { - item := map[string]any{ -- "type": "function_call_part", -- "contentIndex": contentIndex, -- "partIndex": partIndex, -- "name": fc.Get("name").String(), -+ "type": "function_call_part", -+ "contentIndex": contentIndex, -+ "partIndex": partIndex, -+ "targetOccurrence": targetOccurrence, -+ "name": fc.Get("name").String(), - } - if id := strings.TrimSpace(fc.Get("id").String()); id != "" { - item["call_id"] = id -@@ -621,12 +1590,88 @@ func buildAntigravityFunctionCallPartItem(contentIndex, partIndex int, fc gjson. - return raw - } - --func (a *antigravityReasoningReplayAccumulator) Flush(ctx context.Context) { -- if a == nil || !a.scope.valid() || len(a.items) == 0 { -+func (a *antigravityReasoningReplayAccumulator) flushPendingThoughtSignaturesForKind(targetKind string) { -+ if a == nil || (targetKind != "text" && targetKind != "thought") { -+ return -+ } -+ text := a.visibleText.String() -+ partIndex := a.visiblePartIndex -+ if targetKind == "thought" { -+ text = a.thoughtText.String() -+ partIndex = a.thoughtPartIndex -+ } -+ targetHash := "" -+ targetOccurrence := 0 -+ if text != "" { -+ sum := sha256.Sum256([]byte(targetKind + "\x00" + text)) -+ targetHash = fmt.Sprintf("%x", sum[:]) -+ occurrenceKey := targetKind + "\x00" + targetHash -+ targetOccurrence = a.segmentOccurrences[occurrenceKey] -+ a.segmentOccurrences[occurrenceKey] = targetOccurrence + 1 -+ } -+ remaining := a.pendingSignatures[:0] -+ for _, pending := range a.pendingSignatures { -+ if pending.targetKind != targetKind || targetHash == "" { -+ remaining = append(remaining, pending) -+ continue -+ } -+ item := buildAntigravityThoughtSignatureItem(a.contentIndex, partIndex, pending.signature, targetKind, targetHash) -+ item, _ = sjson.SetBytes(item, "targetOccurrence", targetOccurrence) -+ a.appendItem(antigravitySetReplayItemContextHash(item, a.requestPayload, a.contentIndex)) -+ } -+ a.pendingSignatures = remaining -+ if targetKind == "thought" { -+ a.thoughtText.Reset() -+ a.thoughtPartIndex = -1 -+ } else { -+ a.visibleText.Reset() -+ a.visiblePartIndex = -1 -+ } -+} -+ -+func (a *antigravityReasoningReplayAccumulator) appendPendingThoughtSignatures() { -+ if a == nil { -+ return -+ } -+ for index := range a.pendingSignatures { -+ if a.pendingSignatures[index].targetKind != "" { -+ continue -+ } -+ switch { -+ case a.lastResponseKind == "text" && a.visibleText.Len() > 0: -+ a.pendingSignatures[index].targetKind = "text" -+ case a.lastResponseKind == "thought" && a.thoughtText.Len() > 0: -+ a.pendingSignatures[index].targetKind = "thought" -+ case a.visibleText.Len() > 0: -+ a.pendingSignatures[index].targetKind = "text" -+ case a.thoughtText.Len() > 0: -+ a.pendingSignatures[index].targetKind = "thought" -+ } -+ } -+ a.flushPendingThoughtSignaturesForKind("thought") -+ a.flushPendingThoughtSignaturesForKind("text") -+ a.pendingSignatures = nil -+} -+ -+func (a *antigravityReasoningReplayAccumulator) Commit(ctx context.Context) { -+ if a == nil || !a.scope.valid() || !a.terminal { -+ return -+ } -+ if a.overflow { -+ _, _ = internalcache.DeleteAntigravityReasoningReplayItemsIfUnchanged(ctx, a.scope.modelName, a.scope.sessionKey, a.scope.cacheSnapshot) -+ return -+ } -+ a.appendPendingThoughtSignatures() -+ if a.overflow { -+ _, _ = internalcache.DeleteAntigravityReasoningReplayItemsIfUnchanged(ctx, a.scope.modelName, a.scope.sessionKey, a.scope.cacheSnapshot) -+ return -+ } -+ if len(a.items) == 0 { -+ _, _ = internalcache.DeleteAntigravityReasoningReplayItemsIfUnchanged(ctx, a.scope.modelName, a.scope.sessionKey, a.scope.cacheSnapshot) - return - } -- if !internalcache.CacheAntigravityReasoningReplayItemsBestEffort(ctx, a.scope.modelName, a.scope.sessionKey, a.items) { -- _ = internalcache.DeleteAntigravityReasoningReplayItemRequired(ctx, a.scope.modelName, a.scope.sessionKey) -+ if _, errReplace := internalcache.ReplaceAntigravityReasoningReplayItemsIfUnchanged(ctx, a.scope.modelName, a.scope.sessionKey, a.scope.cacheSnapshot, a.items); errReplace != nil { -+ _, _ = internalcache.DeleteAntigravityReasoningReplayItemsIfUnchanged(ctx, a.scope.modelName, a.scope.sessionKey, a.scope.cacheSnapshot) - } - } - -@@ -636,7 +1681,7 @@ func cacheAntigravityReasoningReplayFromResponse(ctx context.Context, scope anti - } - acc := newAntigravityReasoningReplayAccumulator(scope, requestPayload) - acc.observeResponsePayload(body) -- acc.Flush(ctx) -+ acc.Commit(ctx) - } - - func applyAntigravityNativeSignatureReplayIfNeeded(modelName string, payload []byte) []byte { -diff --git a/internal/runtime/executor/antigravity_reasoning_replay_clear_test.go b/internal/runtime/executor/antigravity_reasoning_replay_clear_test.go -index a15f15ece92..83e876f81bc 100644 ---- a/internal/runtime/executor/antigravity_reasoning_replay_clear_test.go -+++ b/internal/runtime/executor/antigravity_reasoning_replay_clear_test.go -@@ -47,7 +47,7 @@ func TestAntigravityReasoningReplayClearsOnInvalidSignature400(t *testing.T) { - }, - } - -- payload := []byte(`{"sessionId":"pr3900-invalid-sig","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"Bash","response":{"result":"ok"}}}]}]}}`) -+ payload := []byte(`{"sessionId":"pr3900-invalid-sig","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"model","parts":[{"functionCall":{"id":"id1","name":"Bash","args":{}}}]},{"role":"model","parts":[{"functionResponse":{"id":"id1","name":"Bash","response":{"result":"ok"}}}]}]}}`) - _, err := exec.Execute(context.Background(), auth, cliproxyexecutor.Request{ - Model: model, - Payload: payload, -diff --git a/internal/runtime/executor/antigravity_reasoning_replay_test.go b/internal/runtime/executor/antigravity_reasoning_replay_test.go -index 98f39d416a1..10e40fb84a2 100644 ---- a/internal/runtime/executor/antigravity_reasoning_replay_test.go -+++ b/internal/runtime/executor/antigravity_reasoning_replay_test.go -@@ -2,11 +2,16 @@ package executor - - import ( - "context" -+ "fmt" -+ "net/http" - "strings" - "testing" - - internalcache "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" -+ internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" -+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util" - cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" -+ sdktranslator "github.com/router-for-me/CLIProxyAPI/v7/sdk/translator" - "github.com/tidwall/gjson" - ) - -@@ -25,10 +30,10 @@ func TestAntigravityReasoningReplayAccumulatorMultiToolSSEChunks(t *testing.T) { - } - - line1 := []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"sig-first","functionCall":{"name":"Read","args":{"file_path":"/a"},"id":"id1"}}]}}]}}`) -- line2 := []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"Read","args":{"file_path":"/b"},"id":"id2"}}]}}]}}`) -+ line2 := []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"Read","args":{"file_path":"/b"},"id":"id2"}}]},"finishReason":"STOP"}]}}`) - acc.ObserveSSELine(line1) - acc.ObserveSSELine(line2) -- acc.Flush(context.Background()) -+ acc.Commit(context.Background()) - - items, ok := internalcache.GetAntigravityReasoningReplayItems("gemini-3-flash-agent", "session:sess-1") - if !ok || len(items) != 2 { -@@ -44,6 +49,56 @@ func TestAntigravityReasoningReplayAccumulatorMultiToolSSEChunks(t *testing.T) { - } - } - -+func TestPrepareAntigravityGeminiReasoningReplayPayloadRejectsToolOutputsAcrossUserBoundary(t *testing.T) { -+ payload := []byte(`{"sessionId":"tool-output-boundary","request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}},{"functionCall":{"id":"call-2","name":"run","args":{}}}]},{"role":"model","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"one"}}}]},{"role":"user","parts":[{"text":"boundary"}]},{"role":"model","parts":[{"functionResponse":{"id":"call-2","name":"run","response":{"result":"two"}}}]}]}}`) -+ _, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if errPrepare == nil { -+ t.Fatal("invalid tool output history was not rejected") -+ } -+ status, ok := errPrepare.(statusErr) -+ if !ok || status.code != http.StatusBadRequest { -+ t.Fatalf("prepare error = %#v, want local 400", errPrepare) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayPayloadKeepsCacheForAlreadyInvalidToolHistory(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ const model, sessionKey = "gemini-3.6-flash-high", "session:invalid-injected-tool-history" -+ item := []byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"call_id":"call-2","name":"run","args":{},"thoughtSignature":"injected-tool-signature-123456789"}`) -+ if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) { -+ t.Fatal("cache write failed") -+ } -+ payload := []byte(`{"sessionId":"invalid-injected-tool-history","request":{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}}]},{"role":"model","parts":[{"functionResponse":{"id":"call-2","name":"run","response":{"result":"two"}}}]}]}}`) -+ _, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if errPrepare == nil { -+ t.Fatal("invalid replay-injected history was not rejected") -+ } -+ if _, found := internalcache.GetAntigravityReasoningReplayItems(model, sessionKey); !found { -+ t.Fatal("already-invalid client history cleared replay state") -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayPayloadKeepsCacheForClientMalformedHistory(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ const model, sessionKey = "gemini-3.6-flash-high", "session:client-malformed-history" -+ payload := []byte(`{"sessionId":"client-malformed-history","request":{"contents":[{"role":"model","parts":[{"text":"answer"}]},{"role":"model","parts":[{"functionResponse":{"id":"orphan","name":"run","response":{"result":"bad"}}}]}]}}`) -+ kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"answer"}`)) -+ item := buildAntigravityThoughtSignatureItem(0, 0, "valid-cache-signature-123456789", kind, fingerprint) -+ item = antigravitySetReplayItemContextHash(item, payload, 0) -+ if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) { -+ t.Fatal("cache write failed") -+ } -+ _, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if errPrepare == nil { -+ t.Fatal("client-malformed history was not rejected") -+ } -+ if _, found := internalcache.GetAntigravityReasoningReplayItems(model, sessionKey); !found { -+ t.Fatal("client-malformed history cleared unrelated valid replay state") -+ } -+} -+ - func TestPrepareAntigravityGeminiReasoningReplayPayloadInjectsCachedToolPart(t *testing.T) { - internalcache.ClearAntigravityReasoningReplayCache() - t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -@@ -77,6 +132,29 @@ func TestPrepareAntigravityGeminiReasoningReplayPayloadInjectsCachedToolPart(t * - } - } - -+func TestPrepareAntigravityGeminiReasoningReplayPayloadSanitizesInsertedUnsignedToolPart(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"name":"Read","call_id":"id1","args":{"file_path":"/a"}}`) -+ if !internalcache.CacheAntigravityReasoningReplayItems("gemini-3-flash-agent", "session:sess-unsigned-replay", [][]byte{item}) { -+ t.Fatal("cache write failed") -+ } -+ -+ payload := []byte(`{"sessionId":"sess-unsigned-replay","request":{"contents":[{"role":"user","parts":[{"text":"hi"}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"Read","response":{"result":"ok"}}}]}]}}`) -+ payload = sanitizeAntigravityGeminiRequestSignatures("gemini-3-flash-agent", payload) -+ out, _, err := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3-flash-agent", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if err != nil { -+ t.Fatalf("prepare error: %v", err) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "skip_thought_signature_validator" { -+ t.Fatalf("inserted first synthetic functionCall signature = %q, want bypass sentinel; output=%s", got, out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.2.role").String(); got != "model" { -+ t.Fatalf("replayed functionResponse role = %q, want native model role; output=%s", got, out) -+ } -+} -+ - func TestPrepareAntigravityGeminiReasoningReplayInsertsBeforeModelFunctionResponse(t *testing.T) { - internalcache.ClearAntigravityReasoningReplayCache() - t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -@@ -114,7 +192,7 @@ func TestMergeAntigravityFunctionCallPartReplayMergesSignatureIntoExistingFuncti - } - } - --func TestPrepareAntigravityGeminiReasoningReplayPayloadAppendsStaleThoughtSignatureWithoutNullParts(t *testing.T) { -+func TestPrepareAntigravityGeminiReasoningReplayPayloadDropsStaleThoughtSignature(t *testing.T) { - internalcache.ClearAntigravityReasoningReplayCache() - t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) - -@@ -128,19 +206,992 @@ func TestPrepareAntigravityGeminiReasoningReplayPayloadAppendsStaleThoughtSignat - } - - parts := gjson.GetBytes(out, "request.contents.1.parts").Array() -- if len(parts) != 2 { -- t.Fatalf("parts length = %d, want 2; body=%s", len(parts), out) -- } -- for i, part := range parts { -- if part.Type == gjson.Null { -- t.Fatalf("parts.%d is null; body=%s", i, out) -- } -+ if len(parts) != 1 { -+ t.Fatalf("parts length = %d, want unchanged single text part; body=%s", len(parts), out) - } - if got := parts[0].Get("text").String(); got != "visible answer" { - t.Fatalf("text part = %q, want visible answer; body=%s", got, out) - } -- if got := parts[1].Get("thoughtSignature").String(); got != "stale-thought-sig-ok12" { -- t.Fatalf("thoughtSignature = %q, want stale-thought-sig-ok12; body=%s", got, out) -+ if got := parts[0].Get("thoughtSignature").String(); got != "" { -+ t.Fatalf("stale thoughtSignature must not move to another turn, got %q; body=%s", got, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayAccumulatesCompleteTextSignatureChain(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ model = "gemini-3.6-flash-high" -+ sessionKey = "session:chain-session" -+ sig1 = "native-signature-turn-one-123456" -+ sig2 = "native-signature-turn-two-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: model, sessionKey: sessionKey} -+ -+ request1 := []byte(`{"sessionId":"chain-session","request":{"contents":[{"role":"user","parts":[{"text":"turn one"}]}]}}`) -+ acc1 := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc1.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer-"}]}}]}}`)) -+ acc1.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"one"}]}}]}}`)) -+ acc1.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + sig1 + `"}]},"finishReason":"STOP"}]}}`)) -+ acc1.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"chain-session","request":{"contents":[{"role":"user","parts":[{"text":"turn one"}]},{"role":"model","parts":[{"text":"answer-one"}]},{"role":"user","parts":[{"text":"turn two"}]}]}}`) -+ prepared2, _, errPrepare2 := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare2 != nil { -+ t.Fatal(errPrepare2) -+ } -+ if got := gjson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("turn 2 signature = %q, want %q; body=%s", got, sig1, prepared2) -+ } -+ if got := gjson.GetBytes(prepared2, "request.contents.1.parts.#").Int(); got != 1 { -+ t.Fatalf("turn 2 must attach signature in place, parts=%d; body=%s", got, prepared2) -+ } -+ -+ acc2 := newAntigravityReasoningReplayAccumulator(scope, prepared2) -+ acc2.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer-two"}]}}]}}`)) -+ acc2.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}]}}`)) -+ acc2.Commit(context.Background()) -+ -+ items, ok := internalcache.GetAntigravityReasoningReplayItems(model, sessionKey) -+ if !ok || len(items) != 2 { -+ t.Fatalf("cached chain length = %d ok=%v, want 2", len(items), ok) -+ } -+ -+ request3 := []byte(`{"sessionId":"chain-session","request":{"contents":[{"role":"user","parts":[{"text":"turn one"}]},{"role":"model","parts":[{"text":"answer-one"}]},{"role":"user","parts":[{"text":"turn two"}]},{"role":"model","parts":[{"text":"answer-two"}]},{"role":"user","parts":[{"text":"turn three"}]}]}}`) -+ prepared3, _, errPrepare3 := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request3) -+ if errPrepare3 != nil { -+ t.Fatal(errPrepare3) -+ } -+ if got := gjson.GetBytes(prepared3, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("turn 3 first signature = %q, want %q; body=%s", got, sig1, prepared3) -+ } -+ if got := gjson.GetBytes(prepared3, "request.contents.3.parts.0.thoughtSignature").String(); got != sig2 { -+ t.Fatalf("turn 3 second signature = %q, want %q; body=%s", got, sig2, prepared3) -+ } -+ if got := len(gjson.GetBytes(prepared3, "request.contents.1.parts").Array()) + len(gjson.GetBytes(prepared3, "request.contents.3.parts").Array()); got != 2 { -+ t.Fatalf("signatures must remain attached to native text parts, total parts=%d; body=%s", got, prepared3) -+ } -+} -+ -+func TestAntigravityReasoningReplaySplitsConsecutiveSignedTextSegments(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ sig1 = "consecutive-text-signature-one-123456" -+ sig2 = "consecutive-text-signature-two-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:consecutive-signed-text"} -+ request1 := []byte(`{"sessionId":"consecutive-signed-text","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"a"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"b","thoughtSignature":"` + sig1 + `"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"c","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"consecutive-signed-text","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"ab"},{"text":"c"}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("first consecutive text signature = %q, want %q; body=%s", got, sig1, out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 { -+ t.Fatalf("second consecutive text signature = %q, want %q; body=%s", got, sig2, out) -+ } -+} -+ -+func TestAntigravityReasoningReplaySignatureOnlyCarrierEndsTextSegment(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ sig1 = "trailing-text-signature-one-123456" -+ sig2 = "trailing-text-signature-two-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:trailing-signed-text"} -+ request1 := []byte(`{"sessionId":"trailing-signed-text","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"a"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + sig1 + `"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"b"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"c","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"trailing-signed-text","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"a"},{"text":"bc"}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("first trailing text signature = %q, want %q; body=%s", got, sig1, out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 { -+ t.Fatalf("second trailing text signature = %q, want %q; body=%s", got, sig2, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayDropsUnmatchedConsecutiveCarrier(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ sig1 = "matched-detached-signature-one-123456" -+ sig2 = "unmatched-detached-signature-two-123456" -+ sig3 = "matched-text-signature-three-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:unmatched-detached"} -+ request1 := []byte(`{"sessionId":"unmatched-detached","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"a"},{"text":"","thoughtSignature":"` + sig1 + `"},{"text":"","thoughtSignature":"` + sig2 + `"},{"text":"b","thoughtSignature":"` + sig3 + `"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"unmatched-detached","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"a"},{"text":"b"}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("first signature = %q, want %q; body=%s", got, sig1, out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig3 { -+ t.Fatalf("second signature = %q, want %q; body=%s", got, sig3, out) -+ } -+ if strings.Contains(string(out), sig2) { -+ t.Fatalf("unmatched carrier must not replace a semantic signature; body=%s", out) -+ } -+} -+ -+func TestAntigravityReasoningReplayDuplicateCarrierDoesNotSplitSegment(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ sig1 = "duplicate-thought-signature-one-123456" -+ sig2 = "following-thought-signature-two-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:duplicate-carrier"} -+ request1 := []byte(`{"sessionId":"duplicate-carrier","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"a","thought":true},{"text":"","thought":true,"thoughtSignature":"` + sig1 + `"},{"text":"b","thought":true},{"text":"","thought":true,"thoughtSignature":"` + sig1 + `"},{"text":"c","thought":true,"thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"duplicate-carrier","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"a","thought":true},{"text":"bc","thought":true}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("first thought signature = %q, want %q; body=%s", got, sig1, out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 { -+ t.Fatalf("second thought signature = %q, want %q; body=%s", got, sig2, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayDirectTextSignatureWinsOverUnboundPrefix(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ prefixSig = "unbound-prefix-signature-123456" -+ directSig = "direct-thought-signature-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:direct-over-prefix"} -+ request1 := []byte(`{"sessionId":"direct-over-prefix","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thought":true,"thoughtSignature":"` + prefixSig + `"},{"text":"hidden","thought":true,"thoughtSignature":"` + directSig + `"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"direct-over-prefix","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"hidden","thought":true}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != directSig { -+ t.Fatalf("thought signature = %q, want direct signature %q; body=%s", got, directSig, out) -+ } -+ if strings.Contains(string(out), prefixSig) { -+ t.Fatalf("unbound prefix must not replace a direct semantic signature; body=%s", out) -+ } -+} -+ -+func TestAntigravityReasoningReplaySameDirectSignatureReplacesPrefix(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const signature = "same-prefix-and-direct-signature-123456" -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:same-direct-prefix"} -+ request1 := []byte(`{"sessionId":"same-direct-prefix","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thought":true,"thoughtSignature":"` + signature + `"},{"text":"hidden","thought":true,"thoughtSignature":"` + signature + `"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"same-direct-prefix","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"hidden","thought":true}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature { -+ t.Fatalf("thought signature = %q, want %q; body=%s", got, signature, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayDirectToolSignatureWinsOverPrefix(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ prefixSig = "unbound-tool-prefix-signature-123456" -+ directSig = "direct-tool-signature-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:direct-tool-over-prefix"} -+ request1 := []byte(`{"sessionId":"direct-tool-over-prefix","request":{"contents":[{"role":"user","parts":[{"text":"run"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + prefixSig + `"},{"functionCall":{"id":"call-1","name":"run","args":{}},"thoughtSignature":"` + directSig + `"},{"text":"after"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"direct-tool-over-prefix","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}},{"text":"after"}]},{"role":"user","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != directSig { -+ t.Fatalf("tool signature = %q, want %q; body=%s", got, directSig, out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "" { -+ t.Fatalf("prefix signature retargeted to later text: %q; body=%s", got, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayAttachesDetachedSignatureToFunctionCall(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const signature = "detached-function-signature-123456789" -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:detached-function"} -+ request1 := []byte(`{"sessionId":"detached-function","request":{"contents":[{"role":"user","parts":[{"text":"run"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call-1","name":"run","args":{"n":1}}},{"text":"","thoughtSignature":"` + signature + `"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"detached-function","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{"n":1}}}]},{"role":"function","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature { -+ t.Fatalf("function signature = %q, want %q; body=%s", got, signature, out) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRestoresParallelOmittedCalls(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ model = "gemini-3.6-flash-high" -+ sessionKey = "session:parallel-omitted-calls" -+ ) -+ full := []byte(`{"sessionId":"parallel-omitted-calls","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"model","parts":[{"functionCall":{"id":"id1","name":"run","args":{"n":1}},"thoughtSignature":"parallel-call-signature-one-123456"},{"functionCall":{"id":"id2","name":"run","args":{"n":2}},"thoughtSignature":"parallel-call-signature-two-123456"}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}},{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]},{"role":"user","parts":[{"text":"finish"}]}]}}`) -+ items := antigravityReasoningReplayItemsFromRequest(full) -+ if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, items) { -+ t.Fatal("cache write failed") -+ } -+ -+ rebuilt := []byte(`{"sessionId":"parallel-omitted-calls","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}},{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]},{"role":"user","parts":[{"text":"finish"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, rebuilt) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(out); errValidate != nil { -+ t.Fatalf("parallel replay is invalid: %v; body=%s", errValidate, out) -+ } -+ calls := gjson.GetBytes(out, "request.contents.1.parts").Array() -+ if len(calls) != 2 || calls[0].Get("functionCall.id").String() != "id1" || calls[1].Get("functionCall.id").String() != "id2" { -+ t.Fatalf("parallel calls were not restored together: %s", out) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayReordersResponsesAfterRestoringParallelCalls(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ model = "gemini-3.6-flash-high" -+ sessionKey = "session:parallel-reversed-responses" -+ ) -+ full := []byte(`{"sessionId":"parallel-reversed-responses","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"model","parts":[{"functionCall":{"id":"id1","name":"run","args":{"n":1}},"thoughtSignature":"parallel-call-signature-one-123456"},{"functionCall":{"id":"id2","name":"run","args":{"n":2}}}]},{"role":"model","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}},{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]}]}}`) -+ if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, antigravityReasoningReplayItemsFromRequest(full)) { -+ t.Fatal("cache write failed") -+ } -+ -+ rebuilt := []byte(`{"sessionId":"parallel-reversed-responses","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"user","parts":[{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}},{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, rebuilt) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(out); errValidate != nil { -+ t.Fatalf("restored reverse responses are invalid: %v; body=%s", errValidate, out) -+ } -+ responses := gjson.GetBytes(out, "request.contents.2.parts").Array() -+ if len(responses) != 2 || responses[0].Get("functionResponse.id").String() != "id1" || responses[1].Get("functionResponse.id").String() != "id2" { -+ t.Fatalf("restored responses were not reordered: %s", out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.2.role").String(); got != "model" { -+ t.Fatalf("restored response role = %q, want model; body=%s", got, out) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRestoresSyntheticParallelCallsWithFirstBypassOnly(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ model = "gemini-3.6-flash-high" -+ sessionKey = "session:parallel-omitted-synthetic-calls" -+ ) -+ full := []byte(`{"sessionId":"parallel-omitted-synthetic-calls","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"model","parts":[{"functionCall":{"id":"id1","name":"run","args":{"n":1}},"thoughtSignature":"skip_thought_signature_validator"},{"functionCall":{"id":"id2","name":"run","args":{"n":2}}}]},{"role":"model","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}},{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]}]}}`) -+ items := antigravityReasoningReplayItemsFromRequest(full) -+ if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, items) { -+ t.Fatal("cache write failed") -+ } -+ -+ rebuilt := []byte(`{"sessionId":"parallel-omitted-synthetic-calls","request":{"contents":[{"role":"user","parts":[{"text":"run both"}]},{"role":"user","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}},{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]}]}}`) -+ rebuilt = sanitizeAntigravityGeminiRequestSignatures(model, rebuilt) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, rebuilt) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ calls := gjson.GetBytes(out, "request.contents.1.parts").Array() -+ if len(calls) != 2 { -+ t.Fatalf("parallel synthetic calls = %d, want 2; body=%s", len(calls), out) -+ } -+ if got := calls[0].Get("thoughtSignature").String(); got != "skip_thought_signature_validator" { -+ t.Fatalf("first synthetic call signature = %q, want bypass; body=%s", got, out) -+ } -+ if signature := calls[1].Get("thoughtSignature"); signature.Exists() { -+ t.Fatalf("second synthetic parallel call must remain unsigned; body=%s", out) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRestoresSequentialOmittedCalls(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ model = "gemini-3.6-flash-high" -+ sessionKey = "session:sequential-omitted-calls" -+ ) -+ full := []byte(`{"sessionId":"sequential-omitted-calls","request":{"contents":[{"role":"user","parts":[{"text":"run1"}]},{"role":"model","parts":[{"functionCall":{"id":"id1","name":"run","args":{"n":1}},"thoughtSignature":"omitted-call-signature-one-123456"}]},{"role":"function","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}}]},{"role":"user","parts":[{"text":"run2"}]},{"role":"model","parts":[{"functionCall":{"id":"id2","name":"run","args":{"n":2}},"thoughtSignature":"omitted-call-signature-two-123456"}]},{"role":"function","parts":[{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]}]}}`) -+ items := antigravityReasoningReplayItemsFromRequest(full) -+ if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, items) { -+ t.Fatal("cache write failed") -+ } -+ -+ rebuilt := []byte(`{"sessionId":"sequential-omitted-calls","request":{"contents":[{"role":"user","parts":[{"text":"run1"}]},{"role":"function","parts":[{"functionResponse":{"id":"id1","name":"run","response":{"result":"one"}}}]},{"role":"user","parts":[{"text":"run2"}]},{"role":"function","parts":[{"functionResponse":{"id":"id2","name":"run","response":{"result":"two"}}}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, rebuilt) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ var calls []string -+ gjson.GetBytes(out, "request.contents").ForEach(func(_, content gjson.Result) bool { -+ content.Get("parts").ForEach(func(_, part gjson.Result) bool { -+ if callID := part.Get("functionCall.id").String(); callID != "" { -+ calls = append(calls, callID) -+ } -+ return true -+ }) -+ return true -+ }) -+ if got := strings.Join(calls, ","); got != "id1,id2" { -+ t.Fatalf("restored calls = %q, want id1,id2; body=%s", got, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayAccumulatesCompleteToolSignatureChain(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ model = "gemini-3.6-flash-high" -+ sessionKey = "session:tool-chain" -+ sig1 = "native-tool-signature-one-123456" -+ sig2 = "native-tool-signature-two-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: model, sessionKey: sessionKey} -+ request1 := []byte(`{"sessionId":"tool-chain","request":{"contents":[{"role":"user","parts":[{"text":"run first"}]}]}}`) -+ acc1 := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc1.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + sig1 + `","functionCall":{"id":"call-1","name":"run_command","args":{"command":"one"}}}]},"finishReason":"STOP"}]}}`)) -+ acc1.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"tool-chain","request":{"contents":[{"role":"user","parts":[{"text":"run first"}]},{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"one"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"call-1","name":"run_command","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"run second"}]}]}}`) -+ request2 = normalizeAntigravityGeminiFunctionResponseRoles(request2) -+ prepared2, _, errPrepare2 := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare2 != nil { -+ t.Fatal(errPrepare2) -+ } -+ if got := gjson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("turn 2 tool signature = %q, want %q; body=%s", got, sig1, prepared2) -+ } -+ -+ acc2 := newAntigravityReasoningReplayAccumulator(scope, prepared2) -+ acc2.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + sig2 + `","functionCall":{"id":"call-2","name":"view_file","args":{"path":"two"}}}]},"finishReason":"STOP"}]}}`)) -+ acc2.Commit(context.Background()) -+ -+ request3 := []byte(`{"sessionId":"tool-chain","request":{"contents":[{"role":"user","parts":[{"text":"run first"}]},{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"one"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"call-1","name":"run_command","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"run second"}]},{"role":"model","parts":[{"functionCall":{"id":"call-2","name":"view_file","args":{"path":"two"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"call-2","name":"view_file","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"finish"}]}]}}`) -+ request3 = normalizeAntigravityGeminiFunctionResponseRoles(request3) -+ prepared3, _, errPrepare3 := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request3) -+ if errPrepare3 != nil { -+ t.Fatal(errPrepare3) -+ } -+ if got := gjson.GetBytes(prepared3, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("turn 3 first tool signature = %q, want %q; body=%s", got, sig1, prepared3) -+ } -+ if got := gjson.GetBytes(prepared3, "request.contents.4.parts.0.thoughtSignature").String(); got != sig2 { -+ t.Fatalf("turn 3 second tool signature = %q, want %q; body=%s", got, sig2, prepared3) -+ } -+} -+ -+func TestAntigravityReasoningReplayDirectSignatureClosesSegmentBeforeUnsignedText(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const signature = "direct-text-signature-closes-segment-123456" -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:direct-signed-unsigned"} -+ request1 := []byte(`{"sessionId":"direct-signed-unsigned","request":{"contents":[{"role":"user","parts":[{"text":"start"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"signed","thoughtSignature":"` + signature + `"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"unsigned"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"direct-signed-unsigned","request":{"contents":[{"role":"user","parts":[{"text":"start"}]},{"role":"model","parts":[{"text":"signed"},{"text":"unsigned"}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ parts := gjson.GetBytes(out, "request.contents.1.parts").Array() -+ if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != signature || parts[1].Get("thoughtSignature").String() != "" { -+ t.Fatalf("direct signature crossed into unsigned text: %s", out) -+ } -+} -+ -+func TestAntigravityReasoningReplayKeepsMixedTextToolTextFingerprintsSeparate(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ textSig1 = "mixed-text-signature-one-123456" -+ toolSig = "mixed-tool-signature-123456789" -+ textSig2 = "mixed-text-signature-two-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:mixed-segments"} -+ request1 := []byte(`{"sessionId":"mixed-segments","request":{"contents":[{"role":"user","parts":[{"text":"mixed"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"sa"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"me","thoughtSignature":"` + textSig1 + `"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"true"}},"thoughtSignature":"` + toolSig + `"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"sa"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"me","thoughtSignature":"` + textSig2 + `"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey) -+ if !ok || len(items) != 3 { -+ t.Fatalf("cached mixed items = %d ok=%v, want 3", len(items), ok) -+ } -+ if occurrence := gjson.GetBytes(items[0], "targetOccurrence"); !occurrence.Exists() || occurrence.Int() != 0 { -+ t.Fatalf("first text targetOccurrence = %s, want 0; item=%s", occurrence.Raw, items[0]) -+ } -+ if got := gjson.GetBytes(items[2], "targetOccurrence").Int(); got != 1 { -+ t.Fatalf("second text targetOccurrence = %d, want 1; item=%s", got, items[2]) -+ } -+ -+ request2 := []byte(`{"sessionId":"mixed-segments","request":{"contents":[{"role":"user","parts":[{"text":"mixed"}]},{"role":"model","parts":[{"text":"same"},{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"true"}}},{"text":"same"}]},{"role":"function","parts":[{"functionResponse":{"id":"call-1","name":"run_command","response":{"result":"ok"}}}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ for partIndex, want := range []string{textSig1, toolSig, textSig2} { -+ if got := gjson.GetBytes(out, fmt.Sprintf("request.contents.1.parts.%d.thoughtSignature", partIndex)).String(); got != want { -+ t.Fatalf("part %d signature = %q, want %q; body=%s", partIndex, got, want, out) -+ } -+ } -+} -+ -+func TestAntigravityReasoningReplayAccumulatorCountsExistingSegmentOccurrences(t *testing.T) { -+ request := []byte(`{"request":{"contents":[{"role":"model","parts":[{"text":"same"},{"text":"same","thought":true}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator( -+ antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:existing-segments"}, -+ request, -+ ) -+ acc.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"same","thoughtSignature":"text-signature-123456789"},{"text":"same","thought":true,"thoughtSignature":"thought-signature-123456789"}]},"finishReason":"STOP"}]}}`)) -+ acc.appendPendingThoughtSignatures() -+ if len(acc.items) != 2 { -+ t.Fatalf("captured items = %d, want 2: %q", len(acc.items), acc.items) -+ } -+ for itemIndex, wantKind := range []string{"text", "thought"} { -+ item := gjson.ParseBytes(acc.items[itemIndex]) -+ if item.Get("targetKind").String() != wantKind || item.Get("targetOccurrence").Int() != 1 { -+ t.Fatalf("item %d kind/occurrence = %q/%d, want %q/1: %s", itemIndex, item.Get("targetKind").String(), item.Get("targetOccurrence").Int(), wantKind, item.Raw) -+ } -+ } -+} -+ -+func TestAntigravityReasoningReplayAccumulatorCountsExistingFunctionOccurrenceThroughReplay(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const signature = "second-function-signature-123456789" -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:existing-function"} -+ request := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"run","args":{"value":"same"}}}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request) -+ acc.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"functionCall":{"name":"run","args":{"value":"same"}},"thoughtSignature":"` + signature + `"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey) -+ if !ok || len(items) != 2 || gjson.GetBytes(items[1], "targetOccurrence").Int() != 1 { -+ t.Fatalf("function occurrences were not committed: ok=%v items=%q", ok, items) -+ } -+ replayPayload := []byte(`{"sessionId":"existing-function","request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"run","args":{"value":"same"}}},{"functionCall":{"name":"run","args":{"value":"same"}}}]}]}}`) -+ prepared, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, replayPayload) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ parts := gjson.GetBytes(prepared, "request.contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != "" || parts[1].Get("thoughtSignature").String() != signature { -+ t.Fatalf("function occurrence replay targeted the wrong call: %s", prepared) -+ } -+} -+ -+func TestAntigravityReasoningReplayCapturesSignatureBeforeThoughtText(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const signature = "thought-first-signature-123456789" -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:thought-first"} -+ request1 := []byte(`{"sessionId":"thought-first","request":{"contents":[{"role":"user","parts":[{"text":"think"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thought":true,"thoughtSignature":"` + signature + `"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"hidden thought","thought":true}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"thought-first","request":{"contents":[{"role":"user","parts":[{"text":"think"}]},{"role":"model","parts":[{"text":"hidden thought","thought":true}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature { -+ t.Fatalf("thought-first signature = %q, want %q; body=%s", got, signature, out) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayReplacesIDLessFunctionCallBypass(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"name":"run_command","args":{"command":"same"},"thoughtSignature":"idless-native-signature-123456"}`) -+ internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:idless", [][]byte{item}) -+ payload := []byte(`{"sessionId":"idless","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"same"}},"thoughtSignature":"skip_thought_signature_validator"}]},{"role":"function","parts":[{"functionResponse":{"name":"run_command","response":{"result":"ok"}}}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "idless-native-signature-123456" { -+ t.Fatalf("id-less function signature = %q, want native replay; body=%s", got, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayContextFingerprintCanonicalizesJSON(t *testing.T) { -+ payload1 := []byte(`{"request":{"tools":[{"functionDeclarations":[{"name":"run","parameters":{"type":"object","properties":{"a":{"type":"string"},"b":{"type":"number"}}}}]}],"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"functionCall":{"name":"run","args":{"a":"x","b":2}}}]}]}}`) -+ payload2 := []byte(`{"request":{"tools":[{"functionDeclarations":[{"parameters":{"properties":{"b":{"type":"number"},"a":{"type":"string"}},"type":"object"},"name":"run"}]}],"contents":[{"parts":[{"text":"turn"}],"role":"user"},{"parts":[{"functionCall":{"args":{"b":2,"a":"x"},"name":"run"}}],"role":"model"}]}}`) -+ if got1, got2 := antigravityReplayContextFingerprint(payload1, 2), antigravityReplayContextFingerprint(payload2, 2); got1 == "" || got1 != got2 { -+ t.Fatalf("canonical context hashes differ: %q vs %q", got1, got2) -+ } -+ key1 := antigravityFunctionCallKey("run", `{"a":"x","b":2}`, "") -+ key2 := antigravityFunctionCallKey("run", `{"b":2,"a":"x"}`, "") -+ if key1 == "" || key1 != key2 { -+ t.Fatalf("canonical function keys differ: %q vs %q", key1, key2) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayMatchesRewrittenToolCallID(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"call_id":"native-call-id","name":"run_command","args":{"command":"same"},"thoughtSignature":"rewritten-id-signature-123456"}`) -+ internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:rewritten-id", [][]byte{item}) -+ payload := []byte(`{"sessionId":"rewritten-id","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"id":"claude-generated-id","name":"run_command","args":{"command":"same"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"claude-generated-id","name":"run_command","response":{"result":"ok"}}}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "rewritten-id-signature-123456" { -+ t.Fatalf("rewritten-ID function signature = %q, want native replay; body=%s", got, out) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRejectsReusedIDWithChangedCall(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"call_id":"reused-id","name":"run_command","args":{"command":"old"},"thoughtSignature":"reused-id-stale-signature-123456"}`) -+ internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:reused-id", [][]byte{item}) -+ payload := []byte(`{"sessionId":"reused-id","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"id":"reused-id","name":"run_command","args":{"command":"new"}}},{"functionCall":{"id":"other-id","name":"run_command","args":{"command":"old"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"reused-id","name":"run_command","response":{"result":"ok"}}},{"functionResponse":{"id":"other-id","name":"run_command","response":{"result":"ok"}}}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" { -+ t.Fatalf("changed call with reused ID received stale signature %q; body=%s", got, out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "" { -+ t.Fatalf("reused-ID signature fell through to another semantic match %q; body=%s", got, out) -+ } -+ callCount := 0 -+ gjson.GetBytes(out, "request.contents").ForEach(func(_, content gjson.Result) bool { -+ content.Get("parts").ForEach(func(_, part gjson.Result) bool { -+ if part.Get("functionCall.id").String() == "reused-id" { -+ callCount++ -+ } -+ return true -+ }) -+ return true -+ }) -+ if callCount != 1 { -+ t.Fatalf("changed call with reused ID was duplicated: count=%d body=%s", callCount, out) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRejectsChangedIDLessCallAtSamePosition(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"name":"run_command","args":{"command":"old"},"thoughtSignature":"idless-stale-signature-123456"}`) -+ internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:idless-changed", [][]byte{item}) -+ payload := []byte(`{"sessionId":"idless-changed","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"new"}}}]},{"role":"function","parts":[{"functionResponse":{"name":"run_command","response":{"result":"ok"}}}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" { -+ t.Fatalf("changed ID-less call received stale signature %q; body=%s", got, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayPreservesRepeatedIDLessCalls(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ sig1 = "repeated-idless-signature-one-123456" -+ sig2 = "repeated-idless-signature-two-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:repeated-idless"} -+ request1 := []byte(`{"sessionId":"repeated-idless","request":{"contents":[{"role":"user","parts":[{"text":"run twice"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + sig1 + `","functionCall":{"name":"run_command","args":{"command":"same"}}},{"thoughtSignature":"` + sig2 + `","functionCall":{"name":"run_command","args":{"command":"same"}}}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"repeated-idless","request":{"contents":[{"role":"user","parts":[{"text":"run twice"}]},{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"same"}}},{"functionCall":{"name":"run_command","args":{"command":"same"}}}]},{"role":"model","parts":[{"functionResponse":{"name":"run_command","response":{"result":"one"}}},{"functionResponse":{"name":"run_command","response":{"result":"two"}}}]},{"role":"user","parts":[{"text":"continue"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("first repeated signature = %q, want %q; body=%s", got, sig1, out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 { -+ t.Fatalf("second repeated signature = %q, want %q; body=%s", got, sig2, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayPreservesRepeatedIDLessCallsAcrossSplitSSEPartDrift(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ sig1 = "split-idless-signature-one-123456" -+ sig2 = "split-idless-signature-two-123456" -+ ) -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:split-repeated-idless"} -+ request1 := []byte(`{"sessionId":"split-repeated-idless","request":{"contents":[{"role":"user","parts":[{"text":"run twice"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"hidden","thought":true}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + sig1 + `","functionCall":{"name":"run_command","args":{"command":"same"}}}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + sig2 + `","functionCall":{"name":"run_command","args":{"command":"same"}}}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey) -+ if !ok || len(items) != 2 { -+ t.Fatalf("cached items = %d ok=%v, want 2", len(items), ok) -+ } -+ for index, item := range items { -+ if occurrence := gjson.GetBytes(item, "targetOccurrence"); !occurrence.Exists() || occurrence.Int() != int64(index) { -+ t.Fatalf("item %d occurrence = %s, want %d; item=%s", index, occurrence.Raw, index, item) -+ } -+ } -+ -+ request2 := []byte(`{"sessionId":"split-repeated-idless","request":{"contents":[{"role":"user","parts":[{"text":"run twice"}]},{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"same"}}},{"functionCall":{"name":"run_command","args":{"command":"same"}}}]},{"role":"model","parts":[{"functionResponse":{"name":"run_command","response":{"result":"one"}}},{"functionResponse":{"name":"run_command","response":{"result":"two"}}}]},{"role":"user","parts":[{"text":"continue"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("first split repeated signature = %q, want %q; body=%s", got, sig1, out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != sig2 { -+ t.Fatalf("second split repeated signature = %q, want %q; body=%s", got, sig2, out) -+ } -+ -+ rebuiltItems := antigravityReasoningReplayItemsFromRequest(out) -+ if len(rebuiltItems) != 2 || gjson.GetBytes(rebuiltItems[0], "targetOccurrence").Int() != 0 || gjson.GetBytes(rebuiltItems[1], "targetOccurrence").Int() != 1 { -+ t.Fatalf("rebuilt occurrences were not preserved: %q", rebuiltItems) -+ } -+} -+ -+func TestAntigravityReasoningReplayLegacyAmbiguousIDLessCallFailsClosed(t *testing.T) { -+ item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":1,"name":"run_command","args":{"command":"same"},"thoughtSignature":"legacy-ambiguous-signature-123456"}`) -+ payload := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"same"}}},{"functionCall":{"name":"run_command","args":{"command":"same"}}}]}]}}`) -+ out, changed := insertAntigravityReasoningReplayItems(payload, [][]byte{item}) -+ if changed || strings.Contains(string(out), "legacy-ambiguous-signature") { -+ t.Fatalf("legacy ambiguous ID-less replay must fail closed: changed=%v body=%s", changed, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayAssociatesSignatureBeforeFunctionCall(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const signature = "signature-before-function-call-123456" -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:signature-first-tool"} -+ request1 := []byte(`{"sessionId":"signature-first-tool","request":{"contents":[{"role":"user","parts":[{"text":"run"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request1) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + signature + `"}]}}]}}`)) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"one"}}}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ -+ request2 := []byte(`{"sessionId":"signature-first-tool","request":{"contents":[{"role":"user","parts":[{"text":"run"}]},{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run_command","args":{"command":"one"}}}]},{"role":"function","parts":[{"functionResponse":{"id":"call-1","name":"run_command","response":{"result":"ok"}}}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), scope.modelName, cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, request2) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != signature { -+ t.Fatalf("signature-first tool signature = %q, want %q; body=%s", got, signature, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayTerminalEmptyChainClearsCache(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:empty-reset"} -+ old := []byte(`{"type":"thought_signature","contentIndex":1,"partIndex":0,"thoughtSignature":"old-signature-123456789"}`) -+ internalcache.CacheAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey, [][]byte{old}) -+ -+ request := []byte(`{"sessionId":"empty-reset","request":{"contents":[{"role":"user","parts":[{"text":"new conversation"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer without signature"}]},"finishReason":"STOP"}]}}`)) -+ acc.Commit(context.Background()) -+ if items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey); ok || len(items) != 0 { -+ t.Fatalf("empty terminal chain did not clear old cache: %d ok=%v", len(items), ok) -+ } -+} -+ -+func TestAntigravityReasoningReplayDoesNotCommitPartialResponse(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ scope := antigravityReasoningReplayScope{modelName: "gemini-3.6-flash-high", sessionKey: "session:partial"} -+ request := []byte(`{"sessionId":"partial","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]}]}}`) -+ acc := newAntigravityReasoningReplayAccumulator(scope, request) -+ acc.ObserveSSELine([]byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"partial"},{"text":"","thoughtSignature":"partial-signature-123456789"}]}}]}}`)) -+ acc.Commit(context.Background()) -+ -+ if items, ok := internalcache.GetAntigravityReasoningReplayItems(scope.modelName, scope.sessionKey); ok || len(items) != 0 { -+ t.Fatalf("partial response published replay items: %d ok=%v", len(items), ok) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRejectsFingerprintMismatch(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"same answer"}`)) -+ item := buildAntigravityThoughtSignatureItem(1, 0, "fingerprinted-signature-123456", kind, fingerprint) -+ originalPayload := []byte(`{"sessionId":"rebuilt","request":{"contents":[{"role":"user","parts":[{"text":"old context"}]},{"role":"model","parts":[{"text":"same answer"}]},{"role":"user","parts":[{"text":"old next"}]}]}}`) -+ item = antigravitySetReplayItemContextHash(item, originalPayload, 1) -+ internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:rebuilt", [][]byte{item}) -+ -+ payload := []byte(`{"sessionId":"rebuilt","request":{"contents":[{"role":"user","parts":[{"text":"new context"}]},{"role":"model","parts":[{"text":"same answer"}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" { -+ t.Fatalf("mismatched rebuilt context received stale signature %q; body=%s", got, out) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayMovesClientSignatureToNativePart(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ kind, fingerprint := antigravityReplayPartFingerprint(gjson.Parse(`{"text":"visible answer"}`)) -+ item := buildAntigravityThoughtSignatureItem(1, 1, "client-carried-signature-123456", kind, fingerprint) -+ internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:client-carried", [][]byte{item}) -+ payload := []byte(`{"sessionId":"client-carried","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"hidden","thought":true,"thoughtSignature":"client-carried-signature-123456"},{"text":"visible answer"}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "" { -+ t.Fatalf("client-carried signature remained on non-native thought part: %q; body=%s", got, out) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.1.thoughtSignature").String(); got != "client-carried-signature-123456" { -+ t.Fatalf("client-carried signature = %q on visible part, want native placement; body=%s", got, out) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayReplacesBypassSignature(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ item := []byte(`{"type":"thought_signature","contentIndex":1,"partIndex":0,"thoughtSignature":"native-real-signature-123456"}`) -+ internalcache.CacheAntigravityReasoningReplayItems("gemini-3.6-flash-high", "session:bypass", [][]byte{item}) -+ payload := []byte(`{"sessionId":"bypass","request":{"contents":[{"role":"user","parts":[{"text":"turn"}]},{"role":"model","parts":[{"text":"answer","thoughtSignature":"skip_thought_signature_validator"}]},{"role":"user","parts":[{"text":"next"}]}]}}`) -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{}, payload) -+ if errPrepare != nil { -+ t.Fatal(errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String(); got != "native-real-signature-123456" { -+ t.Fatalf("signature = %q, want native replay; body=%s", got, out) -+ } -+} -+ -+func TestAntigravityReasoningReplayScopePrefersExecutionSession(t *testing.T) { -+ req := cliproxyexecutor.Request{Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "client-session"}} -+ payload := []byte(`{"sessionId":"provider-session","request":{"contents":[{"role":"user","parts":[{"text":"same prompt"}]}]}}`) -+ scope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", req, cliproxyexecutor.Options{}, payload) -+ if got := scope.sessionKey; got != "execution:client-session" { -+ t.Fatalf("session key = %q, want downstream execution session", got) -+ } -+} -+ -+func TestAntigravityReasoningReplayScopePrefersStableSessionOverExecutionUUID(t *testing.T) { -+ opts := cliproxyexecutor.Options{ -+ Headers: http.Header{"Session-Id": []string{"stable-session"}}, -+ Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "socket-uuid"}, -+ } -+ scope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, opts, nil) -+ if got := scope.sessionKey; got != "responses:stable-session" { -+ t.Fatalf("session key = %q, want stable Responses session", got) -+ } -+} -+ -+func TestAntigravityReasoningReplayScopeKeepsExecutionAheadOfPromptCacheKey(t *testing.T) { -+ opts := cliproxyexecutor.Options{ -+ OriginalRequest: []byte(`{"prompt_cache_key":"shared-cache-bucket"}`), -+ Metadata: map[string]any{cliproxyexecutor.ExecutionSessionMetadataKey: "socket-session"}, -+ } -+ scope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, opts, nil) -+ if got := scope.sessionKey; got != "execution:socket-session" { -+ t.Fatalf("session key = %q, want execution scope ahead of prompt cache key", got) -+ } -+} -+ -+func TestAntigravityReasoningReplayScopeSeparatesPromptCacheAndExplicitSessionNamespaces(t *testing.T) { -+ promptScope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{OriginalRequest: []byte(`{"prompt_cache_key":"same-value"}`)}, nil) -+ sessionScope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, cliproxyexecutor.Options{OriginalRequest: []byte(`{"session_id":"same-value"}`)}, nil) -+ if promptScope.sessionKey != "prompt-cache:same-value" || sessionScope.sessionKey != "responses:same-value" || promptScope.sessionKey == sessionScope.sessionKey { -+ t.Fatalf("prompt/session namespaces collided: %q vs %q", promptScope.sessionKey, sessionScope.sessionKey) -+ } -+} -+ -+func TestAntigravityReasoningReplayScopeUsesClaudeMetadataSession(t *testing.T) { -+ opts := cliproxyexecutor.Options{OriginalRequest: []byte(`{"metadata":{"user_id":"{\"session_id\":\"claude-session\",\"device_id\":\"device\"}"}}`)} -+ payload := []byte(`{"sessionId":"generated-from-prompt","request":{"contents":[{"role":"user","parts":[{"text":"same prompt"}]}]}}`) -+ scope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, opts, payload) -+ if got := scope.sessionKey; got != "claude:claude-session:agent:main" { -+ t.Fatalf("session key = %q, want Claude root-agent session", got) -+ } -+} -+ -+func TestAntigravityReasoningReplaySeparatesClaudeSessionTitleFromResumedTranscript(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const ( -+ model = "gemini-3.6-flash-high" -+ sig1 = "claude-resume-signature-one-123456" -+ sig2 = "claude-resume-signature-two-123456" -+ ) -+ headers := http.Header{"X-Claude-Code-Session-Id": []string{"claude-resume-session"}} -+ mainOriginal1 := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"root prompt"}]}],"system":[{"type":"text","text":"You are Claude Code."}],"thinking":{"type":"enabled"},"tools":[{"name":"Read"}]}`) -+ mainRequest1 := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"root prompt"}]}]}}`) -+ mainScope := antigravityReasoningReplayScopeFromRequest(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{Headers: headers, OriginalRequest: mainOriginal1}, mainRequest1) -+ mainAccumulator1 := newAntigravityReasoningReplayAccumulator(mainScope, mainRequest1) -+ mainAccumulator1.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"answer one"},{"text":"","thoughtSignature":"` + sig1 + `"}]},"finishReason":"STOP"}]}}`)) -+ mainAccumulator1.Commit(context.Background()) -+ -+ // Prepare the next main turn before the auxiliary request commits. This -+ // reproduces Claude Code's concurrent title/main request ordering. -+ mainOriginal2 := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"root prompt"}]},{"role":"assistant","content":[{"type":"text","text":"answer one"}]},{"role":"user","content":[{"type":"text","text":"next prompt"}]}],"system":[{"type":"text","text":"You are Claude Code."}],"thinking":{"type":"enabled"},"tools":[{"name":"Read"}]}`) -+ mainRequest2 := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"root prompt"}]},{"role":"model","parts":[{"text":"answer one"}]},{"role":"user","parts":[{"text":"next prompt"}]}]}}`) -+ prepared2, scope2, errPrepare2 := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{Headers: headers, OriginalRequest: mainOriginal2}, mainRequest2) -+ if errPrepare2 != nil { -+ t.Fatal(errPrepare2) -+ } -+ if scope2.sessionKey != mainScope.sessionKey { -+ t.Fatalf("main replay scope changed: first=%q second=%q", mainScope.sessionKey, scope2.sessionKey) -+ } -+ -+ titleOriginal := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"root prompt"}]}],"system":[{"type":"text","text":"Generate a concise title that summarizes this session."}],"tools":[]}`) -+ titleRequest := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"root prompt"}]}]}}`) -+ preparedTitle, titleScope, errPrepareTitle := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{Headers: headers, OriginalRequest: titleOriginal}, titleRequest) -+ if errPrepareTitle != nil { -+ t.Fatal(errPrepareTitle) -+ } -+ if titleScope.sessionKey == mainScope.sessionKey || !strings.Contains(mainScope.sessionKey, ":context:") || !strings.Contains(titleScope.sessionKey, ":context:") { -+ t.Fatalf("Claude title/main replay scopes collided: main=%q title=%q", mainScope.sessionKey, titleScope.sessionKey) -+ } -+ titleAccumulator := newAntigravityReasoningReplayAccumulator(titleScope, preparedTitle) -+ titleAccumulator.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"session title"},{"text":"","thoughtSignature":"claude-title-signature-123456"}]},"finishReason":"STOP"}]}}`)) -+ titleAccumulator.Commit(context.Background()) -+ -+ if got := gjson.GetBytes(prepared2, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("first main signature after title request = %q, want %q; body=%s", got, sig1, prepared2) -+ } -+ mainAccumulator2 := newAntigravityReasoningReplayAccumulator(scope2, prepared2) -+ mainAccumulator2.observeResponsePayload([]byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"answer two"},{"text":"","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}]}}`)) -+ mainAccumulator2.Commit(context.Background()) -+ -+ resumeOriginal := []byte(`{"messages":[{"role":"user","content":[{"type":"text","text":"root prompt"}]},{"role":"assistant","content":[{"type":"text","text":"answer one"}]},{"role":"user","content":[{"type":"text","text":"next prompt"}]},{"role":"assistant","content":[{"type":"text","text":"answer two"}]},{"role":"user","content":[{"type":"text","text":"resumed prompt"}]}],"system":[{"type":"text","text":"You are Claude Code.","cache_control":{"type":"ephemeral"}}],"thinking":{"type":"enabled"},"tools":[{"name":"Read"}]}`) -+ resumeRequest := []byte(`{"request":{"contents":[{"role":"user","parts":[{"text":"root prompt"}]},{"role":"model","parts":[{"text":"answer one"}]},{"role":"user","parts":[{"text":"next prompt"}]},{"role":"model","parts":[{"text":"answer two"}]},{"role":"user","parts":[{"text":"resumed prompt"}]}]}}`) -+ resumed, resumeScope, errResume := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{}, cliproxyexecutor.Options{Headers: headers, OriginalRequest: resumeOriginal}, resumeRequest) -+ if errResume != nil { -+ t.Fatal(errResume) -+ } -+ if resumeScope.sessionKey != mainScope.sessionKey { -+ t.Fatalf("resumed replay scope = %q, want %q", resumeScope.sessionKey, mainScope.sessionKey) -+ } -+ if got := gjson.GetBytes(resumed, "request.contents.1.parts.0.thoughtSignature").String(); got != sig1 { -+ t.Fatalf("resumed first signature = %q, want %q; body=%s", got, sig1, resumed) -+ } -+ if got := gjson.GetBytes(resumed, "request.contents.3.parts.0.thoughtSignature").String(); got != sig2 { -+ t.Fatalf("resumed second signature = %q, want %q; body=%s", got, sig2, resumed) -+ } -+} -+ -+func TestAntigravityReasoningReplayScopeSeparatesClaudeAgents(t *testing.T) { -+ opts := cliproxyexecutor.Options{Headers: http.Header{ -+ "X-Claude-Code-Session-Id": []string{"claude-session"}, -+ "X-Claude-Code-Agent-Id": []string{"subagent-1"}, -+ }} -+ scope := antigravityReasoningReplayScopeFromRequest(context.Background(), "gemini-3.6-flash-high", cliproxyexecutor.Request{}, opts, nil) -+ if got := scope.sessionKey; got != "claude:claude-session:agent:subagent-1" { -+ t.Fatalf("session key = %q, want agent-scoped Claude session", got) - } - } - -@@ -174,3 +1225,377 @@ func TestAntigravityRequestHasMatchingFunctionResponseWhitespaceCallID(t *testin - t.Fatal("whitespace-only call_id should be treated as empty => true") - } - } -+ -+func TestPrepareAntigravityGeminiReasoningReplayRepairsSequentialCompactedUnknownResponseName(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ model := "gemini-3.6-flash-high" -+ sessionKey := antigravityReasoningReplayScopeFromPayload(model, []byte(`{"sessionId":"sess-seq-compact"}`)).sessionKey -+ -+ item := []byte(`{ -+ "type": "function_call_part", -+ "contentIndex": 1, -+ "partIndex": 0, -+ "targetOccurrence": 0, -+ "name": "Read", -+ "call_id": "call_seq_1", -+ "args": {"path": "/tmp/a"}, -+ "thoughtSignature": "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg" -+ }`) -+ if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) { -+ t.Fatal("failed to cache replay item") -+ } -+ -+ // Payload simulates Responses compaction where assistant function_call was dropped, -+ // and translator generated functionResponse with placeholder name "unknown". -+ compactedPayload := []byte(`{ -+ "sessionId": "sess-seq-compact", -+ "request": { -+ "contents": [ -+ { -+ "role": "user", -+ "parts": [{"text": "Read file /tmp/a"}] -+ }, -+ { -+ "role": "user", -+ "parts": [ -+ { -+ "functionResponse": { -+ "id": "call_seq_1", -+ "name": "unknown", -+ "response": {"output": "hello world"} -+ } -+ } -+ ] -+ } -+ ] -+ } -+ }`) -+ -+ req := cliproxyexecutor.Request{Model: model, Payload: compactedPayload} -+ opts := cliproxyexecutor.Options{} -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, req, opts, compactedPayload) -+ if errPrepare != nil { -+ t.Fatalf("prepare failed unexpectedly: %v", errPrepare) -+ } -+ -+ // Verify restored model functionCall has name "Read" and thoughtSignature -+ fcName := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String() -+ fcID := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String() -+ fcSig := gjson.GetBytes(out, "request.contents.1.parts.0.thoughtSignature").String() -+ if fcName != "Read" || fcID != "call_seq_1" || fcSig != "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg" { -+ t.Fatalf("restored functionCall = name:%q id:%q sig:%q, want Read/call_seq_1/signature", fcName, fcID, fcSig) -+ } -+ -+ // Verify user functionResponse.name was repaired from "unknown" to "Read" -+ frName := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String() -+ frID := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String() -+ if frName != "Read" || frID != "call_seq_1" { -+ t.Fatalf("repaired functionResponse = name:%q id:%q, want Read/call_seq_1", frName, frID) -+ } -+ -+ // Verify ValidateGeminiFunctionCallPairing passes cleanly -+ if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil { -+ t.Fatalf("ValidateGeminiFunctionCallPairing failed: %v", errPairing) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRepairsParallelCompactedUnknownResponseName(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ model := "gemini-3.6-flash-high" -+ sessionKey := antigravityReasoningReplayScopeFromPayload(model, []byte(`{"sessionId":"sess-par-compact"}`)).sessionKey -+ -+ item1 := []byte(`{ -+ "type": "function_call_part", -+ "contentIndex": 1, -+ "partIndex": 0, -+ "targetOccurrence": 0, -+ "name": "Read", -+ "call_id": "call_par_1", -+ "args": {"path": "/tmp/a"}, -+ "thoughtSignature": "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg" -+ }`) -+ item2 := []byte(`{ -+ "type": "function_call_part", -+ "contentIndex": 1, -+ "partIndex": 1, -+ "targetOccurrence": 0, -+ "name": "Grep", -+ "call_id": "call_par_2", -+ "args": {"query": "foo"}, -+ "thoughtSignature": "EvQCCvECARFNMg/sZy4s+7HU2/PDOR12" -+ }`) -+ if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item1, item2}) { -+ t.Fatal("failed to cache parallel replay items") -+ } -+ -+ compactedPayload := []byte(`{ -+ "sessionId": "sess-par-compact", -+ "request": { -+ "contents": [ -+ { -+ "role": "user", -+ "parts": [{"text": "Read /tmp/a and Grep foo"}] -+ }, -+ { -+ "role": "user", -+ "parts": [ -+ { -+ "functionResponse": { -+ "id": "call_par_1", -+ "name": "unknown", -+ "response": {"output": "content a"} -+ } -+ }, -+ { -+ "functionResponse": { -+ "id": "call_par_2", -+ "name": "unknown", -+ "response": {"output": "matches b"} -+ } -+ } -+ ] -+ } -+ ] -+ } -+ }`) -+ -+ req := cliproxyexecutor.Request{Model: model, Payload: compactedPayload} -+ opts := cliproxyexecutor.Options{} -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, req, opts, compactedPayload) -+ if errPrepare != nil { -+ t.Fatalf("prepare parallel failed: %v", errPrepare) -+ } -+ -+ // Verify parallel functionCall restoration -+ fc1Name := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.name").String() -+ fc2Name := gjson.GetBytes(out, "request.contents.1.parts.1.functionCall.name").String() -+ if fc1Name != "Read" || fc2Name != "Grep" { -+ t.Fatalf("restored parallel functionCalls = %q, %q, want Read, Grep", fc1Name, fc2Name) -+ } -+ -+ // Verify parallel functionResponse.name repair -+ fr1Name := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.name").String() -+ fr2Name := gjson.GetBytes(out, "request.contents.2.parts.1.functionResponse.name").String() -+ if fr1Name != "Read" || fr2Name != "Grep" { -+ t.Fatalf("repaired parallel functionResponses = %q, %q, want Read, Grep", fr1Name, fr2Name) -+ } -+ -+ // Verify strict pairing validation succeeds -+ if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil { -+ t.Fatalf("ValidateGeminiFunctionCallPairing failed: %v", errPairing) -+ } -+} -+ -+func TestAntigravityFunctionCallMatchesReplayItemOnlyIgnoresDeclaredDefaults(t *testing.T) { -+ item := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a"}}`) -+ schemas := map[string]any{"Edit": map[string]any{"type": "object", "properties": map[string]any{"replace_all": map[string]any{"type": "boolean", "default": false}}}} -+ declaredDefault := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","replace_all":false}}`) -+ if !antigravityFunctionCallMatchesReplayItem(declaredDefault, item, schemas) { -+ t.Fatal("declared schema default should be semantically equivalent") -+ } -+ changedDefault := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","replace_all":true}}`) -+ if antigravityFunctionCallMatchesReplayItem(changedDefault, item, schemas) { -+ t.Fatal("non-default value must not match native args") -+ } -+ undeclaredExtra := gjson.Parse(`{"name":"Edit","args":{"path":"/tmp/a","force":false}}`) -+ if antigravityFunctionCallMatchesReplayItem(undeclaredExtra, item, schemas) { -+ t.Fatal("undeclared client arg must not match native args") -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRestoresClaudeToolProvenanceWithSchemaDefault(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const model = "gemini-3.6-flash-high" -+ const nativeID = "native-edit-1" -+ const signature = "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg" -+ const nativeArgs = `{"file_path":"/tmp/a","old_string":"x","new_string":"y"}` -+ clientID := util.GeminiClaudeToolUseID(nativeID, "Edit", nativeArgs) -+ payload := []byte(`{"sessionId":"sess-claude-default","request":{"contents":[{"role":"user","parts":[{"text":"edit"}]},{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID + `","name":"Edit","args":{"file_path":"/tmp/a","old_string":"x","new_string":"y","replace_all":false}}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID + `","name":"Edit","response":{"result":"ok"}}}]}]}}`) -+ item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"targetOccurrence":0,"name":"Edit","call_id":"` + nativeID + `","args":` + nativeArgs + `,"thoughtSignature":"` + signature + `"}`) -+ sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey -+ if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) { -+ t.Fatal("failed to cache native Edit provenance") -+ } -+ original := []byte(`{"model":"` + model + `","tools":[{"name":"Edit","input_schema":{"type":"object","properties":{"file_path":{"type":"string"},"old_string":{"type":"string"},"new_string":{"type":"string"},"replace_all":{"type":"boolean","default":false}}}}]}`) -+ opts := cliproxyexecutor.Options{OriginalRequest: original, SourceFormat: sdktranslator.FromString("claude")} -+ -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload) -+ if errPrepare != nil { -+ t.Fatalf("prepare failed: %v", errPrepare) -+ } -+ call := gjson.GetBytes(out, "request.contents.1.parts.0") -+ if call.Get("functionCall.id").String() != nativeID || call.Get("functionCall.name").String() != "Edit" || call.Get("thoughtSignature").String() != signature { -+ t.Fatalf("native call provenance was not restored: %s", call.Raw) -+ } -+ if call.Get("functionCall.args.replace_all").Exists() { -+ t.Fatalf("client-inserted schema default leaked into restored native call: %s", call.Raw) -+ } -+ response := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse") -+ if response.Get("id").String() != nativeID || response.Get("name").String() != "Edit" { -+ t.Fatalf("function response provenance was not restored: %s", response.Raw) -+ } -+ if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil { -+ t.Fatalf("restored history is invalid: %v", errPairing) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRestoresLegacyClaudeToolIDWithSchemaDefault(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const model = "gemini-3.6-flash-high" -+ payload := []byte(`{"sessionId":"sess-claude-legacy-default","request":{"contents":[{"role":"user","parts":[{"text":"edit"}]},{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"Edit-legacy-client-id","name":"Edit","args":{"file_path":"/tmp/a","old_string":"x","new_string":"y","replace_all":false}}}]},{"role":"user","parts":[{"functionResponse":{"id":"Edit-legacy-client-id","name":"Edit","response":{"result":"ok"}}}]}]}}`) -+ item := []byte(`{"type":"function_call_part","contentIndex":1,"partIndex":0,"targetOccurrence":0,"name":"Edit","call_id":"native-edit-legacy","args":{"file_path":"/tmp/a","old_string":"x","new_string":"y"},"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"}`) -+ sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey -+ internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) -+ original := []byte(`{"tools":[{"name":"Edit","input_schema":{"type":"object","properties":{"replace_all":{"type":"boolean","default":false}}}}]}`) -+ opts := cliproxyexecutor.Options{OriginalRequest: original, SourceFormat: sdktranslator.FromString("claude")} -+ -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload) -+ if errPrepare != nil { -+ t.Fatalf("prepare failed: %v", errPrepare) -+ } -+ if got := gjson.GetBytes(out, "request.contents.1.parts.0.functionCall.id").String(); got != "native-edit-legacy" { -+ t.Fatalf("legacy client ID was not restored: %q", got) -+ } -+ if got := gjson.GetBytes(out, "request.contents.2.parts.0.functionResponse.id").String(); got != "native-edit-legacy" { -+ t.Fatalf("legacy functionResponse ID was not restored: %q", got) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayFailsClosedWithoutClaudeToolProvenance(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const model = "gemini-3.6-flash-high" -+ clientID := util.GeminiClaudeToolUseID("native-missing", "Read", `{"file_path":"/tmp/a"}`) -+ payload := []byte(`{"sessionId":"sess-missing-provenance","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID + `","name":"Read","args":{"file_path":"/tmp/a"}}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID + `","name":"Read","response":{"result":"ok"}}}]}]}}`) -+ opts := cliproxyexecutor.Options{SourceFormat: sdktranslator.FromString("claude")} -+ -+ _, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload) -+ if errPrepare == nil || !strings.Contains(errPrepare.Error(), "missing Claude tool provenance") { -+ t.Fatalf("error = %v, want fail-closed missing provenance", errPrepare) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRestoresParallelClaudeToolProvenance(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const model = "gemini-3.6-flash-high" -+ const args1 = `{"file_path":"/tmp/a"}` -+ const args2 = `{"file_path":"/tmp/b"}` -+ clientID1 := util.GeminiClaudeToolUseID("native-read-1", "Read", args1) -+ clientID2 := util.GeminiClaudeToolUseID("native-read-2", "Read", args2) -+ payload := []byte(`{"sessionId":"sess-parallel-provenance","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID1 + `","name":"Read","args":{"file_path":"/tmp/a","offset":0}}},{"functionCall":{"id":"` + clientID2 + `","name":"Read","args":{"file_path":"/tmp/b","offset":0}}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID2 + `","name":"Read","response":{"result":"b"}}},{"functionResponse":{"id":"` + clientID1 + `","name":"Read","response":{"result":"a"}}}]}]}}`) -+ items := [][]byte{ -+ []byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"targetOccurrence":0,"name":"Read","call_id":"native-read-1","args":` + args1 + `,"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"}`), -+ []byte(`{"type":"function_call_part","contentIndex":0,"partIndex":1,"targetOccurrence":0,"name":"Read","call_id":"native-read-2","args":` + args2 + `}`), -+ } -+ sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey -+ if !internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, items) { -+ t.Fatal("failed to cache parallel provenance") -+ } -+ original := []byte(`{"tools":[{"name":"Read","input_schema":{"type":"object","properties":{"file_path":{"type":"string"},"offset":{"type":"integer","default":0}}}}]}`) -+ opts := cliproxyexecutor.Options{OriginalRequest: original, SourceFormat: sdktranslator.FromString("claude")} -+ -+ out, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload) -+ if errPrepare != nil { -+ t.Fatalf("prepare failed: %v", errPrepare) -+ } -+ calls := gjson.GetBytes(out, "request.contents.0.parts").Array() -+ if len(calls) != 2 || calls[0].Get("functionCall.id").String() != "native-read-1" || calls[1].Get("functionCall.id").String() != "native-read-2" { -+ t.Fatalf("parallel calls were not restored in native order: %s", out) -+ } -+ if calls[0].Get("thoughtSignature").String() == "" || calls[1].Get("thoughtSignature").Exists() { -+ t.Fatalf("signed/unsigned parallel provenance changed: %s", gjson.GetBytes(out, "request.contents.0").Raw) -+ } -+ responses := gjson.GetBytes(out, "request.contents.1.parts").Array() -+ if len(responses) != 2 || responses[0].Get("functionResponse.id").String() != "native-read-1" || responses[1].Get("functionResponse.id").String() != "native-read-2" { -+ t.Fatalf("parallel responses were not normalized to native order: %s", gjson.GetBytes(out, "request.contents.1").Raw) -+ } -+ if errPairing := internalsignature.ValidateGeminiFunctionCallPairing(out); errPairing != nil { -+ t.Fatalf("parallel restored history is invalid: %v", errPairing) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRejectsChangedClaudeToolArguments(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ const model = "gemini-3.6-flash-high" -+ const nativeArgs = `{"file_path":"/tmp/a","old_string":"x","new_string":"y"}` -+ clientID := util.GeminiClaudeToolUseID("native-edit-changed", "Edit", nativeArgs) -+ payload := []byte(`{"sessionId":"sess-changed-args","request":{"contents":[{"role":"model","parts":[{"thoughtSignature":"skip_thought_signature_validator","functionCall":{"id":"` + clientID + `","name":"Edit","args":{"file_path":"/tmp/a","old_string":"x","new_string":"y","replace_all":true}}}]},{"role":"user","parts":[{"functionResponse":{"id":"` + clientID + `","name":"Edit","response":{"result":"ok"}}}]}]}}`) -+ item := []byte(`{"type":"function_call_part","contentIndex":0,"partIndex":0,"targetOccurrence":0,"name":"Edit","call_id":"native-edit-changed","args":` + nativeArgs + `,"thoughtSignature":"EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg"}`) -+ sessionKey := antigravityReasoningReplayScopeFromPayload(model, payload).sessionKey -+ internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) -+ original := []byte(`{"tools":[{"name":"Edit","input_schema":{"type":"object","properties":{"replace_all":{"type":"boolean","default":false}}}}]}`) -+ opts := cliproxyexecutor.Options{OriginalRequest: original, SourceFormat: sdktranslator.FromString("claude")} -+ -+ _, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, cliproxyexecutor.Request{Model: model, Payload: payload}, opts, payload) -+ if errPrepare == nil || !strings.Contains(errPrepare.Error(), "missing Claude tool provenance") { -+ t.Fatalf("error = %v, want fail-closed changed arguments", errPrepare) -+ } -+} -+ -+func TestPrepareAntigravityGeminiReasoningReplayRejectsUnmatchedNonPlaceholderResponseName(t *testing.T) { -+ internalcache.ClearAntigravityReasoningReplayCache() -+ t.Cleanup(internalcache.ClearAntigravityReasoningReplayCache) -+ -+ model := "gemini-3.6-flash-high" -+ sessionKey := antigravityReasoningReplayScopeFromPayload(model, []byte(`{"sessionId":"sess-mismatch"}`)).sessionKey -+ -+ item := []byte(`{ -+ "type": "function_call_part", -+ "contentIndex": 1, -+ "partIndex": 0, -+ "targetOccurrence": 0, -+ "name": "Read", -+ "call_id": "call_mismatch_1", -+ "args": {"path": "/tmp/a"}, -+ "thoughtSignature": "EsMTCsATARFNMg/XNVix5lDpkKaHR7Xg" -+ }`) -+ internalcache.CacheAntigravityReasoningReplayItems(model, sessionKey, [][]byte{item}) -+ -+ // Payload has a non-placeholder name mismatch ("Write" != "Read") -+ mismatchedPayload := []byte(`{ -+ "sessionId": "sess-mismatch", -+ "request": { -+ "contents": [ -+ { -+ "role": "user", -+ "parts": [{"text": "Do task"}] -+ }, -+ { -+ "role": "user", -+ "parts": [ -+ { -+ "functionResponse": { -+ "id": "call_mismatch_1", -+ "name": "Write", -+ "response": {"output": "ok"} -+ } -+ } -+ ] -+ } -+ ] -+ } -+ }`) -+ -+ req := cliproxyexecutor.Request{Model: model, Payload: mismatchedPayload} -+ opts := cliproxyexecutor.Options{} -+ _, _, errPrepare := prepareAntigravityGeminiReasoningReplayPayload(context.Background(), model, req, opts, mismatchedPayload) -+ if errPrepare == nil { -+ t.Fatal("expected 400 error for non-placeholder name mismatch, got nil") -+ } -+ if !strings.Contains(errPrepare.Error(), "invalid Gemini function call history") { -+ t.Fatalf("error = %v, want invalid Gemini function call history", errPrepare) -+ } -+} -diff --git a/internal/signature/gemini_sanitize.go b/internal/signature/gemini_sanitize.go -index 4fefc1d4d0d..959800bc1eb 100644 ---- a/internal/signature/gemini_sanitize.go -+++ b/internal/signature/gemini_sanitize.go -@@ -24,9 +24,10 @@ func GeminiReplaySignatureOrBypass(rawSignature string, blockKind SignatureBlock - } - - // SanitizeGeminiRequestThoughtSignatures applies Gemini replay policy to a --// Gemini-shaped request. Model-turn functionCall, thought, and signed parts keep --// compatible Gemini signatures and use the bypass sentinel otherwise. User-turn --// functionResponse parts must not carry thoughtSignature fields. -+// Gemini-shaped request. Existing provider signatures stay on their original -+// model parts. Only a missing or incompatible first functionCall gets the bypass -+// sentinel; unsigned sibling calls remain unsigned, matching native Gemini -+// parallel-call history. functionResponse parts never carry signatures. - func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) []byte { - contentsPath = strings.TrimSpace(contentsPath) - if contentsPath == "" { -@@ -48,21 +49,22 @@ func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) - } - - isModelTurn := content.Get("role").String() == "model" -+ firstFunctionCallSeen := false - partsChanged := false - partItems := make([][]byte, 0, int(parts.Get("#").Int())) - parts.ForEach(func(partIdx, part gjson.Result) bool { - partJSON := []byte(part.Raw) -+ rawSignature, hasSignature := geminiPartThoughtSignature(part) - if part.Get("functionResponse").Exists() { -- _, hadSignature := geminiPartThoughtSignature(part) -- if hadSignature { -+ if hasSignature { - partJSON = deleteGeminiPartThoughtSignatureFields(partJSON) - partsChanged = true - logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), SignatureCompatibilityDecision{ - TargetProvider: SignatureProviderGemini, - BlockKind: SignatureBlockKindGeminiModelPart, - Action: SignatureActionDropSignature, -- Reason: "user-turn functionResponse parts cannot replay thought signatures", -- }, "", true) -+ Reason: "functionResponse parts cannot replay thought signatures", -+ }, rawSignature, true) - } - partItems = append(partItems, partJSON) - return true -@@ -73,9 +75,11 @@ func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) - } - - hasFunctionCall := part.Get("functionCall").Exists() -- hasThought := part.Get("thought").Exists() -- rawSignature, hasSignature := geminiPartThoughtSignature(part) -- if !hasFunctionCall && !hasThought && !hasSignature { -+ isFirstFunctionCall := hasFunctionCall && !firstFunctionCallSeen -+ if hasFunctionCall { -+ firstFunctionCallSeen = true -+ } -+ if !hasFunctionCall && !hasSignature { - partItems = append(partItems, partJSON) - return true - } -@@ -85,14 +89,38 @@ func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) - blockKind = SignatureBlockKindGeminiFunctionCall - } - decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind) -- replaySignature := GeminiReplaySignatureOrBypass(rawSignature, blockKind) -- if !hasNormalizedGeminiPartThoughtSignature(part, replaySignature) { -+ replaySignature := "" -+ switch { -+ case isFirstFunctionCall: -+ replaySignature = GeminiReplaySignatureOrBypass(rawSignature, blockKind) -+ case hasSignature && decision.Action == SignatureActionPreserve && !IsGeminiThoughtSignatureBypass(SignaturePayloadWithoutProviderPrefix(rawSignature)): -+ replaySignature = decision.NormalizedSignature -+ case hasSignature: -+ decision.Action = SignatureActionDropSignature -+ decision.ReplacementSignature = "" -+ if hasFunctionCall { -+ decision.Reason = "unsigned sibling functionCalls preserve native parallel-call shape" -+ } else { -+ decision.Reason = "non-function model parts do not synthesize Gemini bypass signatures" -+ } -+ } -+ -+ partChanged := false -+ if replaySignature != "" { -+ if !hasNormalizedGeminiPartThoughtSignature(part, replaySignature) { -+ partJSON = deleteGeminiPartThoughtSignatureFields(partJSON) -+ partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", replaySignature) -+ partChanged = true -+ } -+ } else if hasSignature { - partJSON = deleteGeminiPartThoughtSignatureFields(partJSON) -- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", replaySignature) -- partsChanged = true -+ partChanged = true - } -- if decision.Action != SignatureActionPreserve { -- logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), decision, rawSignature, hasSignature) -+ if partChanged { -+ partsChanged = true -+ if decision.Action != SignatureActionPreserve { -+ logGeminiThoughtSignatureSanitize(contentsPath, int(contentIdx.Int()), int(partIdx.Int()), decision, rawSignature, hasSignature) -+ } - } - partItems = append(partItems, partJSON) - return true -@@ -120,31 +148,44 @@ func SanitizeGeminiRequestThoughtSignatures(payload []byte, contentsPath string) - func geminiContentsThoughtSignaturesNeedSanitize(contents gjson.Result) bool { - needsSanitize := false - contents.ForEach(func(_, content gjson.Result) bool { -- isModelTurn := content.Get("role").String() == "model" - parts := content.Get("parts") - if !parts.IsArray() { - return true - } -+ isModelTurn := content.Get("role").String() == "model" -+ firstFunctionCallSeen := false - parts.ForEach(func(_, part gjson.Result) bool { -+ rawSignature, hasSignature := geminiPartThoughtSignature(part) - if part.Get("functionResponse").Exists() { -- _, needsSanitize = geminiPartThoughtSignature(part) -+ needsSanitize = hasSignature - return !needsSanitize - } - if !isModelTurn { - return true - } - hasFunctionCall := part.Get("functionCall").Exists() -- hasThought := part.Get("thought").Exists() -- rawSignature, hasSignature := geminiPartThoughtSignature(part) -- if !hasFunctionCall && !hasThought && !hasSignature { -+ isFirstFunctionCall := hasFunctionCall && !firstFunctionCallSeen -+ if hasFunctionCall { -+ firstFunctionCallSeen = true -+ } -+ if isFirstFunctionCall { -+ replaySignature := GeminiReplaySignatureOrBypass(rawSignature, SignatureBlockKindGeminiFunctionCall) -+ needsSanitize = !hasNormalizedGeminiPartThoughtSignature(part, replaySignature) -+ return !needsSanitize -+ } -+ if !hasSignature { - return true - } - blockKind := SignatureBlockKindGeminiModelPart - if hasFunctionCall { - blockKind = SignatureBlockKindGeminiFunctionCall - } -- replaySignature := GeminiReplaySignatureOrBypass(rawSignature, blockKind) -- needsSanitize = !hasNormalizedGeminiPartThoughtSignature(part, replaySignature) -+ decision := DecideSignatureCompatibility(SignatureProviderGemini, rawSignature, blockKind) -+ if decision.Action != SignatureActionPreserve || IsGeminiThoughtSignatureBypass(SignaturePayloadWithoutProviderPrefix(rawSignature)) { -+ needsSanitize = true -+ return false -+ } -+ needsSanitize = !hasNormalizedGeminiPartThoughtSignature(part, decision.NormalizedSignature) - return !needsSanitize - }) - return !needsSanitize -diff --git a/internal/signature/gemini_sanitize_test.go b/internal/signature/gemini_sanitize_test.go -index 63cf456e5a6..c5ea80593fb 100644 ---- a/internal/signature/gemini_sanitize_test.go -+++ b/internal/signature/gemini_sanitize_test.go -@@ -70,6 +70,78 @@ func TestSanitizeGeminiRequestThoughtSignaturesNormalizesDuplicateCanonicalField - } - } - -+func TestSanitizeGeminiRequestThoughtSignaturesParallelSyntheticOnlyFirstGetsBypass(t *testing.T) { -+ input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}}},{"functionCall":{"name":"second","args":{}}}]}]}`) -+ -+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents") -+ -+ if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator { -+ t.Fatalf("first call signature = %q, want bypass sentinel; output=%s", got, out) -+ } -+ if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() { -+ t.Fatalf("second parallel call should remain unsigned; output=%s", out) -+ } -+} -+ -+func TestSanitizeGeminiRequestThoughtSignaturesNativeParallelPreservesUnsignedSibling(t *testing.T) { -+ nativeSignature := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39}) -+ input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}},"thoughtSignature":"` + nativeSignature + `"},{"functionCall":{"name":"second","args":{}}}]}]}`) -+ -+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents") -+ -+ if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != nativeSignature { -+ t.Fatalf("first call signature = %q, want native signature; output=%s", got, out) -+ } -+ if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() { -+ t.Fatalf("native unsigned sibling should remain unsigned; output=%s", out) -+ } -+ if &out[0] != &input[0] { -+ t.Fatal("already-native parallel history was copied") -+ } -+} -+ -+func TestSanitizeGeminiRequestThoughtSignaturesRemovesPollutedSiblingBypass(t *testing.T) { -+ nativeSignature := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39}) -+ input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}},"thoughtSignature":"` + nativeSignature + `"},{"functionCall":{"name":"second","args":{}},"thoughtSignature":"` + GeminiSkipThoughtSignatureValidator + `"}]}]}`) -+ -+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents") -+ -+ if got := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature").String(); got != nativeSignature { -+ t.Fatalf("first call signature = %q, want native signature; output=%s", got, out) -+ } -+ if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() { -+ t.Fatalf("polluted sibling bypass should be removed; output=%s", out) -+ } -+} -+ -+func TestSanitizeGeminiRequestThoughtSignaturesRemovesPrefixedSiblingBypass(t *testing.T) { -+ nativeSignature := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39}) -+ for _, prefix := range []string{"gemini", "google"} { -+ t.Run(prefix, func(t *testing.T) { -+ input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{}},"thoughtSignature":"` + nativeSignature + `"},{"functionCall":{"name":"second","args":{}},"thoughtSignature":"` + prefix + `#` + GeminiSkipThoughtSignatureValidator + `"}]}]}`) -+ -+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents") -+ -+ if signature := gjson.GetBytes(out, "contents.0.parts.1.thoughtSignature"); signature.Exists() { -+ t.Fatalf("prefixed sibling bypass should be removed; output=%s", out) -+ } -+ }) -+ } -+} -+ -+func TestSanitizeGeminiRequestThoughtSignaturesLeavesUnsignedThoughtUnsigned(t *testing.T) { -+ input := []byte(`{"contents":[{"role":"model","parts":[{"text":"hidden","thought":true}]}]}`) -+ -+ out := SanitizeGeminiRequestThoughtSignatures(input, "contents") -+ -+ if signature := gjson.GetBytes(out, "contents.0.parts.0.thoughtSignature"); signature.Exists() { -+ t.Fatalf("unsigned thought should remain unsigned; output=%s", out) -+ } -+ if &out[0] != &input[0] { -+ t.Fatal("unsigned thought payload was copied") -+ } -+} -+ - func TestSanitizeGeminiRequestThoughtSignaturesReusesUnsignedFunctionResponsePayload(t *testing.T) { - input := []byte(`{"contents":[{"role":"user","parts":[{"functionResponse":{"name":"f","response":{"result":"ok"}}}]}]}`) - -@@ -128,14 +200,14 @@ func TestSanitizeGeminiRequestThoughtSignaturesLogsBypassReplacement(t *testing. - assertSignatureDebugDoesNotLeak(t, hook, sig) - } - --func TestSanitizeGeminiRequestThoughtSignaturesReplacesField2WrappedUUIDFunctionCall(t *testing.T) { -+func TestSanitizeGeminiRequestThoughtSignaturesPreservesField2WrappedUUIDFunctionCall(t *testing.T) { - sig := testGemini3ThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3")) - input := []byte(`{"request":{"contents":[{"role":"model","parts":[{"functionCall":{"name":"f","args":{}},"thoughtSignature":"` + sig + `"}]}]}}`) - - out := SanitizeGeminiRequestThoughtSignatures(input, "request.contents") - -- if got := gjson.GetBytes(out, "request.contents.0.parts.0.thoughtSignature").String(); got != GeminiSkipThoughtSignatureValidator { -- t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, string(out)) -+ if got := gjson.GetBytes(out, "request.contents.0.parts.0.thoughtSignature").String(); got != sig { -+ t.Fatalf("thoughtSignature = %q, want wrapped UUID signature preserved. Output: %s", got, string(out)) - } - } - -diff --git a/internal/signature/gemini_validation.go b/internal/signature/gemini_validation.go -index d3a6551126a..75b9e633edc 100644 ---- a/internal/signature/gemini_validation.go -+++ b/internal/signature/gemini_validation.go -@@ -19,10 +19,10 @@ - // - "skip_thought_signature_validator" - // - "context_engineering_is_the_way_to_go" - // --// This repo currently emits "skip_thought_signature_validator" for non-Claude --// Antigravity Gemini model parts that contain functionCall, thought, or an --// existing thoughtSignature. That is a request-shape compatibility policy, not a --// proof that the replaced signature was malformed. -+// This repo emits "skip_thought_signature_validator" only when the first -+// functionCall in a synthetic model turn lacks a compatible provider signature. -+// Later parallel calls and ordinary text/thought parts preserve their native -+// unsigned shape. - // - // This validator is intentionally more conservative than a decrypting verifier. - // Claude has a known E/R base64 envelope and a protobuf tree in this package. -@@ -32,15 +32,16 @@ - // - // Validation tiers: - // --// - Sentinel tier: accept the documented bypass sentinels only when the --// model functionCall is synthetic, migrated, or otherwise not traceable to a --// prior Gemini model response in the same conversation. -+// - Sentinel tier: accept the documented bypass sentinels only on the first -+// model functionCall when it is synthetic, migrated, or otherwise not -+// traceable to a prior Gemini model response in the same conversation. - // - Opaque-shape tier: for real Gemini signatures, require a non-empty string, - // bounded length, successful standard base64 decoding, and a known protobuf - // envelope when the caller needs provider compatibility. Observed samples --// currently include Gemini 3.x field-2 -> field-1 payloads and Gemini 2.5 --// repeated field-1 payloads. Base64 UUID payloads are classified separately --// and should be replaced with the bypass sentinel rather than replayed. -+// currently include Gemini 3.x field-2 -> field-1 payloads containing either -+// versioned opaque state or a provider UUID, plus Gemini 2.5 repeated field-1 -+// payloads. Bare base64 UUID payloads are classified separately and should be -+// replaced with the bypass sentinel rather than replayed. - // - Replay tier: real validation means preserving the exact model part that - // came from Gemini, including its thoughtSignature, id/name/function args, - // part index, and ordering relative to sibling parallel function calls. -@@ -205,8 +206,9 @@ func InspectGeminiThoughtSignature(rawSignature string, opts ...GeminiThoughtSig - } - - // ValidateGeminiThoughtSignatures validates thoughtSignature fields in a Gemini --// native payload. Function-call parts must have a valid signature. Other parts --// are optional, but if a thoughtSignature field is present it must be valid. -+// native payload. The first functionCall in each model Content must have a valid -+// provider signature or allowed synthetic sentinel. Later parallel sibling calls -+// may be unsigned, but any signature they do carry must still be valid. - func ValidateGeminiThoughtSignatures(inputRawJSON []byte, opts ...GeminiThoughtSignatureValidationOptions) error { - contents, contentsPath := geminiContents(inputRawJSON) - if !contents.IsArray() { -@@ -215,29 +217,47 @@ func ValidateGeminiThoughtSignatures(inputRawJSON []byte, opts ...GeminiThoughtS - - contentResults := contents.Array() - for i := 0; i < len(contentResults); i++ { -- parts := contentResults[i].Get("parts") -+ content := contentResults[i] -+ parts := content.Get("parts") - if !parts.IsArray() { - continue - } - -+ isModelTurn := strings.EqualFold(strings.TrimSpace(content.Get("role").String()), "model") -+ firstFunctionCallSeen := false - partResults := parts.Array() - for j := 0; j < len(partResults); j++ { - part := partResults[j] - hasFunctionCall := part.Get("functionCall").Exists() -- hasSignature := part.Get("thoughtSignature").Exists() -+ isFirstFunctionCall := isModelTurn && hasFunctionCall && !firstFunctionCallSeen -+ if isModelTurn && hasFunctionCall { -+ firstFunctionCallSeen = true -+ } -+ rawSignature, hasSignature := geminiPartThoughtSignature(part) - if !hasFunctionCall && !hasSignature { - continue - } - - partPath := fmt.Sprintf("%s[%d].parts[%d]", contentsPath, i, j) -- rawSignature := strings.TrimSpace(part.Get("thoughtSignature").String()) -+ rawSignature = strings.TrimSpace(rawSignature) -+ if part.Get("functionResponse").Exists() && hasSignature { -+ return fmt.Errorf("%s: functionResponse must not carry thoughtSignature", partPath) -+ } - if rawSignature == "" { -- if hasFunctionCall { -- return fmt.Errorf("%s: missing thoughtSignature on functionCall", partPath) -+ if isFirstFunctionCall { -+ return fmt.Errorf("%s: missing thoughtSignature on first functionCall", partPath) - } -- return fmt.Errorf("%s: empty thoughtSignature", partPath) -+ if hasSignature { -+ return fmt.Errorf("%s: empty thoughtSignature", partPath) -+ } -+ continue -+ } -+ if IsGeminiThoughtSignatureBypass(rawSignature) && !isFirstFunctionCall { -+ return fmt.Errorf("%s: Gemini bypass sentinel is allowed only on the first model functionCall", partPath) -+ } -+ if !hasNormalizedGeminiPartThoughtSignature(part, rawSignature) { -+ return fmt.Errorf("%s: thoughtSignature must use one canonical top-level field", partPath) - } -- - if _, err := InspectGeminiThoughtSignature(rawSignature, opts...); err != nil { - return fmt.Errorf("%s: %w", partPath, err) - } -@@ -263,6 +283,9 @@ func ValidateGeminiFunctionCallPairing(inputRawJSON []byte) error { - for i := 0; i < len(contentResults); i++ { - parts := contentResults[i].Get("parts") - if !parts.IsArray() { -+ if len(pending) > 0 { -+ return fmt.Errorf("%s[%d]: content appears before %d pending functionResponse part(s)", contentsPath, i, len(pending)) -+ } - continue - } - -@@ -303,6 +326,9 @@ func ValidateGeminiFunctionCallPairing(inputRawJSON []byte) error { - } - - if len(responses) == 0 { -+ if len(pending) > 0 { -+ return fmt.Errorf("%s[%d]: content appears before %d pending functionResponse part(s)", contentsPath, i, len(pending)) -+ } - continue - } - if len(pending) == 0 { -@@ -423,7 +449,7 @@ func inspectGeminiField1Envelope(decoded []byte) (geminiEnvelopeInfo, bool) { - - func inspectGeminiField2Envelope(decoded []byte) (geminiEnvelopeInfo, bool) { - value, ok := consumeGeminiField2Field1Value(decoded) -- if !ok || !isLikelyGeminiOpaquePayload(value) { -+ if !ok || (!isLikelyGeminiOpaquePayload(value) && !isASCIIUUIDBytes(value)) { - return geminiEnvelopeInfo{}, false - } - return geminiEnvelopeInfo{ -diff --git a/internal/signature/gemini_validation_test.go b/internal/signature/gemini_validation_test.go -index 0a1023e4c1c..4c09efce459 100644 ---- a/internal/signature/gemini_validation_test.go -+++ b/internal/signature/gemini_validation_test.go -@@ -104,6 +104,28 @@ func TestInspectGeminiThoughtSignature_AcceptsCapturedGemini31FlashLiteEnvelope( - } - } - -+func TestInspectGeminiThoughtSignature_AcceptsGemini3WrappedUUIDEnvelope(t *testing.T) { -+ const providerUUID = "e24830a7-5cd6-42fe-998b-ee539e72b9c3" -+ sig := testGemini3ThoughtSignature([]byte(providerUUID)) -+ -+ info, err := InspectGeminiThoughtSignature(sig, GeminiThoughtSignatureValidationOptions{RequireKnownEnvelope: true}) -+ if err != nil { -+ t.Fatalf("Gemini 3 wrapped UUID envelope should be known: %v", err) -+ } -+ if info.Envelope != GeminiThoughtSignatureEnvelopeProtobufField2 { -+ t.Fatalf("Envelope = %q, want %q", info.Envelope, GeminiThoughtSignatureEnvelopeProtobufField2) -+ } -+ if info.RecordCount != 1 { -+ t.Fatalf("RecordCount = %d, want 1", info.RecordCount) -+ } -+ if info.OpaquePayloadLen != len(providerUUID) { -+ t.Fatalf("OpaquePayloadLen = %d, want %d", info.OpaquePayloadLen, len(providerUUID)) -+ } -+ if provider := DetectSignatureProviderForBlock(sig, SignatureBlockKindGeminiFunctionCall); provider != SignatureProviderGemini { -+ t.Fatalf("provider = %q, want %q", provider, SignatureProviderGemini) -+ } -+} -+ - func TestInspectGeminiThoughtSignature_AcceptsGemini25Field1Envelope(t *testing.T) { - sig := testGemini25ThoughtSignature([]byte{0x01, 0x8f}, []byte{0x01, 0x90, 0x91}) - -@@ -191,7 +213,7 @@ func TestInspectGeminiThoughtSignature_RejectsInvalidBase64(t *testing.T) { - } - } - --func TestValidateGeminiThoughtSignatures_FunctionCallRequiresSignature(t *testing.T) { -+func TestValidateGeminiThoughtSignatures_FirstFunctionCallRequiresSignature(t *testing.T) { - input := []byte(`{ - "contents": [{ - "role": "model", -@@ -203,9 +225,66 @@ func TestValidateGeminiThoughtSignatures_FunctionCallRequiresSignature(t *testin - - err := ValidateGeminiThoughtSignatures(input) - if err == nil { -- t.Fatal("missing functionCall thoughtSignature should fail") -+ t.Fatal("missing first functionCall thoughtSignature should fail") -+ } -+ if !strings.Contains(err.Error(), "missing thoughtSignature on first functionCall") { -+ t.Fatalf("unexpected error: %v", err) -+ } -+} -+ -+func TestValidateGeminiThoughtSignatures_AllowsUnsignedParallelSibling(t *testing.T) { -+ input := []byte(`{ -+ "contents": [{ -+ "role": "model", -+ "parts": [ -+ { -+ "functionCall": {"id": "call-1", "name": "read_file", "args": {}}, -+ "thoughtSignature": "skip_thought_signature_validator" -+ }, -+ {"functionCall": {"id": "call-2", "name": "read_file", "args": {}}} -+ ] -+ }] -+ }`) -+ -+ if err := ValidateGeminiThoughtSignatures(input, GeminiThoughtSignatureValidationOptions{AllowBypassSentinel: true}); err != nil { -+ t.Fatalf("unsigned parallel sibling should be valid: %v", err) - } -- if !strings.Contains(err.Error(), "missing thoughtSignature on functionCall") { -+} -+ -+func TestValidateGeminiThoughtSignatures_RejectsSentinelOutsideFirstFunctionCall(t *testing.T) { -+ tests := []struct { -+ name string -+ parts string -+ }{ -+ { -+ name: "parallel sibling", -+ parts: `[ -+ {"functionCall":{"name":"first","args":{}},"thoughtSignature":"skip_thought_signature_validator"}, -+ {"functionCall":{"name":"second","args":{}},"thoughtSignature":"skip_thought_signature_validator"} -+ ]`, -+ }, -+ { -+ name: "thought part", -+ parts: `[{"text":"hidden","thought":true,"thoughtSignature":"skip_thought_signature_validator"}]`, -+ }, -+ } -+ for _, tt := range tests { -+ t.Run(tt.name, func(t *testing.T) { -+ input := []byte(`{"contents":[{"role":"model","parts":` + tt.parts + `}]}`) -+ err := ValidateGeminiThoughtSignatures(input, GeminiThoughtSignatureValidationOptions{AllowBypassSentinel: true}) -+ if err == nil || !strings.Contains(err.Error(), "allowed only on the first model functionCall") { -+ t.Fatalf("unexpected error: %v", err) -+ } -+ }) -+ } -+} -+ -+func TestValidateGeminiThoughtSignatures_RejectsNonCanonicalNestedSignature(t *testing.T) { -+ signature := testGemini3ThoughtSignature([]byte{0x01, 0x0c, 0x39}) -+ input := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"name":"first","args":{},"thoughtSignature":"` + signature + `"}}]}]}`) -+ -+ err := ValidateGeminiThoughtSignatures(input) -+ if err == nil || !strings.Contains(err.Error(), "canonical top-level field") { - t.Fatalf("unexpected error: %v", err) - } - } -@@ -275,6 +354,26 @@ func TestValidateGeminiFunctionCallPairing_ValidParallelGroup(t *testing.T) { - } - } - -+func TestValidateGeminiFunctionCallPairing_RejectsUserBoundaryBeforeResponse(t *testing.T) { -+ payload := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}}]},{"role":"user","parts":[{"text":"boundary"}]},{"role":"model","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"ok"}}}]}]}`) -+ if err := ValidateGeminiFunctionCallPairing(payload); err == nil { -+ t.Fatal("user boundary before function response was accepted") -+ } -+} -+ -+func TestValidateGeminiFunctionCallPairing_RejectsEmptyContentBoundaryBeforeResponse(t *testing.T) { -+ for _, boundary := range []string{ -+ `{"role":"user","parts":[]}`, -+ `{"role":"user"}`, -+ `{"role":"user","parts":null}`, -+ } { -+ payload := []byte(`{"contents":[{"role":"model","parts":[{"functionCall":{"id":"call-1","name":"run","args":{}}}]},` + boundary + `,{"role":"model","parts":[{"functionResponse":{"id":"call-1","name":"run","response":{"result":"ok"}}}]}]}`) -+ if err := ValidateGeminiFunctionCallPairing(payload); err == nil { -+ t.Fatalf("content boundary %s before function response was accepted", boundary) -+ } -+ } -+} -+ - func TestValidateGeminiFunctionCallPairing_RejectsResponseCountMismatch(t *testing.T) { - input := []byte(`{ - "contents": [ -diff --git a/internal/signature/provider_compatibility_test.go b/internal/signature/provider_compatibility_test.go -index 541bfa1563b..768fa7b8f99 100644 ---- a/internal/signature/provider_compatibility_test.go -+++ b/internal/signature/provider_compatibility_test.go -@@ -143,28 +143,23 @@ func TestGeminiASCIIUUIDSignatureUsesBypass(t *testing.T) { - } - } - --func TestGeminiWrappedUUIDFunctionCallSignatureIsUnknown(t *testing.T) { -+func TestGeminiWrappedUUIDFunctionCallSignatureIsCompatible(t *testing.T) { - sig := testGemini3ThoughtSignature([]byte("e24830a7-5cd6-42fe-998b-ee539e72b9c3")) - -- if got := DetectSignatureProvider(sig); got != SignatureProviderUnknown { -- t.Fatalf("DetectSignatureProvider(wrapped UUID) = %q, want %q", got, SignatureProviderUnknown) -- } -- if got := DetectSignatureProviderForBlock(sig, SignatureBlockKindGeminiFunctionCall); got != SignatureProviderUnknown { -- t.Fatalf("DetectSignatureProviderForBlock(wrapped UUID tool call) = %q, want %q", got, SignatureProviderUnknown) -+ if got := DetectSignatureProvider(sig); got != SignatureProviderGemini { -+ t.Fatalf("DetectSignatureProvider(wrapped UUID) = %q, want %q", got, SignatureProviderGemini) - } -- if normalized, ok := CompatibleSignatureForProviderBlock(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall); ok || normalized != "" { -- t.Fatalf("wrapped UUID tool-call signature normalized=%q ok=%v, want empty and false", normalized, ok) -+ if got := DetectSignatureProviderForBlock(sig, SignatureBlockKindGeminiFunctionCall); got != SignatureProviderGemini { -+ t.Fatalf("DetectSignatureProviderForBlock(wrapped UUID tool call) = %q, want %q", got, SignatureProviderGemini) - } -- decision := DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiFunctionCall) -- if decision.Action != SignatureActionReplaceWithGeminiBypass { -- t.Fatalf("function-call wrapped UUID action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass) -+ 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) - } -- if decision.ReplacementSignature != GeminiSkipThoughtSignatureValidator { -- t.Fatalf("function-call wrapped UUID replacement = %q, want %q", decision.ReplacementSignature, GeminiSkipThoughtSignatureValidator) -- } -- decision = DecideSignatureCompatibility(SignatureProviderGemini, sig, SignatureBlockKindGeminiModelPart) -- if decision.Action != SignatureActionReplaceWithGeminiBypass { -- t.Fatalf("model-part wrapped UUID action = %q, want %q", decision.Action, SignatureActionReplaceWithGeminiBypass) -+ 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) -+ } - } - } - -diff --git a/internal/translator/antigravity/claude/antigravity_claude_request.go b/internal/translator/antigravity/claude/antigravity_claude_request.go -index 1efeb3a09f9..8b99cd885eb 100644 ---- a/internal/translator/antigravity/claude/antigravity_claude_request.go -+++ b/internal/translator/antigravity/claude/antigravity_claude_request.go -@@ -31,7 +31,15 @@ func resolveThinkingSignature(modelName, thinkingText, rawSignature string) stri - func resolveThinkingSignatureRequired(ctx context.Context, modelName, thinkingText, rawSignature string) (string, error) { - targetProvider := sigcompat.SignatureProviderFromModelName(modelName) - if targetProvider == sigcompat.SignatureProviderGemini { -- return resolveProviderCompatibleSignature(targetProvider, rawSignature, sigcompat.SignatureBlockKindGeminiModelPart), nil -+ innerSignature, _, targetKind, marked, okCarrier := decodeGeminiClaudeCarrierSignature(rawSignature) -+ if !okCarrier { -+ return "", nil -+ } -+ blockKind := sigcompat.SignatureBlockKindGeminiModelPart -+ if marked && targetKind == geminiClaudeCarrierFunction { -+ blockKind = sigcompat.SignatureBlockKindGeminiFunctionCall -+ } -+ return resolveProviderCompatibleSignature(targetProvider, innerSignature, blockKind), nil - } - if cache.SignatureCacheEnabled() { - return resolveCacheModeSignatureRequired(ctx, modelName, thinkingText, rawSignature) -@@ -366,6 +374,24 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ - role = "user" - } - partItems := make([][]byte, 0, 4) -+ appendDetachedCarrier := func(signature string, _ bool) { -+ carrier := []byte(`{"text":"","thoughtSignature":""}`) -+ carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", signature) -+ partItems = append(partItems, carrier) -+ } -+ pendingDetachedSignature := "" -+ pendingDetachedTargetKind := "" -+ clearPendingDetachedSignature := func() { -+ pendingDetachedSignature = "" -+ pendingDetachedTargetKind = "" -+ } -+ setPendingDetachedSignature := func(signature, targetKind string) { -+ if pendingDetachedSignature != "" { -+ appendDetachedCarrier(pendingDetachedSignature, true) -+ } -+ pendingDetachedSignature = signature -+ pendingDetachedTargetKind = targetKind -+ } - contentsResult := messageResult.Get("content") - if originalRole == "system" { - if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentsResult); ok { -@@ -383,10 +409,29 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ - contentResult := contentResults[j] - contentTypeResult := contentResult.Get("type") - if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "thinking" { -+ if originalRole != "assistant" { -+ continue -+ } - // Use GetThinkingText to handle wrapped thinking objects - thinkingText := thinking.GetThinkingText(contentResult) - signatureResult := contentResult.Get("signature") - signature := resolveThinkingSignature(modelName, thinkingText, signatureResult.String()) -+ if signature != "" && pendingDetachedSignature != "" { -+ if pendingDetachedSignature != signature { -+ appendDetachedCarrier(pendingDetachedSignature, false) -+ } -+ clearPendingDetachedSignature() -+ } -+ signatureFromPendingCarrier := false -+ if signature == "" && thinkingText != "" && pendingDetachedSignature != "" { -+ if pendingDetachedTargetKind == "" || pendingDetachedTargetKind == geminiClaudeCarrierAny || pendingDetachedTargetKind == geminiClaudeCarrierText { -+ signature = pendingDetachedSignature -+ signatureFromPendingCarrier = true -+ } else { -+ appendDetachedCarrier(pendingDetachedSignature, true) -+ } -+ clearPendingDetachedSignature() -+ } - - // Skip unsigned thinking blocks instead of converting them to text. - isUnsigned := !hasResolvedThinkingSignature(modelName, signature) -@@ -400,23 +445,102 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ - continue - } - -- // Drop empty-text thinking blocks (redacted thinking from Claude Max). -- // Antigravity wraps empty text into a prompt-caching-scope object that -- // omits the required inner "thinking" field, causing: -- // 400 "messages.N.content.0.thinking.thinking: Field required" -- if thinkingText == "" { -+ nextAcceptsDetachedSignature := false -+ nextTargetKind := geminiClaudeCarrierAny -+ if j+1 < numContents { -+ switch contentResults[j+1].Get("type").String() { -+ case "text": -+ nextAcceptsDetachedSignature = true -+ nextTargetKind = geminiClaudeCarrierText -+ case "tool_use": -+ nextAcceptsDetachedSignature = true -+ nextTargetKind = geminiClaudeCarrierFunction -+ } -+ } -+ isGeminiSignature := sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini -+ _, carrierDirection, carrierTargetKind, markedCarrier, validCarrier := decodeGeminiClaudeCarrierSignature(signatureResult.String()) -+ -+ // Gemini places the signature on the visible text/function part that -+ // follows hidden thought text. Keep the thought text, but defer its -+ // opaque signature to that native neighboring part. -+ if thinkingText != "" { -+ partJSON := []byte(`{}`) -+ partJSON, _ = sjson.SetBytes(partJSON, "thought", true) -+ partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText) -+ if signatureFromPendingCarrier { -+ partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature) -+ } else if markedCarrier { -+ carrierTargetsNext := carrierTargetKind == geminiClaudeCarrierAny || carrierTargetKind == nextTargetKind -+ if validCarrier && carrierDirection == geminiClaudeCarrierStandalone && (carrierTargetKind == geminiClaudeCarrierText || carrierTargetKind == geminiClaudeCarrierAny) { -+ partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature) -+ } else if validCarrier && carrierDirection == geminiClaudeCarrierNext && nextAcceptsDetachedSignature && carrierTargetsNext { -+ setPendingDetachedSignature(signature, carrierTargetKind) -+ } -+ } else if isGeminiSignature && nextAcceptsDetachedSignature { -+ setPendingDetachedSignature(signature, nextTargetKind) -+ } else if signature != "" { -+ partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature) -+ } -+ partItems = append(partItems, partJSON) -+ continue -+ } -+ -+ if !isGeminiSignature { - logDroppedAntigravityEmptyThinking(modelName, i, j) - continue - } -+ if markedCarrier && !validCarrier { -+ continue -+ } -+ if markedCarrier && carrierDirection == geminiClaudeCarrierNext { -+ if geminiClaudeCarrierMatchesAdjacent(contentResults, j, carrierDirection, carrierTargetKind) { -+ setPendingDetachedSignature(signature, carrierTargetKind) -+ } -+ continue -+ } -+ if markedCarrier && carrierDirection == geminiClaudeCarrierStandalone { -+ appendDetachedCarrier(signature, false) -+ continue -+ } - -- // Valid signature with content, send as thought block. -- partJSON := []byte(`{}`) -- partJSON, _ = sjson.SetBytes(partJSON, "thought", true) -- partJSON, _ = sjson.SetBytes(partJSON, "text", thinkingText) -- if signature != "" { -- partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature) -+ // Tagged trailing carriers bind backward even when another semantic -+ // block follows. Untagged legacy carriers retain adjacency behavior. -+ bindBackward := markedCarrier && carrierDirection == geminiClaudeCarrierPrevious -+ if bindBackward && !geminiClaudeCarrierMatchesAdjacent(contentResults, j, carrierDirection, carrierTargetKind) { -+ continue -+ } -+ if !bindBackward && nextAcceptsDetachedSignature { -+ setPendingDetachedSignature(signature, nextTargetKind) -+ continue -+ } -+ attached := false -+ foundSemanticPart := false -+ for partIndex := len(partItems) - 1; partIndex >= 0; partIndex-- { -+ part := gjson.ParseBytes(partItems[partIndex]) -+ partTargetKind := "" -+ switch { -+ case part.Get("functionCall").Exists(): -+ partTargetKind = geminiClaudeCarrierFunction -+ case part.Get("text").Exists() && part.Get("text").String() != "": -+ partTargetKind = geminiClaudeCarrierText -+ default: -+ continue -+ } -+ foundSemanticPart = true -+ if markedCarrier && carrierTargetKind != geminiClaudeCarrierAny && carrierTargetKind != partTargetKind { -+ break -+ } -+ if strings.TrimSpace(part.Get("thoughtSignature").String()) == "" { -+ partItems[partIndex], _ = sjson.SetBytes(partItems[partIndex], "thoughtSignature", signature) -+ attached = true -+ } -+ break -+ } -+ if !attached && (foundSemanticPart || bindBackward) { -+ appendDetachedCarrier(signature, false) -+ } else if !attached { -+ setPendingDetachedSignature(signature, carrierTargetKind) - } -- partItems = append(partItems, partJSON) - } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" { - prompt := contentResult.Get("text").String() - // Skip empty text parts to avoid Gemini API error: -@@ -426,6 +550,14 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ - } - partJSON := []byte(`{}`) - partJSON, _ = sjson.SetBytes(partJSON, "text", prompt) -+ if pendingDetachedSignature != "" { -+ if pendingDetachedTargetKind == "" || pendingDetachedTargetKind == geminiClaudeCarrierAny || pendingDetachedTargetKind == geminiClaudeCarrierText { -+ partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", pendingDetachedSignature) -+ } else { -+ appendDetachedCarrier(pendingDetachedSignature, true) -+ } -+ clearPendingDetachedSignature() -+ } - partItems = append(partItems, partJSON) - } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" { - // NOTE: Do NOT inject dummy thinking blocks here. -@@ -456,6 +588,15 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ - partJSON := []byte(`{}`) - - signature := resolveToolUseThoughtSignature(modelName, contentResult, true) -+ if pendingDetachedSignature != "" { -+ pendingMatchesTool := pendingDetachedTargetKind == "" || pendingDetachedTargetKind == geminiClaudeCarrierAny || pendingDetachedTargetKind == geminiClaudeCarrierFunction -+ if pendingMatchesTool && (signature == "" || signature == sigcompat.GeminiSkipThoughtSignatureValidator) { -+ signature = pendingDetachedSignature -+ } else { -+ appendDetachedCarrier(pendingDetachedSignature, true) -+ } -+ clearPendingDetachedSignature() -+ } - if signature != "" { - partJSON, _ = sjson.SetBytes(partJSON, "thoughtSignature", signature) - } else { -@@ -580,8 +721,12 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ - } - } - } -+ if pendingDetachedSignature != "" { -+ appendDetachedCarrier(pendingDetachedSignature, false) -+ clearPendingDetachedSignature() -+ } - -- // Reorder model parts: thinking first, regular content second, function calls last. -+ // Reorder model parts: thinking first, regular content second, function calls and trailing signature carriers last. - if len(partItems) == 0 { - continue - } -@@ -589,18 +734,22 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ - if role == "model" && len(partItems) > 1 { - var thinkingParts [][]byte - var regularParts [][]byte -- var functionCallParts [][]byte -+ var trailingParts [][]byte - needsReorder := false - previousCategory := -1 -+ seenFunctionCall := false - for _, partJSON := range partItems { - part := gjson.ParseBytes(partJSON) - category := 1 -+ isSignatureCarrier := part.Get("text").Exists() && part.Get("text").String() == "" && strings.TrimSpace(part.Get("thoughtSignature").String()) != "" -+ isFunctionTailCarrier := isSignatureCarrier && seenFunctionCall - if part.Get("thought").Bool() { - category = 0 - thinkingParts = append(thinkingParts, partJSON) -- } else if part.Get("functionCall").Exists() { -+ } else if part.Get("functionCall").Exists() || isFunctionTailCarrier { - category = 2 -- functionCallParts = append(functionCallParts, partJSON) -+ trailingParts = append(trailingParts, partJSON) -+ seenFunctionCall = seenFunctionCall || part.Get("functionCall").Exists() - } else { - regularParts = append(regularParts, partJSON) - } -@@ -611,7 +760,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ - newParts := make([][]byte, 0, len(partItems)) - newParts = append(newParts, thinkingParts...) - newParts = append(newParts, regularParts...) -- newParts = append(newParts, functionCallParts...) -+ newParts = append(newParts, trailingParts...) - clientContentJSON, _ = sjson.SetRawBytes(clientContentJSON, "parts", translatorcommon.JoinRawArray(newParts)) - } - } -@@ -767,6 +916,9 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _ - } - - out = common.AttachDefaultSafetySettings(out, "request.safetySettings") -+ if sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini { -+ out = sigcompat.SanitizeGeminiRequestThoughtSignatures(out, "request.contents") -+ } - - return out - } -diff --git a/internal/translator/antigravity/claude/antigravity_claude_request_test.go b/internal/translator/antigravity/claude/antigravity_claude_request_test.go -index f928a30d0eb..52586f294f2 100644 ---- a/internal/translator/antigravity/claude/antigravity_claude_request_test.go -+++ b/internal/translator/antigravity/claude/antigravity_claude_request_test.go -@@ -358,6 +358,391 @@ func testGeminiEPrefixSignature(t *testing.T) string { - return signature - } - -+func TestConvertClaudeRequestToAntigravity_ReattachesDetachedGeminiSignature(t *testing.T) { -+ geminiSig := testGeminiEPrefixSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"text","text":"visible answer"}, -+ {"type":"thinking","thinking":"","signature":"` + geminiSig + `"} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 1 { -+ t.Fatalf("parts = %d, want one native text part; output=%s", len(parts), output) -+ } -+ if got := parts[0].Get("text").String(); got != "visible answer" { -+ t.Fatalf("text = %q; output=%s", got, output) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != geminiSig { -+ t.Fatalf("signature = %q, want detached Gemini signature; output=%s", got, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_ReattachesLeadingDetachedGeminiSignature(t *testing.T) { -+ geminiSig := testGeminiEPrefixSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"thinking","thinking":"","signature":"` + geminiSig + `"}, -+ {"type":"text","text":"visible answer"} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ if got := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != geminiSig { -+ t.Fatalf("leading detached signature = %q, want %q; output=%s", got, geminiSig, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_DropsLegacyRawCarrierFromUserMessage(t *testing.T) { -+ geminiSig := testGeminiEPrefixSignature(t) -+ inputJSON := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"user","content":[{"type":"thinking","thinking":"","signature":"` + geminiSig + `"},{"type":"text","text":"user text"}]}]}`) -+ filtered := StripInvalidGeminiSignatureThinkingBlocks(inputJSON) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", filtered, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 1 || parts[0].Get("text").String() != "user text" || parts[0].Get("thoughtSignature").Exists() { -+ t.Fatalf("user legacy carrier reached Gemini after filtering: %s", output) -+ } -+ -+ directOutput := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ directParts := gjson.GetBytes(directOutput, "request.contents.0.parts").Array() -+ if len(directParts) != 1 || directParts[0].Get("text").String() != "user text" || directParts[0].Get("thoughtSignature").Exists() { -+ t.Fatalf("user legacy carrier reached Gemini without prefilter: %s", directOutput) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_DistributesConsecutiveTrailingGeminiCarriers(t *testing.T) { -+ sig1 := testGeminiEPrefixSignature(t) -+ sig2 := differentClaudeGeminiSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"text","text":"first"}, -+ {"type":"text","text":"second"}, -+ {"type":"thinking","thinking":"","signature":"` + sig1 + `"}, -+ {"type":"thinking","thinking":"","signature":"` + sig2 + `"} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 3 { -+ t.Fatalf("parts = %d, want two text parts + detached carrier; output=%s", len(parts), output) -+ } -+ if got := parts[1].Get("thoughtSignature").String(); got != sig1 { -+ t.Fatalf("latest semantic text signature = %q, want %q; output=%s", got, sig1, output) -+ } -+ if got := parts[2].Get("thoughtSignature").String(); got != sig2 || !parts[2].Get("text").Exists() || parts[2].Get("text").String() != "" { -+ t.Fatalf("second carrier malformed: %s; output=%s", parts[2].Raw, output) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != "" { -+ t.Fatalf("second carrier must not search past the nearest semantic part, got %q; output=%s", got, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_PreservesConsecutiveLeadingGeminiCarriers(t *testing.T) { -+ sig1 := testGeminiEPrefixSignature(t) -+ sig2 := differentClaudeGeminiSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"thinking","thinking":"","signature":"` + sig1 + `"}, -+ {"type":"thinking","thinking":"","signature":"` + sig2 + `"}, -+ {"type":"tool_use","id":"tool-1","name":"run_command","input":{"command":"true"}} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 2 { -+ t.Fatalf("parts = %d, want carrier + signed tool; output=%s", len(parts), output) -+ } -+ if parts[0].Get("thoughtSignature").String() != sig1 || !parts[0].Get("text").Exists() || parts[0].Get("text").String() != "" { -+ t.Fatalf("leading carrier malformed: %s; output=%s", parts[0].Raw, output) -+ } -+ if !parts[1].Get("functionCall").Exists() || parts[1].Get("thoughtSignature").String() != sig2 { -+ t.Fatalf("signed tool malformed: %s; output=%s", parts[1].Raw, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_DirectToolSignatureWinsOverLeadingCarrier(t *testing.T) { -+ prefixSig := testGeminiEPrefixSignature(t) -+ directSig := differentClaudeGeminiSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"thinking","thinking":"","signature":"` + prefixSig + `"}, -+ {"type":"tool_use","id":"tool-1","name":"run_command","input":{"command":"true"},"signature":"` + directSig + `"} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 2 { -+ t.Fatalf("parts = %d, want carrier + directly signed tool; output=%s", len(parts), output) -+ } -+ if parts[0].Get("thoughtSignature").String() != prefixSig || !parts[0].Get("text").Exists() || parts[0].Get("text").String() != "" { -+ t.Fatalf("prefix carrier malformed: %s; output=%s", parts[0].Raw, output) -+ } -+ if !parts[1].Get("functionCall").Exists() || parts[1].Get("thoughtSignature").String() != directSig { -+ t.Fatalf("direct tool signature was overwritten: %s; output=%s", parts[1].Raw, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_PreservesCarrierBetweenDirectlySignedParallelTools(t *testing.T) { -+ sig1 := testGeminiEPrefixSignature(t) -+ sig2 := differentClaudeGeminiSignature(t) -+ rawSig3, errDecode := base64.StdEncoding.DecodeString(sig1) -+ if errDecode != nil { -+ t.Fatal(errDecode) -+ } -+ rawSig3[len(rawSig3)-1] ^= 2 -+ sig3 := base64.StdEncoding.EncodeToString(rawSig3) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"tool_use","id":"tool-1","name":"run_command","input":{"command":"one"},"signature":"` + sig1 + `"}, -+ {"type":"thinking","thinking":"","signature":"` + sig2 + `"}, -+ {"type":"tool_use","id":"tool-2","name":"run_command","input":{"command":"two"},"signature":"` + sig3 + `"} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 3 { -+ t.Fatalf("parts = %d, want tool + carrier + tool; output=%s", len(parts), output) -+ } -+ if parts[0].Get("functionCall.id").String() != "tool-1" || parts[0].Get("thoughtSignature").String() != sig1 { -+ t.Fatalf("first tool malformed: %s; output=%s", parts[0].Raw, output) -+ } -+ if parts[1].Get("thoughtSignature").String() != sig2 || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" { -+ t.Fatalf("middle carrier malformed: %s; output=%s", parts[1].Raw, output) -+ } -+ if parts[2].Get("functionCall.id").String() != "tool-2" || parts[2].Get("thoughtSignature").String() != sig3 { -+ t.Fatalf("second tool malformed: %s; output=%s", parts[2].Raw, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_PreservesCarrierOnlyAssistantMessage(t *testing.T) { -+ sig1 := testGeminiEPrefixSignature(t) -+ sig2 := differentClaudeGeminiSignature(t) -+ for _, tc := range []struct { -+ name string -+ content string -+ signatures []string -+ }{ -+ {name: "single", content: `[{"type":"thinking","thinking":"","signature":"` + sig1 + `"}]`, signatures: []string{sig1}}, -+ {name: "multiple", content: `[{"type":"thinking","thinking":"","signature":"` + sig1 + `"},{"type":"thinking","thinking":"","signature":"` + sig2 + `"}]`, signatures: []string{sig1, sig2}}, -+ } { -+ t.Run(tc.name, func(t *testing.T) { -+ inputJSON := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":` + tc.content + `}]}`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != len(tc.signatures) { -+ t.Fatalf("parts = %d, want %d carriers; output=%s", len(parts), len(tc.signatures), output) -+ } -+ for i, signature := range tc.signatures { -+ if parts[i].Get("thoughtSignature").String() != signature || !parts[i].Get("text").Exists() || parts[i].Get("text").String() != "" { -+ t.Fatalf("carrier %d malformed: %s; output=%s", i, parts[i].Raw, output) -+ } -+ } -+ }) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_PreservesConsecutiveLeadingGeminiCarriersBeforeText(t *testing.T) { -+ sig1 := testGeminiEPrefixSignature(t) -+ sig2 := differentClaudeGeminiSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"thinking","thinking":"","signature":"` + sig1 + `"}, -+ {"type":"thinking","thinking":"","signature":"` + sig2 + `"}, -+ {"type":"text","text":"visible"} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 2 { -+ t.Fatalf("parts = %d, want carrier + signed text; output=%s", len(parts), output) -+ } -+ if parts[0].Get("thoughtSignature").String() != sig1 || !parts[0].Get("text").Exists() || parts[0].Get("text").String() != "" { -+ t.Fatalf("leading carrier order malformed: %s; output=%s", parts[0].Raw, output) -+ } -+ if parts[1].Get("text").String() != "visible" || parts[1].Get("thoughtSignature").String() != sig2 { -+ t.Fatalf("signed text malformed: %s; output=%s", parts[1].Raw, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_PreservesTrailingCarrierAfterSignedTool(t *testing.T) { -+ sig1 := testGeminiEPrefixSignature(t) -+ sig2 := differentClaudeGeminiSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"thinking","thinking":"","signature":"` + sig1 + `"}, -+ {"type":"tool_use","id":"tool-1","name":"run_command","input":{"command":"true"}}, -+ {"type":"thinking","thinking":"","signature":"` + sig2 + `"} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 2 { -+ t.Fatalf("parts = %d, want signed tool + trailing carrier; output=%s", len(parts), output) -+ } -+ if !parts[0].Get("functionCall").Exists() || parts[0].Get("thoughtSignature").String() != sig1 { -+ t.Fatalf("signed tool was reordered: %s; output=%s", parts[0].Raw, output) -+ } -+ if parts[1].Get("thoughtSignature").String() != sig2 || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" { -+ t.Fatalf("trailing carrier malformed: %s; output=%s", parts[1].Raw, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_DetachedToolCarrierTargetsFollowingTool(t *testing.T) { -+ geminiSig := testGeminiEPrefixSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"text","text":"preface"}, -+ {"type":"thinking","thinking":"","signature":"` + geminiSig + `"}, -+ {"type":"tool_use","id":"claude-id","name":"run_command","input":{"command":"true"}} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ if got := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != "" { -+ t.Fatalf("detached tool signature attached backward to text: %q; output=%s", got, output) -+ } -+ if got := gjson.GetBytes(output, "request.contents.0.parts.1.thoughtSignature").String(); got != geminiSig { -+ t.Fatalf("tool signature = %q, want %q; output=%s", got, geminiSig, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_GeminiThinkingSignatureTargetsFollowingText(t *testing.T) { -+ geminiSig := testGeminiEPrefixSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"thinking","thinking":"hidden thought","signature":"` + geminiSig + `"}, -+ {"type":"text","text":"visible answer"} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ if got := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != "" { -+ t.Fatalf("Gemini signature remained on thought part: %q; output=%s", got, output) -+ } -+ if got := gjson.GetBytes(output, "request.contents.0.parts.1.thoughtSignature").String(); got != geminiSig { -+ t.Fatalf("visible signature = %q, want %q; output=%s", got, geminiSig, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_LeadingCarrierDoesNotCrossSignedThinking(t *testing.T) { -+ signature1 := testGeminiEPrefixSignature(t) -+ signature2 := differentClaudeGeminiSignature(t) -+ leading := encodeGeminiClaudeCarrierSignature(signature1, geminiClaudeCarrierNext, geminiClaudeCarrierAny) -+ signedThought := encodeGeminiClaudeCarrierSignature(signature2, geminiClaudeCarrierStandalone, geminiClaudeCarrierText) -+ inputJSON := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"` + leading + `"},{"type":"thinking","thinking":"reason","signature":"` + signedThought + `"},{"type":"text","text":"answer"}]}]}`) -+ inputJSON = StripInvalidGeminiSignatureThinkingBlocks(inputJSON) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 3 || parts[0].Get("text").String() != "reason" || !parts[0].Get("thought").Bool() || parts[0].Get("thoughtSignature").String() != signature2 || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature1 || parts[2].Get("text").String() != "answer" || parts[2].Get("thoughtSignature").String() != "" { -+ t.Fatalf("leading carrier crossed signed thinking: %s", output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_DropsMismatchedMarkedNonEmptyCarrier(t *testing.T) { -+ geminiSig := testGeminiEPrefixSignature(t) -+ for _, content := range []string{ -+ `[{"type":"thinking","thinking":"hidden","signature":"` + encodeGeminiClaudeCarrierSignature(geminiSig, geminiClaudeCarrierNext, geminiClaudeCarrierFunction) + `"},{"type":"text","text":"visible"}]`, -+ `[{"type":"thinking","thinking":"hidden","signature":"` + encodeGeminiClaudeCarrierSignature(geminiSig, geminiClaudeCarrierStandalone, geminiClaudeCarrierFunction) + `"}]`, -+ } { -+ inputJSON := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":` + content + `}]}`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ if strings.Contains(string(output), geminiSig) || strings.Contains(string(output), geminiClaudeCarrierPrefix) { -+ t.Fatalf("mismatched marked carrier reached Gemini wire: %s", output) -+ } -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_GeminiThinkingSignatureTargetsFollowingTool(t *testing.T) { -+ geminiSig := testGeminiEPrefixSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"thinking","thinking":"hidden thought","signature":"` + geminiSig + `"}, -+ {"type":"tool_use","id":"claude-id","name":"run_command","input":{"command":"true"}} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 2 || !parts[0].Get("thought").Bool() { -+ t.Fatalf("thought/tool parts malformed: %s", output) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != "" { -+ t.Fatalf("signature remained on thought part: %q; output=%s", got, output) -+ } -+ if got := parts[1].Get("thoughtSignature").String(); got != geminiSig { -+ t.Fatalf("tool signature = %q, want %q; output=%s", got, geminiSig, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_PreservesGeminiToolSignature(t *testing.T) { -+ geminiSig := testGeminiEPrefixSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"thinking","thinking":"","signature":"` + geminiSig + `"}, -+ {"type":"tool_use","id":"claude-id","name":"run_command","input":{"command":"true"} -+ ]}] -+ }`) -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ if got := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature").String(); got != geminiSig { -+ t.Fatalf("tool signature = %q, want %q; output=%s", got, geminiSig, output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_NativeParallelToolLeavesUnsignedSibling(t *testing.T) { -+ geminiSig := testGeminiEPrefixSignature(t) -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"thinking","thinking":"","signature":"` + geminiSig + `"}, -+ {"type":"tool_use","id":"call-1","name":"Read","input":{"file_path":"/tmp/a"}}, -+ {"type":"tool_use","id":"call-2","name":"Read","input":{"file_path":"/tmp/b"}} -+ ]}] -+ }`) -+ -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 2 { -+ t.Fatalf("parts = %d, want 2 function calls; output=%s", len(parts), output) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != geminiSig { -+ t.Fatalf("first call signature = %q, want native signature; output=%s", got, output) -+ } -+ if signature := parts[1].Get("thoughtSignature"); signature.Exists() { -+ t.Fatalf("native unsigned sibling should remain unsigned; output=%s", output) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_SyntheticParallelToolOnlyFirstGetsSentinel(t *testing.T) { -+ inputJSON := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "messages":[{"role":"assistant","content":[ -+ {"type":"tool_use","id":"call-1","name":"Read","input":{"file_path":"/tmp/a"}}, -+ {"type":"tool_use","id":"call-2","name":"Read","input":{"file_path":"/tmp/b"}} -+ ]}] -+ }`) -+ -+ output := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", inputJSON, true) -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() -+ if len(parts) != 2 { -+ t.Fatalf("parts = %d, want 2 function calls; output=%s", len(parts), output) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != "skip_thought_signature_validator" { -+ t.Fatalf("first synthetic call signature = %q, want sentinel; output=%s", got, output) -+ } -+ if signature := parts[1].Get("thoughtSignature"); signature.Exists() { -+ t.Fatalf("second synthetic sibling should remain unsigned; output=%s", output) -+ } -+} -+ - func TestConvertClaudeRequestToAntigravity_BasicStructure(t *testing.T) { - inputJSON := []byte(`{ - "model": "claude-3-5-sonnet-20240620", -@@ -2696,7 +3081,7 @@ func TestConvertClaudeRequestToAntigravity_BypassMode_DropsWrappedRedactedThinki - cache.ClearSignatureCache("") - }) - -- validSignature := testAnthropicNativeSignature(t) -+ _, validSignature := testAntigravityClaudeSignature(t) - - inputJSON := []byte(`{ - "model": "claude-sonnet-4-6", -@@ -2730,6 +3115,40 @@ func TestConvertClaudeRequestToAntigravity_BypassMode_DropsWrappedRedactedThinki - if assistantParts[0].Get("text").String() != "Answer" { - t.Fatalf("Expected text part preserved, got: %s", assistantParts[0].Raw) - } -+ if assistantParts[0].Get("thoughtSignature").Exists() { -+ t.Fatalf("Wrapped redacted Claude signature must not move to text: %s", assistantParts[0].Raw) -+ } -+} -+ -+func TestConvertClaudeRequestToAntigravity_BypassMode_DropsWrappedRedactedThinkingBeforeTool(t *testing.T) { -+ cache.ClearSignatureCache("") -+ previous := cache.SignatureCacheEnabled() -+ cache.SetSignatureCacheEnabled(false) -+ t.Cleanup(func() { -+ cache.SetSignatureCacheEnabled(previous) -+ cache.ClearSignatureCache("") -+ }) -+ -+ _, validSignature := testAntigravityClaudeSignature(t) -+ inputJSON := []byte(`{ -+ "model": "claude-sonnet-4-6", -+ "messages": [{ -+ "role": "assistant", -+ "content": [ -+ {"type": "thinking", "thinking": "", "signature": "` + validSignature + `"}, -+ {"type": "tool_use", "id": "tool-1", "name": "run", "input": {"command": "true"}} -+ ] -+ }] -+ }`) -+ -+ output := ConvertClaudeRequestToAntigravity("claude-sonnet-4-6", inputJSON, false) -+ toolPart := gjson.GetBytes(output, "request.contents.0.parts.0") -+ if !toolPart.Get("functionCall").Exists() { -+ t.Fatalf("Expected tool part preserved: %s", output) -+ } -+ if toolPart.Get("thoughtSignature").Exists() { -+ t.Fatalf("Wrapped redacted Claude signature must not move to tool: %s", toolPart.Raw) -+ } - } - - func TestConvertClaudeRequestToAntigravity_BypassMode_KeepsNonEmptyThinking(t *testing.T) { -diff --git a/internal/translator/antigravity/claude/antigravity_claude_response.go b/internal/translator/antigravity/claude/antigravity_claude_response.go -index b1a2db81c37..96a9073f271 100644 ---- a/internal/translator/antigravity/claude/antigravity_claude_response.go -+++ b/internal/translator/antigravity/claude/antigravity_claude_response.go -@@ -16,6 +16,7 @@ import ( - "time" - - "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" -+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" - translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" - "github.com/router-for-me/CLIProxyAPI/v7/internal/util" - log "github.com/sirupsen/logrus" -@@ -41,7 +42,20 @@ func decodeSignature(signature string) string { - return signature - } - -+func formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind string) string { -+ if sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini { -+ return encodeGeminiClaudeCarrierSignature(signature, direction, targetKind) -+ } -+ return formatClaudeSignatureValue(modelName, signature) -+} -+ - func formatClaudeSignatureValue(modelName, signature string) string { -+ // Gemini signatures are provider-native replay state. Keep them raw so an -+ // empty detached thinking block or tool_use block can round-trip through -+ // Claude Code and be recognized by the Gemini request translator. -+ if cache.GetModelGroup(modelName) == "gemini" { -+ return signature -+ } - if cache.SignatureCacheEnabled() { - return fmt.Sprintf("%s#%s", cache.GetModelGroup(modelName), signature) - } -@@ -69,12 +83,15 @@ type Params struct { - HasSentFinalEvents bool // Indicates if final content/message events have been sent - HasToolUse bool // Indicates if tool use was observed in the stream - HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output -+ HasSemanticContent bool -+ LastSemanticKind string - HasWebSearchTool bool - WebSearchRequests int64 - WebSearchTextBuffer strings.Builder - - // Signature caching support -- CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching -+ CurrentThinkingText strings.Builder // Accumulates thinking text for signature caching -+ CurrentThinkingSigned bool // Tracks whether the active thinking block already has its terminal signature - - // Reverse map: sanitized Gemini function name → original Claude tool name. - // Populated lazily on the first response chunk from the original request JSON. -@@ -84,6 +101,15 @@ type Params struct { - // toolUseIDCounter provides a process-wide unique counter for tool use identifiers. - var toolUseIDCounter uint64 - -+func antigravityClaudeToolUseID(modelName string, functionCall gjson.Result, fallback string) string { -+ if sigcompat.SignatureProviderFromModelName(modelName) == sigcompat.SignatureProviderGemini { -+ if stableID := util.GeminiClaudeToolUseID(functionCall.Get("id").String(), functionCall.Get("name").String(), functionCall.Get("args").Raw); stableID != "" { -+ return stableID -+ } -+ } -+ return util.SanitizeClaudeToolID(fallback) -+} -+ - // ConvertAntigravityResponseToClaude performs sophisticated streaming response format conversion. - // This function implements a complex state machine that translates backend client responses - // into Claude Code-compatible Server-Sent Events (SSE) format. It manages different response types -@@ -133,7 +159,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR - output = translatorcommon.AppendSSEEventString(output, event, payload, 3) - } - webSearchStreamMode := shouldTranslateWebSearchGrounding(originalRequestRawJSON, requestRawJSON) -- appendThinkingSignature := func(signature string) { -+ appendThinkingSignature := func(signature, direction, targetKind string) { - if signature == "" || params.ResponseType != 2 { - return - } -@@ -141,11 +167,50 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR - cache.CacheSignatureBestEffort(ctx, modelName, params.CurrentThinkingText.String(), signature) - params.CurrentThinkingText.Reset() - } -- sigValue := formatClaudeSignatureValue(modelName, signature) -+ sigValue := formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind) - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue) - appendEvent("content_block_delta", string(data)) -+ params.CurrentThinkingSigned = true - params.HasContent = true - } -+ closeCurrentBlock := func() { -+ if params.ResponseType == 0 { -+ return -+ } -+ appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, params.ResponseIndex)) -+ params.ResponseIndex++ -+ params.ResponseType = 0 -+ params.CurrentThinkingSigned = false -+ } -+ startEmptyThinkingBlock := func() { -+ appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex)) -+ params.ResponseType = 2 -+ params.CurrentThinkingSigned = false -+ params.HasContent = true -+ } -+ appendCarrierSignature := func(signature, direction, targetKind string) { -+ if signature == "" || params.ResponseType != 2 { -+ return -+ } -+ sigValue := formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind) -+ data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, params.ResponseIndex)), "delta.signature", sigValue) -+ appendEvent("content_block_delta", string(data)) -+ params.CurrentThinkingSigned = true -+ params.HasContent = true -+ } -+ appendPartSignature := func(signature, direction, targetKind string) bool { -+ if signature == "" { -+ return false -+ } -+ if params.ResponseType == 2 && !params.CurrentThinkingSigned { -+ appendThinkingSignature(signature, direction, targetKind) -+ return false -+ } -+ closeCurrentBlock() -+ startEmptyThinkingBlock() -+ appendCarrierSignature(signature, direction, targetKind) -+ return 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 -@@ -209,8 +274,14 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR - } - hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != "" && !functionCallResult.Exists() - -- if hasThoughtSignature && !partTextResult.Exists() { -- appendThinkingSignature(thoughtSignatureResult.String()) -+ if hasThoughtSignature && (!partTextResult.Exists() || partTextResult.String() == "") { -+ direction := geminiClaudeCarrierNext -+ targetKind := geminiClaudeCarrierAny -+ if params.HasSemanticContent { -+ direction = geminiClaudeCarrierPrevious -+ targetKind = params.LastSemanticKind -+ } -+ appendPartSignature(thoughtSignatureResult.String(), direction, targetKind) - continue - } - -@@ -219,6 +290,11 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR - partText := partTextResult.String() - if partResult.Get("thought").Bool() { - if partText != "" { -+ params.HasSemanticContent = true -+ params.LastSemanticKind = geminiClaudeCarrierText -+ if params.ResponseType == 2 && params.CurrentThinkingSigned { -+ closeCurrentBlock() -+ } - if params.ResponseType == 2 { - params.CurrentThinkingText.WriteString(partText) - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText) -@@ -230,6 +306,7 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR - params.ResponseIndex++ - } - appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, params.ResponseIndex)) -+ params.CurrentThinkingSigned = false - data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, params.ResponseIndex)), "delta.thinking", partText) - appendEvent("content_block_delta", string(data)) - params.ResponseType = 2 -@@ -239,11 +316,12 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR - } - } - if hasThoughtSignature { -- appendThinkingSignature(thoughtSignatureResult.String()) -+ appendThinkingSignature(thoughtSignatureResult.String(), geminiClaudeCarrierStandalone, geminiClaudeCarrierText) - } - } else { -+ signatureTargetsVisibleText := false - if hasThoughtSignature { -- appendThinkingSignature(thoughtSignatureResult.String()) -+ signatureTargetsVisibleText = appendPartSignature(thoughtSignatureResult.String(), geminiClaudeCarrierNext, geminiClaudeCarrierText) - } - finishReasonResult := gjson.GetBytes(rawJSON, "response.candidates.0.finishReason") - if partText != "" || !finishReasonResult.Exists() { -@@ -265,8 +343,19 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR - } - } - } -+ if partText != "" { -+ params.HasSemanticContent = true -+ params.LastSemanticKind = geminiClaudeCarrierText -+ if signatureTargetsVisibleText { -+ closeCurrentBlock() -+ } -+ } - } - } else if functionCallResult.Exists() { -+ toolSignature := thoughtSignatureResult.String() -+ if cache.GetModelGroup(modelName) != "claude" { -+ appendPartSignature(toolSignature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction) -+ } - // Handle function/tool calls from the AI model - // This processes tool usage requests and formats them for Claude Code API compatibility - params.HasToolUse = true -@@ -297,8 +386,12 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR - // This creates the structure for a function call in Claude Code format - // Create the tool use block with unique ID and function details - data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, params.ResponseIndex)) -- data, _ = sjson.SetBytes(data, "content_block.id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&toolUseIDCounter, 1)))) -+ fallbackID := fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&toolUseIDCounter, 1)) -+ data, _ = sjson.SetBytes(data, "content_block.id", antigravityClaudeToolUseID(modelName, functionCallResult, fallbackID)) - data, _ = sjson.SetBytes(data, "content_block.name", fcName) -+ if cache.GetModelGroup(modelName) == "claude" && toolSignature != "" { -+ data, _ = sjson.SetBytes(data, "content_block.signature", formatClaudeSignatureValue(modelName, toolSignature)) -+ } - appendEvent("content_block_start", string(data)) - - if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() { -@@ -307,6 +400,8 @@ func ConvertAntigravityResponseToClaude(ctx context.Context, _ string, originalR - } - params.ResponseType = 3 - params.HasContent = true -+ params.HasSemanticContent = true -+ params.LastSemanticKind = geminiClaudeCarrierFunction - } - } - } -@@ -491,8 +586,12 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or - textBuilder := strings.Builder{} - thinkingBuilder := strings.Builder{} - thinkingSignature := "" -+ thinkingSignatureDirection := geminiClaudeCarrierStandalone -+ thinkingSignatureTargetKind := geminiClaudeCarrierText - toolIDCounter := 0 - hasToolCall := false -+ hasSemanticContent := false -+ lastSemanticKind := geminiClaudeCarrierAny - - flushText := func() { - if textBuilder.Len() == 0 { -@@ -513,12 +612,24 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or - block := []byte(`{"type":"thinking","thinking":""}`) - block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String()) - if thinkingSignature != "" { -- sigValue := formatClaudeSignatureValue(modelName, thinkingSignature) -+ sigValue := formatGeminiClaudeCarrierValue(modelName, thinkingSignature, thinkingSignatureDirection, thinkingSignatureTargetKind) - block, _ = sjson.SetBytes(block, "signature", sigValue) - } - responseJSON, _ = sjson.SetRawBytes(responseJSON, "content.-1", block) - thinkingBuilder.Reset() - thinkingSignature = "" -+ thinkingSignatureDirection = geminiClaudeCarrierStandalone -+ thinkingSignatureTargetKind = geminiClaudeCarrierText -+ } -+ -+ appendSignatureCarrier := func(signature, direction, targetKind string) { -+ if signature == "" { -+ return -+ } -+ ensureContentArray() -+ carrier := []byte(`{"type":"thinking","thinking":"","signature":""}`) -+ carrier, _ = sjson.SetBytes(carrier, "signature", formatGeminiClaudeCarrierValue(modelName, signature, direction, targetKind)) -+ responseJSON, _ = sjson.SetRawBytes(responseJSON, "content.-1", carrier) - } - - if parts.IsArray() { -@@ -527,33 +638,35 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or - if !sig.Exists() { - sig = part.Get("thought_signature") - } -- hasThoughtSignature := sig.Exists() && sig.String() != "" && !part.Get("functionCall").Exists() -- isThought := part.Get("thought").Bool() -- if hasThoughtSignature && (isThought || thinkingBuilder.Len() > 0) { -- thinkingSignature = sig.String() -- } -- -- if text := part.Get("text"); text.Exists() && text.String() != "" { -- if isThought { -- flushText() -- thinkingBuilder.WriteString(text.String()) -- continue -- } -- flushThinking() -- textBuilder.WriteString(text.String()) -- continue -+ signature := "" -+ if sig.Exists() { -+ signature = sig.String() - } - - if functionCall := part.Get("functionCall"); functionCall.Exists() { -+ signatureAttachedToThought := false -+ isClaudeTarget := cache.GetModelGroup(modelName) == "claude" -+ if !isClaudeTarget && signature != "" && thinkingBuilder.Len() > 0 && thinkingSignature == "" { -+ thinkingSignature = signature -+ thinkingSignatureDirection = geminiClaudeCarrierNext -+ thinkingSignatureTargetKind = geminiClaudeCarrierFunction -+ signatureAttachedToThought = true -+ } - flushThinking() - flushText() - hasToolCall = true - - name := util.RestoreSanitizedToolName(toolNameMap, functionCall.Get("name").String()) - toolIDCounter++ -+ if !isClaudeTarget && signature != "" && !signatureAttachedToThought { -+ appendSignatureCarrier(signature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction) -+ } - toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`) -- toolBlock, _ = sjson.SetBytes(toolBlock, "id", fmt.Sprintf("tool_%d", toolIDCounter)) -+ toolBlock, _ = sjson.SetBytes(toolBlock, "id", antigravityClaudeToolUseID(modelName, functionCall, fmt.Sprintf("tool_%d", toolIDCounter))) - toolBlock, _ = sjson.SetBytes(toolBlock, "name", name) -+ if isClaudeTarget && signature != "" { -+ toolBlock, _ = sjson.SetBytes(toolBlock, "signature", formatClaudeSignatureValue(modelName, signature)) -+ } - - if args := functionCall.Get("args"); args.Exists() && args.Raw != "" && gjson.Valid(args.Raw) && args.IsObject() { - toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(args.Raw)) -@@ -561,8 +674,67 @@ func ConvertAntigravityResponseToClaudeNonStream(_ context.Context, _ string, or - - ensureContentArray() - responseJSON, _ = sjson.SetRawBytes(responseJSON, "content.-1", toolBlock) -+ hasSemanticContent = true -+ lastSemanticKind = geminiClaudeCarrierFunction - continue - } -+ -+ text := part.Get("text") -+ isThought := part.Get("thought").Bool() -+ if isThought { -+ flushText() -+ if thinkingSignature != "" { -+ flushThinking() -+ } -+ if text.Exists() && text.String() != "" { -+ thinkingBuilder.WriteString(text.String()) -+ hasSemanticContent = true -+ lastSemanticKind = geminiClaudeCarrierText -+ } -+ if signature != "" { -+ if thinkingBuilder.Len() > 0 { -+ thinkingSignature = signature -+ thinkingSignatureDirection = geminiClaudeCarrierStandalone -+ thinkingSignatureTargetKind = geminiClaudeCarrierText -+ flushThinking() -+ } else if hasSemanticContent { -+ appendSignatureCarrier(signature, geminiClaudeCarrierPrevious, lastSemanticKind) -+ } else { -+ appendSignatureCarrier(signature, geminiClaudeCarrierNext, geminiClaudeCarrierAny) -+ } -+ } -+ continue -+ } -+ -+ visibleSignatureCarrier := false -+ if signature != "" { -+ if thinkingBuilder.Len() > 0 && thinkingSignature == "" { -+ thinkingSignature = signature -+ thinkingSignatureDirection = geminiClaudeCarrierNext -+ thinkingSignatureTargetKind = geminiClaudeCarrierText -+ flushThinking() -+ } else { -+ flushThinking() -+ flushText() -+ if text.Exists() && text.String() != "" { -+ appendSignatureCarrier(signature, geminiClaudeCarrierNext, geminiClaudeCarrierText) -+ visibleSignatureCarrier = true -+ } else if hasSemanticContent { -+ appendSignatureCarrier(signature, geminiClaudeCarrierPrevious, lastSemanticKind) -+ } else { -+ appendSignatureCarrier(signature, geminiClaudeCarrierNext, geminiClaudeCarrierAny) -+ } -+ } -+ } -+ if text.Exists() && text.String() != "" { -+ flushThinking() -+ textBuilder.WriteString(text.String()) -+ hasSemanticContent = true -+ lastSemanticKind = geminiClaudeCarrierText -+ if visibleSignatureCarrier { -+ flushText() -+ } -+ } - } - } - -diff --git a/internal/translator/antigravity/claude/antigravity_claude_response_test.go b/internal/translator/antigravity/claude/antigravity_claude_response_test.go -index 69f38bdda51..14afcff8f33 100644 ---- a/internal/translator/antigravity/claude/antigravity_claude_response_test.go -+++ b/internal/translator/antigravity/claude/antigravity_claude_response_test.go -@@ -3,12 +3,15 @@ package claude - import ( - "bytes" - "context" -+ "encoding/base64" - "encoding/json" - "strings" - "testing" - - "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" -+ "github.com/router-for-me/CLIProxyAPI/v7/internal/util" - "github.com/tidwall/gjson" -+ "github.com/tidwall/sjson" - ) - - // ============================================================================ -@@ -780,6 +783,415 @@ func TestConvertAntigravityResponseToClaude_SignatureOnlyChunkWithoutThoughtFlag - } - } - -+func TestConvertAntigravityResponseToClaude_VisibleGeminiSignatureUsesLeadingCarrier(t *testing.T) { -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ validSignature := testGeminiEPrefixSignature(t) -+ chunk := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"visible answer","thoughtSignature":"` + validSignature + `"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp-visible-sig"}}`) -+ var param any -+ output := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, chunk, ¶m), nil) -+ outputText := string(output) -+ carrierPos := strings.Index(outputText, `"content_block":{"type":"thinking","thinking":""}`) -+ carrierSignature := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierNext, geminiClaudeCarrierText) -+ signaturePos := strings.Index(outputText, `"type":"signature_delta","signature":"`+carrierSignature+`"`) -+ textPos := strings.Index(outputText, `"type":"text_delta","text":"visible answer"`) -+ if carrierPos < 0 || signaturePos < carrierPos || textPos < signaturePos { -+ t.Fatalf("visible signature carrier must precede text: %s", output) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaude_ThoughtThenSignedFunctionUsesOneThinkingBlock(t *testing.T) { -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ validSignature := testGeminiEPrefixSignature(t) -+ chunks := [][]byte{ -+ []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hidden thought","thought":true}]}}],"modelVersion":"gemini-3.6-flash","responseId":"resp-thought-tool"}}`), -+ []byte(`{"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + validSignature + `","functionCall":{"name":"run_command","args":{"command":"true"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp-thought-tool"}}`), -+ } -+ var param any -+ var output []byte -+ for _, chunk := range chunks { -+ output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, chunk, ¶m), nil)...) -+ } -+ outputText := string(output) -+ if got := strings.Count(outputText, `"content_block":{"type":"thinking"`); got != 1 { -+ t.Fatalf("thinking block count = %d, want one signed thought block: %s", got, output) -+ } -+ carrierSignature := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction) -+ signaturePos := strings.Index(outputText, `"type":"signature_delta","signature":"`+carrierSignature+`"`) -+ toolPos := strings.Index(outputText, `"content_block":{"type":"tool_use"`) -+ if signaturePos < 0 || toolPos < signaturePos { -+ t.Fatalf("signed thinking block must precede tool: %s", output) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaude_DetachedGeminiSignatureAfterVisibleText(t *testing.T) { -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"user","content":[{"type":"text","text":"Test"}]}]}`) -+ validSignature := testGeminiEPrefixSignature(t) -+ chunks := [][]byte{ -+ []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"visible answer"}]}}],"modelVersion":"gemini-3.6-flash","responseId":"resp-detached"}}`), -+ []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + validSignature + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":15},"modelVersion":"gemini-3.6-flash","responseId":"resp-detached"}}`), -+ } -+ -+ var param any -+ var output []byte -+ for _, chunk := range chunks { -+ output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, chunk, ¶m), nil)...) -+ } -+ output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...) -+ outputText := string(output) -+ -+ if !strings.Contains(outputText, `"content_block":{"type":"text","text":""}`) { -+ t.Fatalf("missing visible text block: %s", outputText) -+ } -+ if !strings.Contains(outputText, `"content_block":{"type":"thinking","thinking":""}`) { -+ t.Fatalf("missing detached thinking carrier: %s", outputText) -+ } -+ carrierSignature := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText) -+ if !strings.Contains(outputText, `"type":"signature_delta","signature":"`+carrierSignature+`"`) { -+ t.Fatalf("missing detached Gemini signature: %s", outputText) -+ } -+ if got := strings.Count(outputText, `"type":"content_block_stop"`); got != 2 { -+ t.Fatalf("content block stops = %d, want text + detached thinking; output=%s", got, outputText) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaude_GeminiToolSignature(t *testing.T) { -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"user","content":[{"type":"text","text":"Test"}]}]}`) -+ validSignature := testGeminiEPrefixSignature(t) -+ chunk := []byte(`{"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + validSignature + `","functionCall":{"id":"native-id","name":"run_command","args":{"command":"true"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":15},"modelVersion":"gemini-3.6-flash","responseId":"resp-tool"}}`) -+ -+ var param any -+ output := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, chunk, ¶m), nil) -+ outputText := string(output) -+ carrierPos := strings.Index(outputText, `"content_block":{"type":"thinking","thinking":""}`) -+ carrierSignature := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction) -+ signaturePos := strings.Index(outputText, `"type":"signature_delta","signature":"`+carrierSignature+`"`) -+ toolPos := strings.Index(outputText, `"content_block":{"type":"tool_use"`) -+ if carrierPos < 0 || signaturePos < carrierPos || toolPos < signaturePos { -+ t.Fatalf("tool signature carrier must precede tool_use: %s", output) -+ } -+} -+ -+func differentClaudeGeminiSignature(t *testing.T) string { -+ t.Helper() -+ raw, errDecode := base64.StdEncoding.DecodeString(testGeminiEPrefixSignature(t)) -+ if errDecode != nil { -+ t.Fatal(errDecode) -+ } -+ raw[len(raw)-1] ^= 1 -+ return base64.StdEncoding.EncodeToString(raw) -+} -+ -+func TestConvertAntigravityResponseToClaude_PreservesClaudeThoughtAndToolSignatures(t *testing.T) { -+ previousCache := cache.SignatureCacheEnabled() -+ cache.SetSignatureCacheEnabled(false) -+ t.Cleanup(func() { cache.SetSignatureCacheEnabled(previousCache) }) -+ -+ _, upstreamSig1 := testAntigravityClaudeSignature(t) -+ nativePayload2 := buildClaudeSignaturePayload(t, 13, uint64Ptr(2), "claude-opus-4-6", true) -+ nativeSig2 := base64.StdEncoding.EncodeToString(nativePayload2) -+ upstreamSig2 := base64.StdEncoding.EncodeToString([]byte(nativeSig2)) -+ requestJSON := []byte(`{"model":"claude-sonnet-4-6"}`) -+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hidden","thought":true,"thoughtSignature":"` + upstreamSig1 + `"},{"functionCall":{"id":"native-id","name":"run_command","args":{"command":"true"}},"thoughtSignature":"` + upstreamSig2 + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"thoughtsTokenCount":1,"totalTokenCount":3},"modelVersion":"claude-sonnet-4-6-thinking","responseId":"resp-claude-thought-tool"}}`) -+ -+ nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "claude-sonnet-4-6", requestJSON, requestJSON, responseJSON, nil) -+ content := gjson.GetBytes(nonStream, "content").Array() -+ if len(content) != 2 { -+ t.Fatalf("content blocks = %d, want thinking + tool; output=%s", len(content), nonStream) -+ } -+ thinkingCarrierSig := content[0].Get("signature").String() -+ toolCarrierSig := content[1].Get("signature").String() -+ if thinkingCarrierSig == "" || toolCarrierSig == "" || thinkingCarrierSig == toolCarrierSig { -+ t.Fatalf("Claude signatures were not kept on distinct native blocks: %s", nonStream) -+ } -+ -+ replayRequest := []byte(`{"model":"claude-sonnet-4-6","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"text","text":"continue"}]}]}`) -+ replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(nonStream, "content").Raw)) -+ replayRequest = StripEmptySignatureThinkingBlocks(replayRequest) -+ translated := ConvertClaudeRequestToAntigravity("claude-sonnet-4-6", replayRequest, false) -+ parts := gjson.GetBytes(translated, "request.contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != upstreamSig1 || parts[1].Get("thoughtSignature").String() != upstreamSig2 { -+ t.Fatalf("Claude thought/tool signatures did not round-trip: %s", translated) -+ } -+ -+ var param any -+ stream := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "claude-sonnet-4-6", requestJSON, requestJSON, responseJSON, ¶m), nil) -+ streamText := string(stream) -+ if got := strings.Count(streamText, `"content_block":{"type":"thinking"`); got != 1 { -+ t.Fatalf("stream thinking block count = %d, want 1; output=%s", got, stream) -+ } -+ if !strings.Contains(streamText, `"content_block":{"type":"tool_use"`) || !strings.Contains(streamText, `"signature":"`+toolCarrierSig+`"`) { -+ t.Fatalf("stream tool signature missing: %s", stream) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaudeNonStream_SignedThoughtBeforeUnsignedTextKeepsTarget(t *testing.T) { -+ signature := testGeminiEPrefixSignature(t) -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hidden","thought":true,"thoughtSignature":"` + signature + `"},{"text":"visible"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"signed-thought-unsigned-text"}}`) -+ -+ output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil) -+ replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]}]}`) -+ replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(output, "content").Raw)) -+ replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest) -+ translated := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false) -+ parts := gjson.GetBytes(translated, "request.contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("text").String() != "hidden" || !parts[0].Get("thought").Bool() || parts[0].Get("thoughtSignature").String() != signature || parts[1].Get("text").String() != "visible" || parts[1].Get("thoughtSignature").String() != "" { -+ t.Fatalf("signed thought target changed: output=%s translated=%s", output, translated) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaudeNonStream_PreviousCarrierDoesNotCrossFollowingText(t *testing.T) { -+ signature1 := testGeminiEPrefixSignature(t) -+ signature2 := differentClaudeGeminiSignature(t) -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"A","thoughtSignature":"` + signature1 + `"},{"text":"","thoughtSignature":"` + signature2 + `"},{"text":"B"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"previous-carrier-boundary"}}`) -+ -+ output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil) -+ replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]}]}`) -+ replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(output, "content").Raw)) -+ replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest) -+ translated := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false) -+ parts := gjson.GetBytes(translated, "request.contents.0.parts").Array() -+ if len(parts) != 3 || parts[0].Get("text").String() != "A" || parts[0].Get("thoughtSignature").String() != signature1 || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature2 || parts[2].Get("text").String() != "B" || parts[2].Get("thoughtSignature").String() != "" { -+ t.Fatalf("previous carrier crossed following text: output=%s translated=%s", output, translated) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaudeNonStream_PreservesDistinctThoughtAndTextSignatures(t *testing.T) { -+ sig1 := testGeminiEPrefixSignature(t) -+ sig2 := differentClaudeGeminiSignature(t) -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hidden","thought":true,"thoughtSignature":"` + sig1 + `"},{"text":"visible","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp-distinct-signatures"}}`) -+ -+ output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil) -+ content := gjson.GetBytes(output, "content").Array() -+ if len(content) != 3 { -+ t.Fatalf("content blocks = %d, want signed thought + carrier + text; output=%s", len(content), output) -+ } -+ if got := content[0].Get("thinking").String(); got != "hidden" { -+ t.Fatalf("thought text = %q; output=%s", got, output) -+ } -+ wantThoughtCarrier := encodeGeminiClaudeCarrierSignature(sig1, geminiClaudeCarrierStandalone, geminiClaudeCarrierText) -+ if got := content[0].Get("signature").String(); got != wantThoughtCarrier { -+ t.Fatalf("thought signature = %q, want standalone carrier %q; output=%s", got, wantThoughtCarrier, output) -+ } -+ wantCarrier := encodeGeminiClaudeCarrierSignature(sig2, geminiClaudeCarrierNext, geminiClaudeCarrierText) -+ if got := content[1].Get("signature").String(); got != wantCarrier || content[1].Get("thinking").String() != "" { -+ t.Fatalf("visible carrier malformed: %s; output=%s", content[1].Raw, output) -+ } -+ if got := content[2].Get("text").String(); got != "visible" { -+ t.Fatalf("visible text = %q; output=%s", got, output) -+ } -+ -+ replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"text","text":"continue"}]}]}`) -+ replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(output, "content").Raw)) -+ replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest) -+ translated := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false) -+ parts := gjson.GetBytes(translated, "request.contents.0.parts").Array() -+ if len(parts) != 2 { -+ t.Fatalf("replayed parts = %d, want thought + text; translated=%s", len(parts), translated) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != sig1 { -+ t.Fatalf("replayed thought signature = %q, want %q; translated=%s", got, sig1, translated) -+ } -+ if got := parts[1].Get("thoughtSignature").String(); got != sig2 { -+ t.Fatalf("replayed text signature = %q, want %q; translated=%s", got, sig2, translated) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaudeStream_PreservesDistinctThoughtAndTextSignatures(t *testing.T) { -+ sig1 := testGeminiEPrefixSignature(t) -+ sig2 := differentClaudeGeminiSignature(t) -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ chunk := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hidden","thought":true,"thoughtSignature":"` + sig1 + `"},{"text":"visible","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"thoughtsTokenCount":1,"totalTokenCount":3},"modelVersion":"gemini-3.6-flash","responseId":"resp-distinct-signatures"}}`) -+ var param any -+ output := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, chunk, ¶m), nil) -+ outputText := string(output) -+ if got := strings.Count(outputText, `"content_block":{"type":"thinking"`); got != 2 { -+ t.Fatalf("thinking block count = %d, want 2; output=%s", got, output) -+ } -+ if got := strings.Count(outputText, `"type":"signature_delta"`); got != 2 { -+ t.Fatalf("signature delta count = %d, want 2; output=%s", got, output) -+ } -+ firstCarrier := encodeGeminiClaudeCarrierSignature(sig1, geminiClaudeCarrierStandalone, geminiClaudeCarrierText) -+ firstSignature := strings.Index(outputText, `"signature":"`+firstCarrier+`"`) -+ secondCarrier := encodeGeminiClaudeCarrierSignature(sig2, geminiClaudeCarrierNext, geminiClaudeCarrierText) -+ secondSignature := strings.Index(outputText, `"signature":"`+secondCarrier+`"`) -+ visibleText := strings.Index(outputText, `"text":"visible"`) -+ if firstSignature < 0 || secondSignature < firstSignature || visibleText < secondSignature { -+ t.Fatalf("signature/text order is wrong; output=%s", output) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaude_PreservesConsecutiveDetachedCarriers(t *testing.T) { -+ sig1 := testGeminiEPrefixSignature(t) -+ sig2 := differentClaudeGeminiSignature(t) -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"visible"},{"text":"","thoughtSignature":"` + sig1 + `"},{"text":"","thoughtSignature":"` + sig2 + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2},"modelVersion":"gemini-3.6-flash","responseId":"resp-consecutive-carriers"}}`) -+ -+ nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil) -+ content := gjson.GetBytes(nonStream, "content").Array() -+ wantCarrier1 := encodeGeminiClaudeCarrierSignature(sig1, geminiClaudeCarrierPrevious, geminiClaudeCarrierText) -+ wantCarrier2 := encodeGeminiClaudeCarrierSignature(sig2, geminiClaudeCarrierPrevious, geminiClaudeCarrierText) -+ if len(content) != 3 || content[1].Get("signature").String() != wantCarrier1 || content[2].Get("signature").String() != wantCarrier2 { -+ t.Fatalf("non-stream carriers were merged: %s", nonStream) -+ } -+ replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"text","text":"continue"}]}]}`) -+ replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(nonStream, "content").Raw)) -+ replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest) -+ translated := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false) -+ parts := gjson.GetBytes(translated, "request.contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != sig1 || parts[1].Get("thoughtSignature").String() != sig2 { -+ t.Fatalf("consecutive carriers did not round-trip in order: %s", translated) -+ } -+ if parts[0].Get("text").String() != "visible" || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" { -+ t.Fatalf("consecutive carrier targets malformed: %s", translated) -+ } -+ -+ var param any -+ stream := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, ¶m), nil) -+ streamText := string(stream) -+ if got := strings.Count(streamText, `"content_block":{"type":"thinking"`); got != 2 { -+ t.Fatalf("stream thinking carrier count = %d, want 2; output=%s", got, stream) -+ } -+ if got := strings.Count(streamText, `"type":"signature_delta"`); got != 2 { -+ t.Fatalf("stream signature count = %d, want 2; output=%s", got, stream) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaudeNonStream_ThoughtBeforeSignedToolRoundTrips(t *testing.T) { -+ validSignature := testGeminiEPrefixSignature(t) -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"hidden analysis","thought":true},{"thoughtSignature":"` + validSignature + `","functionCall":{"id":"native-id","name":"run_command","args":{"command":"true"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp-thought-tool"}}`) -+ -+ output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil) -+ if got := gjson.GetBytes(output, "content.#").Int(); got != 2 { -+ t.Fatalf("content blocks = %d, want thinking + tool_use; output=%s", got, output) -+ } -+ if got := gjson.GetBytes(output, "content.0.thinking").String(); got != "hidden analysis" { -+ t.Fatalf("thinking text = %q; output=%s", got, output) -+ } -+ wantCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction) -+ if got := gjson.GetBytes(output, "content.0.signature").String(); got != wantCarrier { -+ t.Fatalf("thinking carrier signature = %q, want %q; output=%s", got, wantCarrier, output) -+ } -+ -+ replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"text","text":"continue"}]}]}`) -+ replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(output, "content").Raw)) -+ replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest) -+ translated := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false) -+ if got := gjson.GetBytes(translated, "request.contents.0.parts.0.text").String(); got != "hidden analysis" { -+ t.Fatalf("replayed thought text = %q; translated=%s", got, translated) -+ } -+ if gjson.GetBytes(translated, "request.contents.0.parts.0.thoughtSignature").Exists() { -+ t.Fatalf("thought part must remain unsigned; translated=%s", translated) -+ } -+ if got := gjson.GetBytes(translated, "request.contents.0.parts.1.thoughtSignature").String(); got != validSignature { -+ t.Fatalf("tool signature = %q, want %q; translated=%s", got, validSignature, translated) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaudeNonStream_DetachedGeminiSignatureAfterVisibleText(t *testing.T) { -+ validSignature := testGeminiEPrefixSignature(t) -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"visible answer"},{"text":"","thoughtSignature":"` + validSignature + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":15},"modelVersion":"gemini-3.6-flash","responseId":"resp-detached"}}`) -+ output := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil) -+ if got := gjson.GetBytes(output, "content.#").Int(); got != 2 { -+ t.Fatalf("content blocks = %d, want text + detached thinking; output=%s", got, output) -+ } -+ if got := gjson.GetBytes(output, "content.0.text").String(); got != "visible answer" { -+ t.Fatalf("visible text = %q; output=%s", got, output) -+ } -+ if got := gjson.GetBytes(output, "content.1.type").String(); got != "thinking" { -+ t.Fatalf("detached block type = %q; output=%s", got, output) -+ } -+ wantCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText) -+ if got := gjson.GetBytes(output, "content.1.signature").String(); got != wantCarrier { -+ t.Fatalf("detached signature = %q, want %q; output=%s", got, wantCarrier, output) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaude_DirectionalTextCarriersRoundTrip(t *testing.T) { -+ signature := testGeminiEPrefixSignature(t) -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ testCases := []struct { -+ name string -+ parts string -+ wantFirstSignature string -+ wantSecondSignature string -+ wantDirection string -+ carrierIndex int -+ }{ -+ {name: "signed first part", parts: `[{"text":"A","thoughtSignature":"` + signature + `"},{"text":"B"}]`, wantFirstSignature: signature, wantDirection: geminiClaudeCarrierNext}, -+ {name: "trailing carrier before next part", parts: `[{"text":"A"},{"text":"","thoughtSignature":"` + signature + `"},{"text":"B"}]`, wantFirstSignature: signature, wantDirection: geminiClaudeCarrierPrevious, carrierIndex: 1}, -+ {name: "signed second part", parts: `[{"text":"A"},{"text":"B","thoughtSignature":"` + signature + `"}]`, wantSecondSignature: signature, wantDirection: geminiClaudeCarrierNext, carrierIndex: 1}, -+ } -+ for _, testCase := range testCases { -+ t.Run(testCase.name, func(t *testing.T) { -+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":` + testCase.parts + `},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"directional-text"}}`) -+ nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil) -+ content := gjson.GetBytes(nonStream, "content").Array() -+ if len(content) != 3 { -+ t.Fatalf("Claude content count = %d, want carrier + two text blocks; output=%s", len(content), nonStream) -+ } -+ carrierSignature := content[testCase.carrierIndex].Get("signature").String() -+ _, direction, targetKind, marked, okCarrier := decodeGeminiClaudeCarrierSignature(carrierSignature) -+ if !marked || !okCarrier || direction != testCase.wantDirection || targetKind != geminiClaudeCarrierText { -+ t.Fatalf("directional carrier malformed: %q", carrierSignature) -+ } -+ -+ replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]},{"role":"user","content":[{"type":"text","text":"continue"}]}]}`) -+ replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(nonStream, "content").Raw)) -+ replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest) -+ translated := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false) -+ parts := gjson.GetBytes(translated, "request.contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("text").String() != "A" || parts[1].Get("text").String() != "B" { -+ t.Fatalf("text boundaries changed: %s", translated) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != testCase.wantFirstSignature { -+ t.Fatalf("first signature = %q, want %q; translated=%s", got, testCase.wantFirstSignature, translated) -+ } -+ if got := parts[1].Get("thoughtSignature").String(); got != testCase.wantSecondSignature { -+ t.Fatalf("second signature = %q, want %q; translated=%s", got, testCase.wantSecondSignature, translated) -+ } -+ if strings.Contains(string(translated), geminiClaudeCarrierPrefix) { -+ t.Fatalf("carrier envelope leaked to Gemini wire: %s", translated) -+ } -+ -+ var param any -+ stream := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, ¶m), nil) -+ if got := strings.Count(string(stream), `"content_block":{"type":"text"`); got != 2 { -+ t.Fatalf("stream text block count = %d, want 2; output=%s", got, stream) -+ } -+ if !strings.Contains(string(stream), geminiClaudeCarrierPrefix+testCase.wantDirection+":"+geminiClaudeCarrierText+":") { -+ t.Fatalf("stream carrier direction missing: %s", stream) -+ } -+ }) -+ } -+} -+ -+func TestConvertAntigravityResponseToClaude_LeadingCarrierTargetsFollowingThought(t *testing.T) { -+ signature := testGeminiEPrefixSignature(t) -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + signature + `"},{"text":"reason","thought":true}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"leading-thought"}}`) -+ nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil) -+ content := gjson.GetBytes(nonStream, "content").Array() -+ if len(content) != 2 || content[0].Get("thinking").String() != "" || content[1].Get("thinking").String() != "reason" { -+ t.Fatalf("leading thought carrier response malformed: %s", nonStream) -+ } -+ replayRequest := []byte(`{"model":"gemini-3.6-flash-high","messages":[{"role":"assistant","content":[]}]}`) -+ replayRequest, _ = sjson.SetRawBytes(replayRequest, "messages.0.content", []byte(gjson.GetBytes(nonStream, "content").Raw)) -+ replayRequest = StripInvalidGeminiSignatureThinkingBlocks(replayRequest) -+ if got := gjson.GetBytes(replayRequest, "messages.0.content.#").Int(); got != 2 { -+ t.Fatalf("prevalidation dropped unsigned target thought: %s", replayRequest) -+ } -+ translated := ConvertClaudeRequestToAntigravity("gemini-3.6-flash-high", replayRequest, false) -+ part := gjson.GetBytes(translated, "request.contents.0.parts.0") -+ if part.Get("text").String() != "reason" || !part.Get("thought").Bool() || part.Get("thoughtSignature").String() != signature { -+ t.Fatalf("leading thought carrier did not round-trip: %s", translated) -+ } -+} -+ - func TestConvertAntigravityResponseToClaudeNonStream_SignatureOnlyPartWithoutThoughtFlag(t *testing.T) { - previousCache := cache.SignatureCacheEnabled() - cache.SetSignatureCacheEnabled(false) -@@ -863,8 +1275,9 @@ func TestConvertAntigravityResponseToClaudeNonStream_TextWithThoughtSignatureSta - if got := gjson.GetBytes(output, "content.0.thinking").String(); got != "I need to multiply 17 by 24." { - t.Fatalf("thinking = %q, want thought text. Output: %s", got, output) - } -- if got := gjson.GetBytes(output, "content.0.signature").String(); got != "sig-final-answer" { -- t.Fatalf("signature = %q, want sig-final-answer. Output: %s", got, output) -+ wantCarrier := encodeGeminiClaudeCarrierSignature("sig-final-answer", geminiClaudeCarrierNext, geminiClaudeCarrierText) -+ if got := gjson.GetBytes(output, "content.0.signature").String(); got != wantCarrier { -+ t.Fatalf("signature = %q, want %q. Output: %s", got, wantCarrier, output) - } - if got := gjson.GetBytes(output, "content.1.type").String(); got != "text" { - t.Fatalf("content.1.type = %q, want text. Output: %s", got, output) -@@ -912,7 +1325,8 @@ func TestConvertAntigravityResponseToClaudeStream_TextWithThoughtSignatureStaysT - output = append(output, bytes.Join(ConvertAntigravityResponseToClaude(ctx, "gemini-3.1-pro-low", requestJSON, translatedRequestJSON, []byte("[DONE]"), ¶m), nil)...) - outputText := string(output) - -- if !strings.Contains(outputText, `"delta":{"type":"signature_delta","signature":"sig-final-answer"}`) { -+ wantCarrier := encodeGeminiClaudeCarrierSignature("sig-final-answer", geminiClaudeCarrierNext, geminiClaudeCarrierText) -+ if !strings.Contains(outputText, `"delta":{"type":"signature_delta","signature":"`+wantCarrier+`"}`) { - t.Fatalf("expected signature delta for thinking block: %s", outputText) - } - if !strings.Contains(outputText, `"content_block":{"type":"text","text":""}`) { -@@ -925,3 +1339,23 @@ func TestConvertAntigravityResponseToClaudeStream_TextWithThoughtSignatureStaysT - t.Fatalf("final answer must not be emitted as thinking delta: %s", outputText) - } - } -+ -+func TestConvertAntigravityResponseToClaudeUsesStableGeminiToolProvenanceID(t *testing.T) { -+ requestJSON := []byte(`{"model":"gemini-3.6-flash-high"}`) -+ responseJSON := []byte(`{"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"sig-native","functionCall":{"id":"native-call-1","name":"Edit","args":{"file_path":"/tmp/a","old_string":"x","new_string":"y"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":1,"totalTokenCount":2},"modelVersion":"gemini-3.6-flash","responseId":"resp-stable-tool-id"}}`) -+ wantID := util.GeminiClaudeToolUseID("native-call-1", "Edit", `{"file_path":"/tmp/a","old_string":"x","new_string":"y"}`) -+ if wantID == "" { -+ t.Fatal("stable tool provenance ID is empty") -+ } -+ -+ nonStream := ConvertAntigravityResponseToClaudeNonStream(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, nil) -+ if got := gjson.GetBytes(nonStream, "content.#(type==\"tool_use\").id").String(); got != wantID { -+ t.Fatalf("non-stream tool_use.id = %q, want %q; output=%s", got, wantID, nonStream) -+ } -+ -+ var param any -+ stream := bytes.Join(ConvertAntigravityResponseToClaude(context.Background(), "gemini-3.6-flash-high", requestJSON, requestJSON, responseJSON, ¶m), nil) -+ if !strings.Contains(string(stream), `"content_block":{"type":"tool_use","id":"`+wantID+`"`) { -+ t.Fatalf("stream tool_use.id is not stable: %s", stream) -+ } -+} -diff --git a/internal/translator/antigravity/claude/signature_validation.go b/internal/translator/antigravity/claude/signature_validation.go -index 9431a4c7e73..bdb34a6b0d7 100644 ---- a/internal/translator/antigravity/claude/signature_validation.go -+++ b/internal/translator/antigravity/claude/signature_validation.go -@@ -2,14 +2,109 @@ - package claude - - import ( -+ "encoding/base64" -+ "strings" -+ - "github.com/router-for-me/CLIProxyAPI/v7/internal/cache" - "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" -+ translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common" -+ "github.com/tidwall/gjson" -+ "github.com/tidwall/sjson" - ) - --const maxBypassSignatureLen = signature.MaxClaudeThinkingSignatureLen -+const ( -+ maxBypassSignatureLen = signature.MaxClaudeThinkingSignatureLen -+ -+ // Gemini carrier envelopes exist only on the Claude-facing wire. The request -+ // translator validates and unwraps them before writing native Gemini parts. -+ geminiClaudeCarrierPrefix = "cpa-gemini-carrier-v1:" -+ geminiClaudeCarrierNext = "next" -+ geminiClaudeCarrierPrevious = "previous" -+ geminiClaudeCarrierStandalone = "standalone" -+ geminiClaudeCarrierText = "text" -+ geminiClaudeCarrierFunction = "function" -+ geminiClaudeCarrierAny = "any" -+) - - type claudeSignatureTree = signature.ClaudeSignatureTree - -+func encodeGeminiClaudeCarrierSignature(rawSignature, direction, targetKind string) string { -+ rawSignature = strings.TrimSpace(rawSignature) -+ if rawSignature == "" { -+ return "" -+ } -+ return geminiClaudeCarrierPrefix + direction + ":" + targetKind + ":" + base64.RawStdEncoding.EncodeToString([]byte(rawSignature)) -+} -+ -+func decodeGeminiClaudeCarrierSignature(rawSignature string) (signatureValue, direction, targetKind string, marked, ok bool) { -+ rawSignature = strings.TrimSpace(rawSignature) -+ if !strings.HasPrefix(rawSignature, geminiClaudeCarrierPrefix) { -+ return rawSignature, "", "", false, true -+ } -+ marked = true -+ if len(rawSignature) > (signature.MaxGeminiThoughtSignatureLen*4/3)+1024 { -+ return "", "", "", true, false -+ } -+ fields := strings.SplitN(strings.TrimPrefix(rawSignature, geminiClaudeCarrierPrefix), ":", 3) -+ if len(fields) != 3 { -+ return "", "", "", true, false -+ } -+ direction, targetKind = fields[0], fields[1] -+ switch direction { -+ case geminiClaudeCarrierNext, geminiClaudeCarrierPrevious, geminiClaudeCarrierStandalone: -+ default: -+ return "", "", "", true, false -+ } -+ switch targetKind { -+ case geminiClaudeCarrierText, geminiClaudeCarrierFunction, geminiClaudeCarrierAny: -+ default: -+ return "", "", "", true, false -+ } -+ decoded, errDecode := base64.RawStdEncoding.DecodeString(fields[2]) -+ if errDecode != nil || len(decoded) == 0 || strings.HasPrefix(string(decoded), geminiClaudeCarrierPrefix) { -+ return "", "", "", true, false -+ } -+ blockKind := signature.SignatureBlockKindGeminiModelPart -+ if targetKind == geminiClaudeCarrierFunction { -+ blockKind = signature.SignatureBlockKindGeminiFunctionCall -+ } -+ normalized, compatible := signature.CompatibleSignatureForProviderBlock(signature.SignatureProviderGemini, string(decoded), blockKind) -+ if !compatible || signature.IsGeminiThoughtSignatureBypass(signature.SignaturePayloadWithoutProviderPrefix(normalized)) { -+ return "", "", "", true, false -+ } -+ return normalized, direction, targetKind, true, true -+} -+ -+func geminiClaudeSemanticTargetKind(block gjson.Result) string { -+ switch block.Get("type").String() { -+ case "text": -+ return geminiClaudeCarrierText -+ case "tool_use": -+ return geminiClaudeCarrierFunction -+ case "thinking": -+ if strings.TrimSpace(block.Get("thinking").String()) != "" { -+ return geminiClaudeCarrierText -+ } -+ } -+ return "" -+} -+ -+func geminiClaudeCarrierMatchesAdjacent(blocks []gjson.Result, index int, direction, targetKind string) bool { -+ step := 1 -+ if direction == geminiClaudeCarrierPrevious { -+ step = -1 -+ } -+ for adjacent := index + step; adjacent >= 0 && adjacent < len(blocks); adjacent += step { -+ if kind := geminiClaudeSemanticTargetKind(blocks[adjacent]); kind != "" { -+ return targetKind == geminiClaudeCarrierAny || targetKind == kind -+ } -+ if blocks[adjacent].Get("type").String() != "thinking" || strings.TrimSpace(blocks[adjacent].Get("thinking").String()) != "" { -+ return false -+ } -+ } -+ return false -+} -+ - // StripEmptySignatureThinkingBlocks removes thinking blocks whose signatures - // are empty or not valid Claude thinking signatures. These usually come from - // proxy-generated responses where no real Claude signature exists. -@@ -17,6 +112,93 @@ func StripEmptySignatureThinkingBlocks(payload []byte) []byte { - return signature.StripInvalidClaudeThinkingBlocks(payload, signature.ClaudeSignatureValidationOptions{PrefixOnly: true}) - } - -+// StripInvalidGeminiSignatureThinkingBlocks preserves only thinking carriers -+// whose signatures can be replayed to Gemini. Claude Code uses these carriers -+// to return provider-native signatures from prior translated responses. -+func StripInvalidGeminiSignatureThinkingBlocks(payload []byte) []byte { -+ messages := gjson.GetBytes(payload, "messages") -+ if !messages.IsArray() { -+ return payload -+ } -+ changed := false -+ messageItems := make([][]byte, 0, len(messages.Array())) -+ for _, message := range messages.Array() { -+ messageJSON := []byte(message.Raw) -+ content := message.Get("content") -+ if !content.IsArray() { -+ messageItems = append(messageItems, messageJSON) -+ continue -+ } -+ contentChanged := false -+ assistantMessage := strings.EqualFold(message.Get("role").String(), "assistant") -+ contentBlocks := content.Array() -+ contentItems := make([][]byte, 0, len(contentBlocks)) -+ pendingCarrierTargetKind := "" -+ for blockIndex, block := range contentBlocks { -+ if block.Get("type").String() == "thinking" { -+ rawSignature := strings.TrimSpace(block.Get("signature").String()) -+ thinkingText := strings.TrimSpace(block.Get("thinking").String()) -+ if rawSignature == "" && thinkingText != "" && (pendingCarrierTargetKind == geminiClaudeCarrierAny || pendingCarrierTargetKind == geminiClaudeCarrierText) { -+ pendingCarrierTargetKind = "" -+ contentItems = append(contentItems, []byte(block.Raw)) -+ continue -+ } -+ innerSignature, direction, targetKind, marked, okCarrier := decodeGeminiClaudeCarrierSignature(rawSignature) -+ blockKind := signature.SignatureBlockKindGeminiModelPart -+ if marked && targetKind == geminiClaudeCarrierFunction { -+ blockKind = signature.SignatureBlockKindGeminiFunctionCall -+ } -+ invalidMarkedPlacement := false -+ if marked { -+ switch direction { -+ case geminiClaudeCarrierNext, geminiClaudeCarrierPrevious: -+ invalidMarkedPlacement = !geminiClaudeCarrierMatchesAdjacent(contentBlocks, blockIndex, direction, targetKind) -+ case geminiClaudeCarrierStandalone: -+ invalidMarkedPlacement = thinkingText != "" && targetKind == geminiClaudeCarrierFunction -+ } -+ if thinkingText != "" && direction == geminiClaudeCarrierPrevious { -+ invalidMarkedPlacement = true -+ } -+ } -+ if !okCarrier || !assistantMessage || invalidMarkedPlacement { -+ pendingCarrierTargetKind = "" -+ contentChanged = true -+ continue -+ } -+ if !marked { -+ innerSignature = rawSignature -+ } -+ if _, ok := signature.CompatibleSignatureForProviderBlock(signature.SignatureProviderGemini, innerSignature, blockKind); !ok { -+ pendingCarrierTargetKind = "" -+ contentChanged = true -+ continue -+ } -+ if marked && direction == geminiClaudeCarrierNext { -+ pendingCarrierTargetKind = targetKind -+ } else { -+ pendingCarrierTargetKind = "" -+ } -+ } else { -+ pendingCarrierTargetKind = "" -+ } -+ contentItems = append(contentItems, []byte(block.Raw)) -+ } -+ if contentChanged { -+ messageJSON, _ = sjson.SetRawBytes(messageJSON, "content", translatorcommon.JoinRawArray(contentItems)) -+ changed = true -+ } -+ messageItems = append(messageItems, messageJSON) -+ } -+ if !changed { -+ return payload -+ } -+ updated, errSet := sjson.SetRawBytes(payload, "messages", translatorcommon.JoinRawArray(messageItems)) -+ if errSet != nil { -+ return payload -+ } -+ return updated -+} -+ - func StripInvalidBypassSignatureThinkingBlocks(payload []byte) []byte { - return signature.StripInvalidClaudeThinkingBlocks(payload, claudeBypassSignatureValidationOptions()) - } -diff --git a/internal/translator/antigravity/claude/signature_validation_test.go b/internal/translator/antigravity/claude/signature_validation_test.go -new file mode 100644 -index 00000000000..cb1132316b1 ---- /dev/null -+++ b/internal/translator/antigravity/claude/signature_validation_test.go -@@ -0,0 +1,84 @@ -+package claude -+ -+import ( -+ "testing" -+ -+ "github.com/tidwall/gjson" -+) -+ -+func TestGeminiClaudeCarrierSignatureRoundTrip(t *testing.T) { -+ validSignature := testGeminiEPrefixSignature(t) -+ for _, testCase := range []struct { -+ direction string -+ kind string -+ }{ -+ {direction: geminiClaudeCarrierNext, kind: geminiClaudeCarrierText}, -+ {direction: geminiClaudeCarrierPrevious, kind: geminiClaudeCarrierFunction}, -+ {direction: geminiClaudeCarrierStandalone, kind: geminiClaudeCarrierAny}, -+ } { -+ encoded := encodeGeminiClaudeCarrierSignature(validSignature, testCase.direction, testCase.kind) -+ decoded, direction, kind, marked, ok := decodeGeminiClaudeCarrierSignature(encoded) -+ if !marked || !ok || decoded != validSignature || direction != testCase.direction || kind != testCase.kind { -+ t.Fatalf("carrier round trip = (%q,%q,%q,%v,%v)", decoded, direction, kind, marked, ok) -+ } -+ } -+} -+ -+func TestStripInvalidGeminiSignatureThinkingBlocksPreservesMarkedNonEmptyThinking(t *testing.T) { -+ validSignature := testGeminiEPrefixSignature(t) -+ standalone := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierStandalone, geminiClaudeCarrierText) -+ nextFunction := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction) -+ invalidPrevious := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText) -+ input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"signed thought","signature":"` + standalone + `"},{"type":"thinking","thinking":"tool preface","signature":"` + nextFunction + `"},{"type":"tool_use","id":"tool-1","name":"run","input":{}},{"type":"thinking","thinking":"invalid backward","signature":"` + invalidPrevious + `"}]}]}`) -+ out := StripInvalidGeminiSignatureThinkingBlocks(input) -+ content := gjson.GetBytes(out, "messages.0.content").Array() -+ if len(content) != 3 || content[0].Get("signature").String() != standalone || content[1].Get("signature").String() != nextFunction || content[2].Get("type").String() != "tool_use" { -+ t.Fatalf("marked non-empty thinking validation changed carriers: %s", out) -+ } -+} -+ -+func TestStripInvalidGeminiSignatureThinkingBlocksDropsMismatchedDirectionalThinking(t *testing.T) { -+ validSignature := testGeminiEPrefixSignature(t) -+ nextFunction := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierNext, geminiClaudeCarrierFunction) -+ standaloneFunction := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierStandalone, geminiClaudeCarrierFunction) -+ input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"wrong next target","signature":"` + nextFunction + `"},{"type":"text","text":"visible"},{"type":"thinking","thinking":"wrong standalone target","signature":"` + standaloneFunction + `"}]}]}`) -+ out := StripInvalidGeminiSignatureThinkingBlocks(input) -+ content := gjson.GetBytes(out, "messages.0.content").Array() -+ if len(content) != 1 || content[0].Get("type").String() != "text" { -+ t.Fatalf("mismatched directional thinking was preserved: %s", out) -+ } -+} -+ -+func TestStripInvalidGeminiSignatureThinkingBlocksDropsLegacyRawCarrierFromUserMessage(t *testing.T) { -+ validSignature := testGeminiEPrefixSignature(t) -+ input := []byte(`{"messages":[{"role":"user","content":[{"type":"thinking","thinking":"","signature":"` + validSignature + `"},{"type":"text","text":"user text"}]},{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"` + validSignature + `"},{"type":"text","text":"assistant text"}]}]}`) -+ out := StripInvalidGeminiSignatureThinkingBlocks(input) -+ userContent := gjson.GetBytes(out, "messages.0.content").Array() -+ assistantContent := gjson.GetBytes(out, "messages.1.content").Array() -+ if len(userContent) != 1 || userContent[0].Get("type").String() != "text" { -+ t.Fatalf("legacy raw carrier survived user message: %s", out) -+ } -+ if len(assistantContent) != 2 || assistantContent[0].Get("signature").String() != validSignature { -+ t.Fatalf("assistant legacy carrier was not preserved: %s", out) -+ } -+} -+ -+func TestStripInvalidGeminiSignatureThinkingBlocks(t *testing.T) { -+ validSignature := testGeminiEPrefixSignature(t) -+ validCarrier := encodeGeminiClaudeCarrierSignature(validSignature, geminiClaudeCarrierPrevious, geminiClaudeCarrierText) -+ input := []byte(`{"messages":[{"role":"assistant","content":[{"type":"text","text":"first"},{"type":"thinking","thinking":"","signature":"` + validSignature + `"},{"type":"thinking","thinking":"","signature":"` + validCarrier + `"},{"type":"thinking","thinking":"","signature":"cpa-gemini-carrier-v1:previous:text:invalid"},{"type":"thinking","thinking":"","signature":"invalid"},{"type":"text","text":"last"}]}]}`) -+ out := StripInvalidGeminiSignatureThinkingBlocks(input) -+ content := gjson.GetBytes(out, "messages.0.content").Array() -+ if len(content) != 4 { -+ t.Fatalf("content count = %d, want 4; output=%s", len(content), out) -+ } -+ if got := content[1].Get("signature").String(); got != validSignature { -+ t.Fatalf("preserved signature = %q, want Gemini signature", got) -+ } -+ if got := content[2].Get("signature").String(); got != validCarrier { -+ t.Fatalf("preserved carrier = %q, want directional carrier", got) -+ } -+ if got := content[3].Get("text").String(); got != "last" { -+ t.Fatalf("last text = %q, want last", got) -+ } -+} -diff --git a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go -index 016fce04e45..a177ad9e4d9 100644 ---- a/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go -+++ b/internal/translator/antigravity/gemini/antigravity_gemini_request_test.go -@@ -40,7 +40,7 @@ func TestConvertGeminiRequestToAntigravity_ReplacesClientSignatureOnFunctionCall - } - } - --func TestConvertGeminiRequestToAntigravity_ReplacesClientSignatureOnTextPart(t *testing.T) { -+func TestConvertGeminiRequestToAntigravity_DropsIncompatibleClientSignatureOnTextPart(t *testing.T) { - validSignature := "abc123validSignature1234567890123456789012345678901234567890" - inputJSON := []byte(fmt.Sprintf(`{ - "model": "gemini-3-pro-preview", -@@ -55,16 +55,12 @@ func TestConvertGeminiRequestToAntigravity_ReplacesClientSignatureOnTextPart(t * - }`, validSignature)) - - output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) -- outputStr := string(output) -- -- sig := gjson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature").String() -- expectedSig := "skip_thought_signature_validator" -- if sig != expectedSig { -- t.Errorf("Expected thoughtSignature '%s', got '%s'", expectedSig, sig) -+ if signature := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature"); signature.Exists() { -+ t.Fatalf("incompatible text signature should be dropped, got %s", signature.Raw) - } - } - --func TestConvertGeminiRequestToAntigravity_AddsSkipSentinelToStringThoughtPart(t *testing.T) { -+func TestConvertGeminiRequestToAntigravity_LeavesUnsignedThoughtPartUnsigned(t *testing.T) { - inputJSON := []byte(`{ - "model": "gemini-3-pro-preview", - "contents": [ -@@ -78,12 +74,8 @@ func TestConvertGeminiRequestToAntigravity_AddsSkipSentinelToStringThoughtPart(t - }`) - - output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) -- outputStr := string(output) -- -- sig := gjson.Get(outputStr, "request.contents.0.parts.0.thoughtSignature").String() -- expectedSig := "skip_thought_signature_validator" -- if sig != expectedSig { -- t.Errorf("Expected thoughtSignature '%s', got '%s'", expectedSig, sig) -+ if signature := gjson.GetBytes(output, "request.contents.0.parts.0.thoughtSignature"); signature.Exists() { -+ t.Fatalf("unsigned thought should remain unsigned, got %s", signature.Raw) - } - } - -@@ -277,8 +269,7 @@ func testAntigravityGeminiClaudeSignature(t *testing.T) string { - return base64.StdEncoding.EncodeToString(payload) - } - --func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) { -- // Multiple functionCalls should all get skip_thought_signature_validator -+func TestConvertGeminiRequestToAntigravity_ParallelFunctionCallsOnlyFirstGetsSentinel(t *testing.T) { - inputJSON := []byte(`{ - "model": "gemini-3-pro-preview", - "contents": [ -@@ -293,19 +284,15 @@ func TestConvertGeminiRequestToAntigravity_ParallelFunctionCalls(t *testing.T) { - }`) - - output := ConvertGeminiRequestToAntigravity("gemini-3-pro-preview", inputJSON, false) -- outputStr := string(output) -- -- parts := gjson.Get(outputStr, "request.contents.0.parts").Array() -+ parts := gjson.GetBytes(output, "request.contents.0.parts").Array() - if len(parts) != 2 { - t.Fatalf("Expected 2 parts, got %d", len(parts)) - } -- -- expectedSig := "skip_thought_signature_validator" -- for i, part := range parts { -- sig := part.Get("thoughtSignature").String() -- if sig != expectedSig { -- t.Errorf("Part %d: Expected '%s', got '%s'", i, expectedSig, sig) -- } -+ if got := parts[0].Get("thoughtSignature").String(); got != signature.GeminiSkipThoughtSignatureValidator { -+ t.Fatalf("first call signature = %q, want sentinel", got) -+ } -+ if parts[1].Get("thoughtSignature").Exists() { -+ t.Fatalf("second parallel call should remain unsigned: %s", parts[1].Raw) - } - } - -diff --git a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go -index 58549f3c0a9..7dfad0eb721 100644 ---- a/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go -+++ b/internal/translator/antigravity/openai/responses/antigravity_openai-responses_request_test.go -@@ -137,6 +137,11 @@ func TestConvertOpenAIResponsesRequestToAntigravity_ClaudeReasoningDropsEmptyThi - } - - func testAntigravityResponsesClaudeSignature(t *testing.T) string { -+ t.Helper() -+ return testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6") -+} -+ -+func testAntigravityResponsesClaudeSignatureForModel(t *testing.T, model string) string { - t.Helper() - channelBlock := []byte{} - channelBlock = protowire.AppendTag(channelBlock, 1, protowire.VarintType) -@@ -144,7 +149,7 @@ func testAntigravityResponsesClaudeSignature(t *testing.T) string { - 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") -+ channelBlock = protowire.AppendString(channelBlock, model) - - container := []byte{} - container = protowire.AppendTag(container, 1, protowire.BytesType) -@@ -175,18 +180,88 @@ func firstByte(s string) string { - return s[:1] - } - --func TestConvertOpenAIResponsesRequestToAntigravity_GeminiReasoningUsesNativeVisibleSignaturePlacement(t *testing.T) { -+func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningDoesNotShiftLaterSignature(t *testing.T) { -+ rawSig1 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6") -+ rawSig2 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-opus-4-6") -+ expectedSig2, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSig2) -+ if !ok { -+ t.Fatal("second Claude signature should be compatible") -+ } -+ raw := []byte(`{ -+ "model":"claude-opus-4-6-thinking", -+ "input":[ -+ {"type":"reasoning","encrypted_content":"` + rawSig1 + `","summary":[]}, -+ {"role":"user","content":[{"type":"input_text","text":"boundary"}]}, -+ {"type":"reasoning","encrypted_content":"` + rawSig2 + `","summary":[{"type":"summary_text","text":"second reasoning"}]}, -+ {"role":"user","content":[{"type":"input_text","text":"continue"}]} -+ ] -+ }`) -+ out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false) -+ var thoughts []gjson.Result -+ for _, content := range gjson.GetBytes(out, "request.contents").Array() { -+ for _, part := range content.Get("parts").Array() { -+ if part.Get("thought").Bool() { -+ thoughts = append(thoughts, part) -+ } -+ } -+ } -+ if len(thoughts) != 1 { -+ t.Fatalf("thought count = %d, want only the non-empty reasoning item. Output: %s", len(thoughts), out) -+ } -+ if got := thoughts[0].Get("text").String(); got != "second reasoning" { -+ t.Fatalf("thought text = %q, want second reasoning. Output: %s", got, out) -+ } -+ if got := thoughts[0].Get("thoughtSignature").String(); got != expectedSig2 { -+ t.Fatalf("later thought received the wrong signature prefix/len = %q/%d, want %q/%d. Output: %s", firstByte(got), len(got), firstByte(expectedSig2), len(expectedSig2), out) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToAntigravity_EmptyClaudeReasoningBeforeFunctionDoesNotShiftLaterSignature(t *testing.T) { -+ rawSig1 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-sonnet-4-6") -+ rawSig2 := testAntigravityResponsesClaudeSignatureForModel(t, "claude-opus-4-6") -+ expectedSig2, ok := sigcompat.CompatibleAntigravityClaudeThinkingSignature(rawSig2) -+ if !ok { -+ t.Fatal("second Claude signature should be compatible") -+ } -+ raw := []byte(`{ -+ "model":"claude-opus-4-6-thinking", -+ "input":[ -+ {"type":"reasoning","encrypted_content":"` + rawSig1 + `","summary":[]}, -+ {"type":"function_call","call_id":"call-1","name":"run","arguments":"{}"}, -+ {"type":"function_call_output","call_id":"call-1","output":"ok"}, -+ {"type":"reasoning","encrypted_content":"` + rawSig2 + `","summary":[{"type":"summary_text","text":"second reasoning"}]}, -+ {"role":"user","content":[{"type":"input_text","text":"continue"}]} -+ ] -+ }`) -+ out := ConvertOpenAIResponsesRequestToAntigravity("claude-opus-4-6-thinking", raw, false) -+ var thoughts []gjson.Result -+ for _, content := range gjson.GetBytes(out, "request.contents").Array() { -+ for _, part := range content.Get("parts").Array() { -+ if part.Get("thought").Bool() { -+ thoughts = append(thoughts, part) -+ } -+ } -+ } -+ if len(thoughts) != 1 || thoughts[0].Get("text").String() != "second reasoning" { -+ t.Fatalf("later reasoning placement malformed. Output: %s", out) -+ } -+ if got := thoughts[0].Get("thoughtSignature").String(); got != expectedSig2 { -+ t.Fatalf("later thought received the wrong signature prefix/len = %q/%d, want %q/%d. Output: %s", firstByte(got), len(got), firstByte(expectedSig2), len(expectedSig2), out) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToAntigravity_GeminiReasoningUsesNativeThoughtSignaturePlacement(t *testing.T) { - sig := "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA" - raw := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"reasoning","encrypted_content":"gemini#` + sig + `","summary":[{"type":"summary_text","text":"reasoning summary"}]}]}`) - out := ConvertOpenAIResponsesRequestToAntigravity("gemini-3-flash-agent", raw, false) - parts := gjson.GetBytes(out, "request.contents.0.parts").Array() -- if len(parts) != 2 { -- t.Fatalf("parts length = %d, want 2. Output: %s", len(parts), out) -+ if len(parts) != 1 { -+ t.Fatalf("parts length = %d, want 1. Output: %s", len(parts), out) - } - if got := parts[0].Get("thought").Bool(); !got { - t.Fatalf("parts[0] should be thought. Output: %s", out) - } -- if got := parts[1].Get("thoughtSignature").String(); got != sig { -- t.Fatalf("parts[1].thoughtSignature = %q, want preserved Gemini signature. Output: %s", got, out) -+ if got := parts[0].Get("thoughtSignature").String(); got != sig { -+ t.Fatalf("parts[0].thoughtSignature = %q, want preserved Gemini signature. Output: %s", got, out) - } - } -diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go -index 81621a3f3c3..8ee3186a4c5 100644 ---- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go -+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request.go -@@ -36,9 +36,14 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte - - // Convert input messages to Gemini contents format - if input := root.Get("input"); input.Exists() && input.IsArray() { -- items := input.Array() -+ inputItems, hasGeminiCarrier := normalizeGeminiResponsesCarriers(input.Array()) -+ if hasGeminiCarrier { -+ useGeminiNativeReasoningLayout = true -+ } -+ items := pairOpenAIResponsesReasoningWithFunctionCalls(inputItems) - contentItems := make([][]byte, 0, len(items)) - functionNamesByCallID := make(map[string]string) -+ pendingFunctionCallIDs := make([]string, 0) - for _, item := range items { - if item.Get("type").String() == "function_call" { - callID := item.Get("call_id").String() -@@ -48,81 +53,15 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte - } - } - -- // Normalize consecutive function calls and outputs so each call is immediately followed by its response -- normalized := make([]gjson.Result, 0, len(items)) -- for i := 0; i < len(items); { -- item := items[i] -- itemType := item.Get("type").String() -- itemRole := item.Get("role").String() -- if itemType == "" && itemRole != "" { -- itemType = "message" -- } -- -- if itemType == "function_call" { -- var calls []gjson.Result -- var outputs []gjson.Result -- -- for i < len(items) { -- next := items[i] -- nextType := next.Get("type").String() -- nextRole := next.Get("role").String() -- if nextType == "" && nextRole != "" { -- nextType = "message" -- } -- if nextType != "function_call" { -- break -- } -- calls = append(calls, next) -- i++ -- } -- -- for i < len(items) { -- next := items[i] -- nextType := next.Get("type").String() -- nextRole := next.Get("role").String() -- if nextType == "" && nextRole != "" { -- nextType = "message" -- } -- if nextType != "function_call_output" { -- break -- } -- outputs = append(outputs, next) -- i++ -- } -- -- if len(calls) > 0 { -- outputMap := make(map[string]gjson.Result, len(outputs)) -- for _, outItem := range outputs { -- outputMap[outItem.Get("call_id").String()] = outItem -- } -- for _, call := range calls { -- normalized = append(normalized, call) -- callID := call.Get("call_id").String() -- if resp, ok := outputMap[callID]; ok { -- normalized = append(normalized, resp) -- delete(outputMap, callID) -- } -- } -- for _, outItem := range outputs { -- if _, ok := outputMap[outItem.Get("call_id").String()]; ok { -- normalized = append(normalized, outItem) -- } -- } -- continue -- } -- } -- -- if itemType == "function_call_output" { -- normalized = append(normalized, item) -- i++ -- continue -- } -- -- normalized = append(normalized, item) -- i++ -+ normalized := items -+ if useGeminiNativeReasoningLayout { -+ normalized = reorderOpenAIResponsesDetachedReasoning(normalized) - } -- -+ consumedFunctionOutputIndexes := make(map[int]bool) - for i := 0; i < len(normalized); i++ { -+ if consumedFunctionOutputIndexes[i] { -+ continue -+ } - item := normalized[i] - itemType := item.Get("type").String() - itemRole := item.Get("role").String() -@@ -133,6 +72,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte - switch itemType { - case "message": - if strings.EqualFold(itemRole, "system") || strings.EqualFold(itemRole, "developer") { -+ pendingFunctionCallIDs = nil - if contentArray := item.Get("content"); contentArray.Exists() { - if contentArray.IsArray() { - contentArray.ForEach(func(_, contentItem gjson.Result) bool { -@@ -150,6 +90,10 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte - continue - } - -+ if _, isAssistantOutput := openAIResponsesAssistantVisibleText(item); !isAssistantOutput { -+ pendingFunctionCallIDs = nil -+ } -+ - // Handle regular messages - // Note: In Responses format, model outputs may appear as content items with type "output_text" - // even when the message.role is "user". We split such items into distinct Gemini messages -@@ -289,75 +233,73 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte - } - - case "function_call": -- // Handle function calls - convert to model message with functionCall -- name := util.SanitizeFunctionName(item.Get("name").String()) -- arguments := item.Get("arguments").String() -- -- modelContent := []byte(`{"role":"model","parts":[]}`) -- functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`) -- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", name) -- functionCall, _ = sjson.SetBytes(functionCall, "thoughtSignature", geminiResponsesThoughtSignature) -- functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", item.Get("call_id").String()) -- -- // Parse arguments JSON string and set as args object -- if arguments != "" { -- argsResult := gjson.Parse(arguments) -- functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsResult.Raw)) -+ signature := geminiResponsesThoughtSignature -+ if rawSignature := strings.TrimSpace(item.Get("_cpa_reasoning_signature").String()); rawSignature != "" { -+ signature = openAIResponsesGeminiThoughtSignature(rawSignature) -+ } -+ if thoughtText := item.Get("_cpa_reasoning_summary").String(); thoughtText != "" { -+ contentItems = append(contentItems, buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText, item, signature)) -+ } else if !useGeminiNativeReasoningLayout && strings.TrimSpace(item.Get("_cpa_reasoning_signature").String()) != "" { -+ contentItems = append(contentItems, buildOpenAIResponsesEmptyReasoningFunctionCallModelContent(item, signature)) -+ } else { -+ contentItems = append(contentItems, buildOpenAIResponsesFunctionCallModelContent(item, signature)) -+ } -+ if callID := strings.TrimSpace(item.Get("call_id").String()); callID != "" { -+ pendingFunctionCallIDs = append(pendingFunctionCallIDs, callID) - } -- -- modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray([][]byte{functionCall})) -- contentItems = append(contentItems, modelContent) - - case "function_call_output": -- // Handle function call outputs - convert to function message with functionResponse -- callID := item.Get("call_id").String() -- // Use .Raw to preserve the JSON encoding (includes quotes for strings) -- outputRaw := item.Get("output").Str -- -- functionContent := []byte(`{"role":"function","parts":[]}`) -- functionResponse := []byte(`{"functionResponse":{"name":"","response":{}}}`) -- -- functionName := "unknown" -- if matchedName, ok := functionNamesByCallID[callID]; ok { -- functionName = matchedName -+ orderedOutputs, consumedIndexes, remainingPending := collectOpenAIResponsesFunctionCallOutputs(normalized, i, pendingFunctionCallIDs) -+ pendingFunctionCallIDs = remainingPending -+ for consumedIndex := range consumedIndexes { -+ consumedFunctionOutputIndexes[consumedIndex] = true - } -- functionName = util.SanitizeFunctionName(functionName) -- -- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.name", functionName) -- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.id", callID) -- -- // Set the raw JSON output directly (preserves string encoding) -- if outputRaw != "" && outputRaw != "null" { -- output := gjson.Parse(outputRaw) -- if output.Type == gjson.JSON && json.Valid([]byte(output.Raw)) { -- functionResponse, _ = sjson.SetRawBytes(functionResponse, "functionResponse.response.result", []byte(output.Raw)) -- } else { -- functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.response.result", outputRaw) -- } -+ responseParts := make([][]byte, 0, len(orderedOutputs)) -+ for _, output := range orderedOutputs { -+ responseParts = append(responseParts, buildOpenAIResponsesFunctionResponsePart(output, functionNamesByCallID)) -+ } -+ if len(responseParts) > 0 { -+ contentItems = append(contentItems, geminiContent("user", responseParts)) - } -- functionContent, _ = sjson.SetRawBytes(functionContent, "parts", translatorcommon.JoinRawArray([][]byte{functionResponse})) -- contentItems = append(contentItems, functionContent) - - case "reasoning": - thoughtText := item.Get("summary.0.text").String() -- signature := openAIResponsesGeminiThoughtSignature(item.Get("encrypted_content").String()) -+ rawSignature := item.Get("encrypted_content").String() -+ carrierDirection := geminiResponsesCarrierDirection(item) -+ carrierTarget := geminiResponsesCarrierTarget(item) -+ if strings.TrimSpace(rawSignature) == "" && i+1 < len(normalized) { -+ nextReasoning := normalized[i+1] -+ if nextReasoning.Get("type").String() == "reasoning" && strings.Contains(nextReasoning.Get("id").String(), "_detached_after_") && strings.TrimSpace(nextReasoning.Get("summary.0.text").String()) == "" && strings.TrimSpace(nextReasoning.Get("encrypted_content").String()) != "" { -+ rawSignature = nextReasoning.Get("encrypted_content").String() -+ i++ -+ } -+ } -+ signature := openAIResponsesGeminiThoughtSignature(rawSignature) - - visibleText := "" - if useGeminiNativeReasoningLayout && i+1 < len(normalized) { - next := normalized[i+1] -- if visible, ok := openAIResponsesAssistantVisibleText(next); ok { -+ canBindText := (carrierDirection == "" || carrierDirection == geminiResponsesCarrierNext) && (carrierTarget == "" || carrierTarget == geminiResponsesCarrierText || carrierTarget == geminiResponsesCarrierAny) -+ canBindFunction := (carrierDirection == "" || carrierDirection == geminiResponsesCarrierNext) && (carrierTarget == "" || carrierTarget == geminiResponsesCarrierFunction || carrierTarget == geminiResponsesCarrierAny) -+ if visible, ok := openAIResponsesAssistantVisibleText(next); ok && canBindText { - visibleText = visible - i++ -+ } else if next.Get("type").String() == "function_call" && canBindFunction && strings.TrimSpace(next.Get("_cpa_reasoning_signature").String()) == "" && signature != geminiResponsesThoughtSignature { -+ contentItems = append(contentItems, buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText, next, signature)) -+ if callID := strings.TrimSpace(next.Get("call_id").String()); callID != "" { -+ pendingFunctionCallIDs = append(pendingFunctionCallIDs, callID) -+ } -+ i++ -+ continue - } - } - -- modelContent := buildOpenAIResponsesReasoningModelContent(thoughtText, visibleText, signature, useGeminiNativeReasoningLayout) -- contentItems = append(contentItems, modelContent) -+ if modelContent := buildOpenAIResponsesReasoningModelContent(thoughtText, visibleText, signature, useGeminiNativeReasoningLayout); len(modelContent) > 0 { -+ contentItems = append(contentItems, modelContent) -+ } - } - } -- if len(contentItems) > 0 && shouldStripTrailingOpenAIResponsesModelPrefill(gjson.ParseBytes(contentItems[len(contentItems)-1])) { -- contentItems = contentItems[:len(contentItems)-1] -- } -+ contentItems = coalesceAdjacentOpenAIResponsesModelContents(contentItems) - out = translatorcommon.SetRawArrayItems(out, "contents", contentItems) - } else if input.Exists() && input.Type == gjson.String { - // Simple string input conversion to user message. -@@ -447,7 +389,10 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte - - result := out - result = common.AttachDefaultSafetySettings(result, "safetySettings") -- return result -+ if useGeminiNativeReasoningLayout { -+ result = sigcompat.SanitizeGeminiRequestThoughtSignatures(result, "contents") -+ } -+ return stripTrailingOpenAIResponsesModelPrefill(result) - } - - func geminiContent(role string, parts [][]byte) []byte { -@@ -457,12 +402,64 @@ func geminiContent(role string, parts [][]byte) []byte { - return content - } - -+func coalesceAdjacentOpenAIResponsesModelContents(contents [][]byte) [][]byte { -+ coalesced := make([][]byte, 0, len(contents)) -+ for _, content := range contents { -+ contentResult := gjson.ParseBytes(content) -+ if !strings.EqualFold(strings.TrimSpace(contentResult.Get("role").String()), "model") || len(coalesced) == 0 { -+ coalesced = append(coalesced, content) -+ continue -+ } -+ lastIndex := len(coalesced) - 1 -+ lastResult := gjson.ParseBytes(coalesced[lastIndex]) -+ if !strings.EqualFold(strings.TrimSpace(lastResult.Get("role").String()), "model") { -+ coalesced = append(coalesced, content) -+ continue -+ } -+ merged := coalesced[lastIndex] -+ parts := contentResult.Get("parts") -+ if !parts.IsArray() { -+ coalesced = append(coalesced, content) -+ continue -+ } -+ parts.ForEach(func(_, part gjson.Result) bool { -+ merged, _ = sjson.SetRawBytes(merged, "parts.-1", []byte(part.Raw)) -+ return true -+ }) -+ coalesced[lastIndex] = merged -+ } -+ return coalesced -+} -+ - func geminiSystemInstruction(parts [][]byte) []byte { - systemInstruction := []byte(`{"parts":[]}`) - systemInstruction, _ = sjson.SetRawBytes(systemInstruction, "parts", translatorcommon.JoinRawArray(parts)) - return systemInstruction - } - -+func stripTrailingOpenAIResponsesModelPrefill(payload []byte) []byte { -+ contents := gjson.GetBytes(payload, "contents") -+ if !contents.IsArray() { -+ return payload -+ } -+ contentArray := contents.Array() -+ if len(contentArray) == 0 || !shouldStripTrailingOpenAIResponsesModelPrefill(contentArray[len(contentArray)-1]) { -+ return payload -+ } -+ items := make([][]byte, 0, len(contentArray)-1) -+ for _, content := range contentArray[:len(contentArray)-1] { -+ items = append(items, []byte(content.Raw)) -+ } -+ if len(items) == 0 { -+ updated, errSet := sjson.SetRawBytes(payload, "contents", []byte("[]")) -+ if errSet == nil { -+ return updated -+ } -+ return payload -+ } -+ return translatorcommon.SetRawArrayItems(payload, "contents", items) -+} -+ - func shouldStripTrailingOpenAIResponsesModelPrefill(lastContent gjson.Result) bool { - if lastContent.Get("role").String() != "model" { - return false -@@ -472,7 +469,7 @@ func shouldStripTrailingOpenAIResponsesModelPrefill(lastContent gjson.Result) bo - return false - } - for _, part := range parts.Array() { -- if part.Get("thought").Bool() { -+ if part.Get("thought").Bool() || part.Get("functionCall").Exists() || strings.TrimSpace(part.Get("thoughtSignature").String()) != "" { - return false - } - } -@@ -550,17 +547,287 @@ func openAIResponsesAssistantVisibleText(item gjson.Result) (string, bool) { - return strings.Join(textParts, "\n"), true - } - --func buildOpenAIResponsesReasoningModelContent(thoughtText, visibleText, signature string, useGeminiNativeReasoningLayout bool) []byte { -+func pairOpenAIResponsesReasoningWithFunctionCalls(items []gjson.Result) []gjson.Result { -+ isDetachedCarrier := isOpenAIResponsesDetachedCarrier -+ postCallSignature := make(map[int]string) -+ postCallCarrier := make(map[int]bool) -+ consumedPostCallCarrier := make(map[int]bool) -+ for groupStart := 0; groupStart < len(items); { -+ if items[groupStart].Get("type").String() != "function_call" && !isDetachedCarrier(items[groupStart]) { -+ groupStart++ -+ continue -+ } -+ groupEnd := groupStart -+ hasFunctionCall := false -+ for groupEnd < len(items) && (items[groupEnd].Get("type").String() == "function_call" || isDetachedCarrier(items[groupEnd])) { -+ hasFunctionCall = hasFunctionCall || items[groupEnd].Get("type").String() == "function_call" -+ groupEnd++ -+ } -+ if !hasFunctionCall || groupEnd >= len(items) || items[groupEnd].Get("type").String() != "function_call_output" { -+ groupStart = groupEnd -+ continue -+ } -+ outputEnd := groupEnd -+ for outputEnd < len(items) && items[outputEnd].Get("type").String() == "function_call_output" { -+ outputEnd++ -+ } -+ // A run beginning with a carrier uses leading-carrier semantics. A run -+ // beginning with a call uses post-call semantics. This preserves both -+ // carrier,call,carrier,call and call,carrier,call,carrier histories. -+ if items[groupStart].Get("type").String() == "function_call" { -+ for callIndex := groupStart; callIndex < groupEnd; callIndex++ { -+ item := items[callIndex] -+ if item.Get("type").String() != "function_call" || strings.TrimSpace(item.Get("_cpa_reasoning_signature").String()) != "" || callIndex+1 >= groupEnd || !isDetachedCarrier(items[callIndex+1]) { -+ continue -+ } -+ carrierDirection := geminiResponsesCarrierDirection(items[callIndex+1]) -+ carrierTarget := geminiResponsesCarrierTarget(items[callIndex+1]) -+ if carrierDirection != "" && (carrierDirection != geminiResponsesCarrierPrevious || (carrierTarget != geminiResponsesCarrierFunction && carrierTarget != geminiResponsesCarrierAny)) { -+ continue -+ } -+ carrierEnd := callIndex + 1 -+ for carrierEnd < groupEnd && isDetachedCarrier(items[carrierEnd]) { -+ postCallCarrier[carrierEnd] = true -+ carrierEnd++ -+ } -+ callID := strings.TrimSpace(item.Get("call_id").String()) -+ if callID == "" { -+ continue -+ } -+ for outputIndex := groupEnd; outputIndex < outputEnd; outputIndex++ { -+ if strings.TrimSpace(items[outputIndex].Get("call_id").String()) == callID { -+ postCallSignature[callIndex] = strings.TrimSpace(items[callIndex+1].Get("encrypted_content").String()) -+ consumedPostCallCarrier[callIndex+1] = true -+ break -+ } -+ } -+ } -+ } -+ groupStart = outputEnd -+ } -+ -+ paired := make([]gjson.Result, 0, len(items)) -+ for index := 0; index < len(items); index++ { -+ item := items[index] -+ if signature := postCallSignature[index]; signature != "" { -+ functionCall := []byte(item.Raw) -+ functionCall, _ = sjson.SetBytes(functionCall, "_cpa_reasoning_signature", signature) -+ paired = append(paired, gjson.ParseBytes(functionCall)) -+ continue -+ } -+ if consumedPostCallCarrier[index] { -+ continue -+ } -+ carrierDirection := geminiResponsesCarrierDirection(item) -+ carrierTarget := geminiResponsesCarrierTarget(item) -+ canBindFollowingCall := carrierDirection == "" || (carrierDirection == geminiResponsesCarrierNext && (carrierTarget == geminiResponsesCarrierFunction || carrierTarget == geminiResponsesCarrierAny)) -+ if item.Get("type").String() == "reasoning" && !postCallCarrier[index] && canBindFollowingCall && !strings.Contains(item.Get("id").String(), "_detached_after_") && index+1 < len(items) && items[index+1].Get("type").String() == "function_call" { -+ rawSignature := strings.TrimSpace(item.Get("encrypted_content").String()) -+ if rawSignature != "" { -+ functionCall := []byte(items[index+1].Raw) -+ functionCall, _ = sjson.SetBytes(functionCall, "_cpa_reasoning_signature", rawSignature) -+ if summary := item.Get("summary.0.text").String(); summary != "" { -+ functionCall, _ = sjson.SetBytes(functionCall, "_cpa_reasoning_summary", summary) -+ } -+ paired = append(paired, gjson.ParseBytes(functionCall)) -+ index++ -+ continue -+ } -+ } -+ paired = append(paired, item) -+ } -+ return paired -+} -+ -+func reorderOpenAIResponsesDetachedReasoning(items []gjson.Result) []gjson.Result { -+ reordered := make([]gjson.Result, 0, len(items)) -+ for itemIndex, item := range items { -+ isReasoningCarrier := isOpenAIResponsesDetachedCarrier(item) -+ markedDetached := strings.Contains(item.Get("id").String(), "_detached_after_") -+ if isReasoningCarrier && len(reordered) > 0 { -+ previous := reordered[len(reordered)-1] -+ previousType := previous.Get("type").String() -+ if previousType == "" && previous.Get("role").String() != "" { -+ previousType = "message" -+ } -+ isAssistantMessage := false -+ if previousType == "message" { -+ _, isAssistantMessage = openAIResponsesAssistantVisibleText(previous) -+ } -+ -+ direction := geminiResponsesCarrierDirection(item) -+ targetKind := geminiResponsesCarrierTarget(item) -+ if direction != "" { -+ alreadyPairedText := false -+ alreadyPairedFunction := false -+ if len(reordered) > 1 { -+ prior := reordered[len(reordered)-2] -+ priorDirection := geminiResponsesCarrierDirection(prior) -+ priorTarget := geminiResponsesCarrierTarget(prior) -+ priorBindsFollowing := isOpenAIResponsesDetachedCarrier(prior) && (priorDirection == geminiResponsesCarrierNext || priorDirection == geminiResponsesCarrierPrevious) -+ alreadyPairedText = priorBindsFollowing && (priorTarget == geminiResponsesCarrierText || priorTarget == geminiResponsesCarrierAny) -+ alreadyPairedFunction = priorBindsFollowing && (priorTarget == geminiResponsesCarrierFunction || priorTarget == geminiResponsesCarrierAny) -+ } -+ bindPreviousMessage := direction == geminiResponsesCarrierPrevious && (targetKind == geminiResponsesCarrierText || targetKind == geminiResponsesCarrierAny) && isAssistantMessage && !alreadyPairedText -+ bindPreviousFunction := direction == geminiResponsesCarrierPrevious && (targetKind == geminiResponsesCarrierFunction || targetKind == geminiResponsesCarrierAny) && previousType == "function_call" && strings.TrimSpace(previous.Get("_cpa_reasoning_signature").String()) == "" && !alreadyPairedFunction -+ if bindPreviousMessage || bindPreviousFunction { -+ movedItemJSON, _ := sjson.SetBytes([]byte(item.Raw), geminiResponsesCarrierDirectionField, geminiResponsesCarrierNext) -+ reordered[len(reordered)-1] = gjson.ParseBytes(movedItemJSON) -+ reordered = append(reordered, previous) -+ continue -+ } -+ reordered = append(reordered, item) -+ continue -+ } -+ -+ if isAssistantMessage && !markedDetached && itemIndex+1 < len(items) { -+ _, nextIsAssistantMessage := openAIResponsesAssistantVisibleText(items[itemIndex+1]) -+ isAssistantMessage = !nextIsAssistantMessage -+ } -+ alreadyPaired := false -+ if len(reordered) > 1 { -+ prior := reordered[len(reordered)-2] -+ alreadyPaired = isOpenAIResponsesDetachedCarrier(prior) && strings.Contains(prior.Get("id").String(), "_detached_after_") -+ } -+ if !alreadyPaired && (isAssistantMessage || (markedDetached && previousType == "function_call" && strings.TrimSpace(previous.Get("_cpa_reasoning_signature").String()) == "")) { -+ reordered[len(reordered)-1] = item -+ reordered = append(reordered, previous) -+ continue -+ } -+ } -+ reordered = append(reordered, item) -+ } -+ return reordered -+} -+ -+func buildOpenAIResponsesFunctionCallPart(item gjson.Result, signature string) []byte { -+ name := util.SanitizeFunctionName(item.Get("name").String()) -+ arguments := item.Get("arguments").String() -+ functionCall := []byte(`{"functionCall":{"name":"","args":{}}}`) -+ functionCall, _ = sjson.SetBytes(functionCall, "functionCall.name", name) -+ functionCall, _ = sjson.SetBytes(functionCall, "thoughtSignature", signature) -+ functionCall, _ = sjson.SetBytes(functionCall, "functionCall.id", item.Get("call_id").String()) -+ if arguments != "" { -+ argsResult := gjson.Parse(arguments) -+ functionCall, _ = sjson.SetRawBytes(functionCall, "functionCall.args", []byte(argsResult.Raw)) -+ } -+ return functionCall -+} -+ -+func buildOpenAIResponsesFunctionResponsePart(item gjson.Result, functionNamesByCallID map[string]string) []byte { -+ callID := item.Get("call_id").String() -+ functionName := "unknown" -+ if matchedName, ok := functionNamesByCallID[callID]; ok { -+ functionName = matchedName -+ } -+ functionResponse := []byte(`{"functionResponse":{"name":"","response":{}}}`) -+ functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.name", util.SanitizeFunctionName(functionName)) -+ functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.id", callID) -+ -+ outputRaw := item.Get("output").Str -+ if outputRaw != "" && outputRaw != "null" { -+ output := gjson.Parse(outputRaw) -+ if output.Type == gjson.JSON && json.Valid([]byte(output.Raw)) { -+ functionResponse, _ = sjson.SetRawBytes(functionResponse, "functionResponse.response.result", []byte(output.Raw)) -+ } else { -+ functionResponse, _ = sjson.SetBytes(functionResponse, "functionResponse.response.result", outputRaw) -+ } -+ } -+ return functionResponse -+} -+ -+func collectOpenAIResponsesFunctionCallOutputs(items []gjson.Result, start int, pendingCallIDs []string) ([]gjson.Result, map[int]bool, []string) { -+ end := start + 1 -+ for end < len(items) && items[end].Get("type").String() == "function_call_output" { -+ end++ -+ } -+ outputs := items[start:end] -+ ordered, remainingPending := orderOpenAIResponsesFunctionCallOutputs(outputs, pendingCallIDs) -+ consumed := make(map[int]bool, len(outputs)) -+ for itemIndex := start; itemIndex < end; itemIndex++ { -+ consumed[itemIndex] = true -+ } -+ return ordered, consumed, remainingPending -+} -+ -+func orderOpenAIResponsesFunctionCallOutputs(outputs []gjson.Result, pendingCallIDs []string) ([]gjson.Result, []string) { -+ ordered := make([]gjson.Result, 0, len(outputs)) -+ used := make([]bool, len(outputs)) -+ remainingPending := make([]string, 0, len(pendingCallIDs)) -+ for _, pendingID := range pendingCallIDs { -+ match := -1 -+ for outputIndex, output := range outputs { -+ if !used[outputIndex] && output.Get("call_id").String() == pendingID { -+ match = outputIndex -+ break -+ } -+ } -+ if match < 0 { -+ remainingPending = append(remainingPending, pendingID) -+ continue -+ } -+ used[match] = true -+ ordered = append(ordered, outputs[match]) -+ } -+ for outputIndex, output := range outputs { -+ if !used[outputIndex] { -+ ordered = append(ordered, output) -+ } -+ } -+ return ordered, remainingPending -+} -+ -+func buildOpenAIResponsesFunctionCallModelContent(item gjson.Result, signature string) []byte { - modelContent := []byte(`{"role":"model","parts":[]}`) -- if useGeminiNativeReasoningLayout { -+ modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray([][]byte{buildOpenAIResponsesFunctionCallPart(item, signature)})) -+ return modelContent -+} -+ -+func buildOpenAIResponsesEmptyReasoningFunctionCallModelContent(item gjson.Result, signature string) []byte { -+ thought := []byte(`{"text":"","thought":true,"thoughtSignature":""}`) -+ thought, _ = sjson.SetBytes(thought, "thoughtSignature", signature) -+ parts := [][]byte{thought, buildOpenAIResponsesFunctionCallPart(item, signature)} -+ modelContent := []byte(`{"role":"model","parts":[]}`) -+ modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray(parts)) -+ return modelContent -+} -+ -+func buildOpenAIResponsesReasoningFunctionCallModelContent(thoughtText string, item gjson.Result, signature string) []byte { -+ parts := make([][]byte, 0, 2) -+ if thoughtText != "" { - thought := []byte(`{"text":"","thought":true}`) - thought, _ = sjson.SetBytes(thought, "text", thoughtText) -- modelContent, _ = sjson.SetRawBytes(modelContent, "parts.-1", thought) -+ parts = append(parts, thought) -+ } -+ parts = append(parts, buildOpenAIResponsesFunctionCallPart(item, signature)) -+ modelContent := []byte(`{"role":"model","parts":[]}`) -+ modelContent, _ = sjson.SetRawBytes(modelContent, "parts", translatorcommon.JoinRawArray(parts)) -+ return modelContent -+} - -- visible := []byte(`{"text":"","thoughtSignature":""}`) -- visible, _ = sjson.SetBytes(visible, "text", visibleText) -- visible, _ = sjson.SetBytes(visible, "thoughtSignature", signature) -- modelContent, _ = sjson.SetRawBytes(modelContent, "parts.-1", visible) -+func buildOpenAIResponsesReasoningModelContent(thoughtText, visibleText, signature string, useGeminiNativeReasoningLayout bool) []byte { -+ modelContent := []byte(`{"role":"model","parts":[]}`) -+ if useGeminiNativeReasoningLayout { -+ if thoughtText == "" && visibleText == "" { -+ carrier := []byte(`{"text":"","thoughtSignature":""}`) -+ carrier, _ = sjson.SetBytes(carrier, "thoughtSignature", signature) -+ modelContent, _ = sjson.SetRawBytes(modelContent, "parts.-1", carrier) -+ return modelContent -+ } -+ if thoughtText != "" { -+ thought := []byte(`{"text":"","thought":true}`) -+ thought, _ = sjson.SetBytes(thought, "text", thoughtText) -+ if visibleText == "" { -+ thought, _ = sjson.SetBytes(thought, "thoughtSignature", signature) -+ } -+ modelContent, _ = sjson.SetRawBytes(modelContent, "parts.-1", thought) -+ } -+ if visibleText != "" { -+ visible := []byte(`{"text":"","thoughtSignature":""}`) -+ visible, _ = sjson.SetBytes(visible, "text", visibleText) -+ visible, _ = sjson.SetBytes(visible, "thoughtSignature", signature) -+ modelContent, _ = sjson.SetRawBytes(modelContent, "parts.-1", visible) -+ } - return modelContent - } - -diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go -index bd85ad9807a..a22f250337d 100644 ---- a/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go -+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_request_test.go -@@ -2,13 +2,557 @@ package responses - - import ( - "encoding/base64" -+ "strings" - "testing" - -+ internalsignature "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" - "github.com/tidwall/gjson" - ) - - const testResponsesGeminiThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA" - -+func TestReorderOpenAIResponsesDetachedReasoningDoesNotCrossUserMessage(t *testing.T) { -+ items := gjson.Parse(`[ -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"next"}]}, -+ {"id":"rs_test_detached_after_1","type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"} -+ ]`).Array() -+ reordered := reorderOpenAIResponsesDetachedReasoning(items) -+ if got := reordered[0].Get("role").String(); got != "user" { -+ t.Fatalf("detached reasoning crossed user boundary: first role=%q", got) -+ } -+ if got := reordered[1].Get("type").String(); got != "reasoning" { -+ t.Fatalf("item 1 = %q, want reasoning", got) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_ReattachesReasoningAndSignatureToFunctionCall(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"run"}]}, -+ {"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[{"type":"summary_text","text":"hidden thought"}]}, -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"true\"}"}, -+ {"type":"function_call_output","call_id":"call-1","output":"ok"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ parts := gjson.GetBytes(result, "contents.1.parts").Array() -+ if len(parts) != 2 || !parts[0].Get("thought").Bool() { -+ t.Fatalf("reasoning/function parts malformed: %s", result) -+ } -+ if got := parts[1].Get("functionCall.name").String(); got != "run_command" { -+ t.Fatalf("function name = %q; result=%s", got, result) -+ } -+ if got := parts[1].Get("thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("function signature = %q, want %q; result=%s", got, testResponsesGeminiThoughtSignature, result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_SyntheticParallelCallsOnlyFirstGetsSentinel(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"one\"}"}, -+ {"type":"function_call","call_id":"call-2","name":"run_command","arguments":"{\"command\":\"two\"}"} -+ ] -+ }` -+ -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ parts := gjson.GetBytes(result, "contents.0.parts").Array() -+ if len(parts) != 2 { -+ t.Fatalf("parts = %d, want 2 parallel calls; result=%s", len(parts), result) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != internalsignature.GeminiSkipThoughtSignatureValidator { -+ t.Fatalf("first synthetic call signature = %q, want sentinel; result=%s", got, result) -+ } -+ if signature := parts[1].Get("thoughtSignature"); signature.Exists() { -+ t.Fatalf("second synthetic sibling should remain unsigned; result=%s", result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_NativeParallelCallsPreserveUnsignedSibling(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"run twice"}]}, -+ {"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"one\"}"}, -+ {"type":"function_call","call_id":"call-2","name":"run_command","arguments":"{\"command\":\"two\"}"} -+ ] -+ }` -+ -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ var calls []gjson.Result -+ for _, content := range gjson.GetBytes(result, "contents").Array() { -+ for _, part := range content.Get("parts").Array() { -+ if part.Get("functionCall").Exists() { -+ calls = append(calls, part) -+ } -+ } -+ } -+ if len(calls) != 2 { -+ t.Fatalf("calls = %d, want 2; result=%s", len(calls), result) -+ } -+ if got := calls[0].Get("thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("first call signature = %q, want native signature; result=%s", got, result) -+ } -+ if signature := calls[1].Get("thoughtSignature"); signature.Exists() { -+ t.Fatalf("native unsigned sibling should remain unsigned; result=%s", result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_PreservesMultipleLeadingToolSignatures(t *testing.T) { -+ secondRaw, errDecode := base64.StdEncoding.DecodeString(testResponsesGeminiThoughtSignature) -+ if errDecode != nil { -+ t.Fatal(errDecode) -+ } -+ secondRaw[len(secondRaw)-1] ^= 1 -+ secondSignature := base64.StdEncoding.EncodeToString(secondRaw) -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"run twice"}]}, -+ {"id":"rs_before_1","type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"one\"}"}, -+ {"id":"rs_before_2","type":"reasoning","encrypted_content":"` + secondSignature + `","summary":[]}, -+ {"type":"function_call","call_id":"call-2","name":"run_command","arguments":"{\"command\":\"two\"}"}, -+ {"type":"function_call_output","call_id":"call-1","output":"one"}, -+ {"type":"function_call_output","call_id":"call-2","output":"two"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ var signatures, sequence []string -+ for _, content := range gjson.GetBytes(result, "contents").Array() { -+ for _, part := range content.Get("parts").Array() { -+ if part.Get("functionCall").Exists() { -+ signatures = append(signatures, part.Get("thoughtSignature").String()) -+ sequence = append(sequence, "call:"+part.Get("functionCall.id").String()) -+ } -+ if part.Get("functionResponse").Exists() { -+ sequence = append(sequence, "output:"+part.Get("functionResponse.id").String()) -+ } -+ } -+ } -+ if len(signatures) != 2 || signatures[0] != testResponsesGeminiThoughtSignature || signatures[1] != secondSignature { -+ t.Fatalf("tool signatures = %v; result=%s", signatures, result) -+ } -+ if got := strings.Join(sequence, ","); got != "call:call-1,call:call-2,output:call-1,output:call-2" { -+ t.Fatalf("parallel tool call/output sequence = %q; result=%s", got, result) -+ } -+ if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(result); errValidate != nil { -+ t.Fatalf("parallel tool history is invalid: %v; result=%s", errValidate, result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_GroupsReversedParallelToolOutputs(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"one\"}"}, -+ {"type":"function_call","call_id":"call-2","name":"run_command","arguments":"{\"command\":\"two\"}"}, -+ {"type":"function_call_output","call_id":"call-2","output":"two"}, -+ {"type":"function_call_output","call_id":"call-1","output":"one"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ if errValidate := internalsignature.ValidateGeminiFunctionCallPairing(result); errValidate != nil { -+ t.Fatalf("parallel tool history is invalid: %v; result=%s", errValidate, result) -+ } -+ contents := gjson.GetBytes(result, "contents").Array() -+ if len(contents) != 2 || contents[0].Get("role").String() != "model" || contents[1].Get("role").String() != "user" { -+ t.Fatalf("parallel tool roles malformed; result=%s", result) -+ } -+ responses := contents[1].Get("parts").Array() -+ if len(responses) != 2 { -+ t.Fatalf("function response count = %d, want 2; result=%s", len(responses), result) -+ } -+ if got := responses[0].Get("functionResponse.id").String(); got != "call-1" { -+ t.Fatalf("first function response = %q, want call-1; result=%s", got, result) -+ } -+ if got := responses[0].Get("functionResponse.response.result").String(); got != "one" { -+ t.Fatalf("first function result = %q, want one; result=%s", got, result) -+ } -+ if got := responses[1].Get("functionResponse.id").String(); got != "call-2" { -+ t.Fatalf("second function response = %q, want call-2; result=%s", got, result) -+ } -+ if got := responses[1].Get("functionResponse.response.result").String(); got != "two" { -+ t.Fatalf("second function result = %q, want two; result=%s", got, result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_GroupsNonContiguousParallelToolOutputs(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"one\"}"}, -+ {"type":"function_call","call_id":"call-2","name":"run_command","arguments":"{\"command\":\"two\"}"}, -+ {"type":"function_call_output","call_id":"call-1","output":"one"}, -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"between outputs"}]}, -+ {"type":"function_call_output","call_id":"call-2","output":"two"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ contents := gjson.GetBytes(result, "contents").Array() -+ if len(contents) != 4 || contents[0].Get("role").String() != "model" || contents[1].Get("role").String() != "user" || contents[2].Get("role").String() != "user" || contents[3].Get("role").String() != "user" { -+ t.Fatalf("non-contiguous tool output roles malformed; result=%s", result) -+ } -+ if got := contents[1].Get("parts.0.functionResponse.id").String(); got != "call-1" { -+ t.Fatalf("first function response = %q, want call-1; result=%s", got, result) -+ } -+ if got := contents[2].Get("parts.0.text").String(); got != "between outputs" { -+ t.Fatalf("intervening user message = %q; result=%s", got, result) -+ } -+ if got := contents[3].Get("parts.0.functionResponse.id").String(); got != "call-2" { -+ t.Fatalf("second function response crossed user boundary: got %q; result=%s", got, result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_PreservesReasoningBeforePairedFunctionSignature(t *testing.T) { -+ secondSignature := differentResponsesGeminiThoughtSignature(t) -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[{"type":"summary_text","text":"first"}]}, -+ {"type":"reasoning","encrypted_content":"` + secondSignature + `","summary":[{"type":"summary_text","text":"second"}]}, -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"true\"}"}, -+ {"type":"function_call_output","call_id":"call-1","output":"ok"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ var signatures []string -+ for _, content := range gjson.GetBytes(result, "contents").Array() { -+ for _, part := range content.Get("parts").Array() { -+ if signature := part.Get("thoughtSignature").String(); signature != "" { -+ signatures = append(signatures, signature) -+ } -+ } -+ } -+ if len(signatures) != 2 || signatures[0] != testResponsesGeminiThoughtSignature || signatures[1] != secondSignature { -+ t.Fatalf("reasoning/function signatures = %v; result=%s", signatures, result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_PreservesFunctionOutputOrderAcrossModelText(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"one\"}"}, -+ {"type":"message","role":"assistant","content":[{"type":"output_text","text":"between"}]}, -+ {"type":"function_call","call_id":"call-2","name":"run_command","arguments":"{\"command\":\"two\"}"}, -+ {"type":"function_call_output","call_id":"call-1","output":"one"}, -+ {"type":"function_call_output","call_id":"call-2","output":"two"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ var sequence []string -+ for _, content := range gjson.GetBytes(result, "contents").Array() { -+ for _, part := range content.Get("parts").Array() { -+ if id := part.Get("functionCall.id").String(); id != "" { -+ sequence = append(sequence, "call:"+id) -+ } -+ if id := part.Get("functionResponse.id").String(); id != "" { -+ sequence = append(sequence, "output:"+id) -+ } -+ } -+ } -+ if got := strings.Join(sequence, ","); got != "call:call-1,call:call-2,output:call-1,output:call-2" { -+ t.Fatalf("function output order = %q; result=%s", got, result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_ReattachesTrailingDetachedSignatureToText(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"turn one"}]}, -+ {"type":"message","role":"assistant","content":[{"type":"output_text","text":"visible answer"}]}, -+ {"id":"rs_text_detached_after_1","type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"turn two"}]} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ parts := gjson.GetBytes(result, "contents.1.parts").Array() -+ if len(parts) != 1 { -+ t.Fatalf("model parts = %d, want one signed visible part; result=%s", len(parts), result) -+ } -+ if got := parts[0].Get("text").String(); got != "visible answer" { -+ t.Fatalf("visible text = %q; result=%s", got, result) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("signature = %q, want detached signature; result=%s", got, result) -+ } -+ if parts[0].Get("thought").Bool() { -+ t.Fatalf("detached visible carrier must not emit an empty thought part; result=%s", result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_ReattachesUnmarkedTrailingSignatureToText(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.5-flash", -+ "input":[ -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"turn one"}]}, -+ {"type":"message","role":"assistant","content":[{"type":"output_text","text":"visible answer"}]}, -+ {"id":"rs_client_rewritten","type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"turn two"}]} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false) -+ parts := gjson.GetBytes(result, "contents.1.parts").Array() -+ if len(parts) != 1 { -+ t.Fatalf("model parts = %d, want one signed visible part after client rewrites carrier ID; result=%s", len(parts), result) -+ } -+ if got := parts[0].Get("text").String(); got != "visible answer" { -+ t.Fatalf("visible text = %q; result=%s", got, result) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("signature = %q, want unmarked trailing signature; result=%s", got, result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_UnmarkedReasoningBeforeFunctionCallStillPairsCall(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.5-flash", -+ "input":[ -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"run"}]}, -+ {"type":"message","role":"assistant","content":[{"type":"output_text","text":"I will run it."}]}, -+ {"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"true\"}"}, -+ {"type":"function_call_output","call_id":"call-1","output":"ok"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", []byte(inputJSON), false) -+ modelParts := gjson.GetBytes(result, "contents.1.parts").Array() -+ if len(modelParts) != 2 { -+ t.Fatalf("model parts = %d, want unsigned preamble plus signed call; result=%s", len(modelParts), result) -+ } -+ if signature := modelParts[0].Get("thoughtSignature"); signature.Exists() { -+ t.Fatalf("function-call signature was retargeted to preamble; result=%s", result) -+ } -+ if got := modelParts[1].Get("thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("function signature = %q, want unmarked reasoning signature; result=%s", got, result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_ReattachesDetachedSignatureToFunctionCall(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"run"}]}, -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"true\"}"}, -+ {"id":"rs_function_detached_after_1","type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"function_call_output","call_id":"call-1","output":"ok"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ functionParts := gjson.GetBytes(result, "contents.#(role==\"model\")#.parts").Array() -+ found := false -+ for _, partArray := range functionParts { -+ for _, part := range partArray.Array() { -+ if part.Get("functionCall.name").String() != "run_command" { -+ continue -+ } -+ found = true -+ if got := part.Get("thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("function signature = %q, want detached signature; result=%s", got, result) -+ } -+ } -+ } -+ if !found { -+ t.Fatalf("function call not found; result=%s", result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_ReattachesUnmarkedPostCallSignatureWithMatchingOutput(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"true\"}"}, -+ {"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"function_call_output","call_id":"call-1","output":"ok"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ if got := gjson.GetBytes(result, "contents.0.parts.0.thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("unmarked post-call signature = %q, want native signature; result=%s", got, result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_ReattachesDirectionalFunctionCarriersWithoutIDs(t *testing.T) { -+ for _, testCase := range []struct { -+ name string -+ direction string -+ input func(string) string -+ }{ -+ { -+ name: "leading", -+ direction: geminiResponsesCarrierNext, -+ input: func(carrier string) string { -+ return `[{"type":"reasoning","encrypted_content":"` + carrier + `","summary":[]},{"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"},{"type":"function_call_output","call_id":"call-1","output":"ok"}]` -+ }, -+ }, -+ { -+ name: "post-call", -+ direction: geminiResponsesCarrierPrevious, -+ input: func(carrier string) string { -+ return `[{"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"},{"type":"reasoning","encrypted_content":"` + carrier + `","summary":[]},{"type":"function_call_output","call_id":"call-1","output":"ok"}]` -+ }, -+ }, -+ } { -+ t.Run(testCase.name, func(t *testing.T) { -+ carrier := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, testCase.direction, geminiResponsesCarrierFunction) -+ inputJSON := []byte(`{"model":"gemini-3.6-flash-high","input":` + testCase.input(carrier) + `}`) -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", inputJSON, false) -+ if got := gjson.GetBytes(result, "contents.0.parts.0.thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("directional function signature = %q, want native signature; result=%s", got, result) -+ } -+ if strings.Contains(string(result), geminiResponsesCarrierPrefix) { -+ t.Fatalf("directional function carrier leaked to Gemini wire: %s", result) -+ } -+ }) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_DoesNotRetargetExtraPreviousCarrier(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ for _, testCase := range []struct { -+ name string -+ targetKind string -+ input func(string, string) string -+ assert func(*testing.T, []gjson.Result) -+ }{ -+ { -+ name: "text", -+ targetKind: geminiResponsesCarrierText, -+ input: func(first, extra string) string { -+ return `[{"type":"reasoning","encrypted_content":"` + first + `","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"signed"}]},{"type":"reasoning","encrypted_content":"` + extra + `","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"unsigned"}]}]` -+ }, -+ assert: func(t *testing.T, parts []gjson.Result) { -+ if len(parts) != 3 || parts[0].Get("text").String() != "signed" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature2 || parts[2].Get("text").String() != "unsigned" || parts[2].Get("thoughtSignature").String() != "" { -+ t.Fatalf("extra previous text carrier retargeted: %v", parts) -+ } -+ }, -+ }, -+ { -+ name: "function", -+ targetKind: geminiResponsesCarrierFunction, -+ input: func(first, extra string) string { -+ return `[{"type":"reasoning","encrypted_content":"` + first + `","summary":[]},{"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"},{"type":"reasoning","encrypted_content":"` + extra + `","summary":[]},{"type":"function_call","call_id":"call-2","name":"run_command","arguments":"{}"}]` -+ }, -+ assert: func(t *testing.T, parts []gjson.Result) { -+ if len(parts) != 3 || parts[0].Get("functionCall.id").String() != "call-1" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature2 || parts[2].Get("functionCall.id").String() != "call-2" || parts[2].Get("thoughtSignature").String() != "" { -+ t.Fatalf("extra previous function carrier retargeted: %v", parts) -+ } -+ }, -+ }, -+ } { -+ t.Run(testCase.name, func(t *testing.T) { -+ first := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, testCase.targetKind) -+ extra := encodeGeminiResponsesCarrier(signature2, geminiResponsesCarrierPrevious, testCase.targetKind) -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":` + testCase.input(first, extra) + `}`) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ testCase.assert(t, gjson.GetBytes(translated, "contents.0.parts").Array()) -+ }) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_DoesNotBindStandaloneFunctionCarrier(t *testing.T) { -+ carrier := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierStandalone, geminiResponsesCarrierFunction) -+ inputJSON := []byte(`{"model":"gemini-3.6-flash-high","input":[{"type":"reasoning","encrypted_content":"` + carrier + `","summary":[]},{"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"}]}`) -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", inputJSON, false) -+ parts := gjson.GetBytes(result, "contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("thoughtSignature").String() != geminiResponsesThoughtSignature { -+ t.Fatalf("standalone carrier was bound to function call: %s", result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_ReattachesUnmarkedParallelPostCallSignature(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"one\"}"}, -+ {"type":"function_call","call_id":"call-2","name":"run_command","arguments":"{\"command\":\"two\"}"}, -+ {"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"function_call_output","call_id":"call-1","output":"one"}, -+ {"type":"function_call_output","call_id":"call-2","output":"two"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ parts := gjson.GetBytes(result, "contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("thoughtSignature").String() != geminiResponsesThoughtSignature || parts[1].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature { -+ t.Fatalf("parallel post-call signature was not attached to call-2: %s", result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_ReattachesAlternatingParallelPostCallSignatures(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{\"command\":\"one\"}"}, -+ {"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"function_call","call_id":"call-2","name":"run_command","arguments":"{\"command\":\"two\"}"}, -+ {"type":"reasoning","encrypted_content":"` + signature2 + `","summary":[]}, -+ {"type":"function_call_output","call_id":"call-1","output":"one"}, -+ {"type":"function_call_output","call_id":"call-2","output":"two"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ parts := gjson.GetBytes(result, "contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("functionCall.id").String() != "call-1" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("functionCall.id").String() != "call-2" || parts[1].Get("thoughtSignature").String() != signature2 { -+ t.Fatalf("alternating parallel post-call signatures shifted: %s", result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_PreservesExtraConsecutivePostCallCarrier(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"}, -+ {"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"reasoning","encrypted_content":"` + signature2 + `","summary":[]}, -+ {"type":"function_call_output","call_id":"call-1","output":"ok"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ parts := gjson.GetBytes(result, "contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("functionCall.id").String() != "call-1" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("thoughtSignature").String() != signature2 { -+ t.Fatalf("consecutive post-call carriers malformed: %s", result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_DoesNotPairUnmarkedPostCallSignatureAcrossMismatch(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"}, -+ {"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"function_call_output","call_id":"other-call","output":"ok"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ if got := gjson.GetBytes(result, "contents.0.parts.0.thoughtSignature").String(); got != geminiResponsesThoughtSignature { -+ t.Fatalf("mismatched output paired signature %q; result=%s", got, result) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_DoesNotPairUnmarkedPostCallSignatureAcrossUserMessage(t *testing.T) { -+ inputJSON := `{ -+ "model":"gemini-3.6-flash-high", -+ "input":[ -+ {"type":"function_call","call_id":"call-1","name":"run_command","arguments":"{}"}, -+ {"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]}, -+ {"type":"message","role":"user","content":[{"type":"input_text","text":"boundary"}]}, -+ {"type":"function_call_output","call_id":"call-1","output":"ok"} -+ ] -+ }` -+ result := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", []byte(inputJSON), false) -+ if got := gjson.GetBytes(result, "contents.0.parts.0.thoughtSignature").String(); got != geminiResponsesThoughtSignature { -+ t.Fatalf("user-boundary carrier paired signature %q; result=%s", got, result) -+ } -+} -+ - func TestConvertOpenAIResponsesRequestToGemini_StripsTrailingAssistantPrefill(t *testing.T) { - inputJSON := `{ - "model": "gpt-5.4", -@@ -140,20 +684,53 @@ func TestConvertOpenAIResponsesRequestToGemini_PreservesReasoningOnlyHistory(t * - if got := gjson.GetBytes(output, "contents").Array(); len(got) != 1 { - t.Fatalf("contents length = %d, want 1. Output: %s", len(got), output) - } -- if len(parts) != 2 { -- t.Fatalf("parts length = %d, want 2. Output: %s", len(parts), output) -+ if len(parts) != 1 { -+ t.Fatalf("parts length = %d, want 1. Output: %s", len(parts), output) - } - if got := parts[0].Get("thought").Bool(); !got { - t.Fatalf("parts[0] should be thought. Output: %s", output) - } -- if got := parts[0].Get("thoughtSignature").String(); got != "" { -- t.Fatalf("parts[0].thoughtSignature = %q, want empty. Output: %s", got, output) -+ if got := parts[0].Get("thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("parts[0].thoughtSignature = %q, want %q. Output: %s", got, testResponsesGeminiThoughtSignature, output) - } - if got := parts[0].Get("text").String(); got != "reasoning summary" { - t.Fatalf("thought text = %q, want reasoning summary. Output: %s", got, output) - } -- if got := parts[1].Get("thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -- t.Fatalf("visible thoughtSignature = %q, want %q. Output: %s", got, testResponsesGeminiThoughtSignature, output) -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_DropsEmptyUnsignedReasoningCarrier(t *testing.T) { -+ input := []byte(`{ -+ "model":"gemini-3.6-flash-high", -+ "input":[{"type":"reasoning","encrypted_content":"","summary":[]}] -+ }`) -+ -+ output := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", input, false) -+ if got := gjson.GetBytes(output, "contents.#").Int(); got != 0 { -+ t.Fatalf("contents = %d, want no empty unsigned model content; output=%s", got, output) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_PreservesUnboundDetachedCarrierWithoutEmptyThought(t *testing.T) { -+ input := []byte(`{ -+ "model": "gemini-3.6-flash-high", -+ "input": [{ -+ "id": "rs_unbound_detached_after_1", -+ "type": "reasoning", -+ "encrypted_content": "` + testResponsesGeminiThoughtSignature + `", -+ "summary": [] -+ }] -+ }`) -+ -+ output := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", input, false) -+ parts := gjson.GetBytes(output, "contents.0.parts").Array() -+ if len(parts) != 1 { -+ t.Fatalf("unbound carrier parts = %d, want one signed carrier; output=%s", len(parts), output) -+ } -+ if parts[0].Get("thought").Bool() || !parts[0].Get("text").Exists() || parts[0].Get("text").String() != "" { -+ t.Fatalf("unbound carrier emitted an empty thought part: %s", output) -+ } -+ if got := parts[0].Get("thoughtSignature").String(); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("unbound carrier signature = %q, want %q; output=%s", got, testResponsesGeminiThoughtSignature, output) - } - } - -@@ -199,9 +776,9 @@ func TestConvertOpenAIResponsesRequestToGemini_ReasoningSignatureCompatibility(t - wantSignature string - }{ - { -- name: "GPT encrypted_content uses Gemini bypass", -+ name: "GPT encrypted_content is dropped from Gemini thought", - encrypted: validResponsesGPTReasoningSignature(), -- wantSignature: geminiResponsesThoughtSignature, -+ wantSignature: "", - }, - { - name: "Gemini encrypted_content is preserved", -@@ -209,9 +786,9 @@ func TestConvertOpenAIResponsesRequestToGemini_ReasoningSignatureCompatibility(t - wantSignature: testResponsesGeminiThoughtSignature, - }, - { -- name: "Missing encrypted_content uses Gemini bypass", -+ name: "Missing encrypted_content leaves Gemini thought unsigned", - encrypted: "", -- wantSignature: geminiResponsesThoughtSignature, -+ wantSignature: "", - }, - } - -@@ -228,11 +805,11 @@ func TestConvertOpenAIResponsesRequestToGemini_ReasoningSignatureCompatibility(t - - output := ConvertOpenAIResponsesRequestToGemini("gemini-3.5-flash", input, false) - parts := gjson.GetBytes(output, "contents.0.parts").Array() -- if len(parts) != 2 { -- t.Fatalf("parts length = %d, want 2. Output: %s", len(parts), output) -+ if len(parts) != 1 { -+ t.Fatalf("parts length = %d, want 1. Output: %s", len(parts), output) - } -- if got := parts[1].Get("thoughtSignature").String(); got != tt.wantSignature { -- t.Fatalf("visible thoughtSignature = %q, want %q. Output: %s", got, tt.wantSignature, output) -+ if got := parts[0].Get("thoughtSignature").String(); got != tt.wantSignature { -+ t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, tt.wantSignature, output) - } - if got := parts[0].Get("text").String(); got != "reasoning summary" { - t.Fatalf("thought text = %q, want reasoning summary. Output: %s", got, output) -diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go -index 36d30df753e..a5ce6d23a15 100644 ---- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go -+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go -@@ -14,6 +14,23 @@ import ( - "github.com/tidwall/sjson" - ) - -+type geminiDetachedReasoningItem struct { -+ Index int -+ ID string -+ Signature string -+} -+ -+type geminiCompletedMessageItem struct { -+ ID string -+ Text string -+} -+ -+type geminiCompletedReasoningItem struct { -+ ID string -+ Signature string -+ Text string -+} -+ - type geminiToResponsesState struct { - Seq int - ResponseID string -@@ -25,16 +42,24 @@ type geminiToResponsesState struct { - MsgClosed bool - MsgIndex int - CurrentMsgID string -- TextBuf strings.Builder - ItemTextBuf strings.Builder - - // reasoning aggregation -- ReasoningOpened bool -- ReasoningIndex int -- ReasoningItemID string -- ReasoningEnc string -- ReasoningBuf strings.Builder -- ReasoningClosed bool -+ ReasoningOpened bool -+ ReasoningIndex int -+ ReasoningItemID string -+ ReasoningEnc string -+ ReasoningDirection string -+ ReasoningTargetKind string -+ ReasoningBuf strings.Builder -+ ReasoningPendingDeltas []string -+ ReasoningClosed bool -+ PendingReasoningSignature string -+ DetachedReasoning map[int]geminiDetachedReasoningItem -+ CompletedMessages map[int]geminiCompletedMessageItem -+ CompletedReasoning map[int]geminiCompletedReasoningItem -+ SeenReasoningSignatures map[string]bool -+ LastSemanticKind string - - // function call aggregation (keyed by output_index) - NextIndex int -@@ -92,11 +117,15 @@ func emitEvent(event string, payload []byte) []byte { - func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte { - if *param == nil { - *param = &geminiToResponsesState{ -- FuncArgsBuf: make(map[int]*strings.Builder), -- FuncNames: make(map[int]string), -- FuncCallIDs: make(map[int]string), -- FuncDone: make(map[int]bool), -- SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), -+ FuncArgsBuf: make(map[int]*strings.Builder), -+ FuncNames: make(map[int]string), -+ FuncCallIDs: make(map[int]string), -+ FuncDone: make(map[int]bool), -+ DetachedReasoning: make(map[int]geminiDetachedReasoningItem), -+ CompletedMessages: make(map[int]geminiCompletedMessageItem), -+ CompletedReasoning: make(map[int]geminiCompletedReasoningItem), -+ SeenReasoningSignatures: make(map[string]bool), -+ SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON), - } - } - st := (*param).(*geminiToResponsesState) -@@ -112,6 +141,18 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - if st.FuncDone == nil { - st.FuncDone = make(map[int]bool) - } -+ if st.DetachedReasoning == nil { -+ st.DetachedReasoning = make(map[int]geminiDetachedReasoningItem) -+ } -+ if st.CompletedMessages == nil { -+ st.CompletedMessages = make(map[int]geminiCompletedMessageItem) -+ } -+ if st.CompletedReasoning == nil { -+ st.CompletedReasoning = make(map[int]geminiCompletedReasoningItem) -+ } -+ if st.SeenReasoningSignatures == nil { -+ st.SeenReasoningSignatures = make(map[string]bool) -+ } - if st.SanitizedNameMap == nil { - st.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON) - } -@@ -121,9 +162,13 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - } - - rawJSON = bytes.TrimSpace(rawJSON) -- if len(rawJSON) == 0 || bytes.Equal(rawJSON, []byte("[DONE]")) { -+ if len(rawJSON) == 0 { - return [][]byte{} - } -+ doneOnly := bytes.Equal(rawJSON, []byte("[DONE]")) -+ if doneOnly { -+ rawJSON = []byte(`{}`) -+ } - - root := gjson.ParseBytes(rawJSON) - if !root.Exists() { -@@ -134,10 +179,47 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - var out [][]byte - nextSeq := func() int { st.Seq++; return st.Seq } - -+ reasoningEncryptedContent := func() string { -+ if st.ReasoningEnc == "" || st.ReasoningDirection == "" { -+ return st.ReasoningEnc -+ } -+ return encodeGeminiResponsesCarrier(st.ReasoningEnc, st.ReasoningDirection, st.ReasoningTargetKind) -+ } -+ openReasoning := func() { -+ if st.ReasoningOpened || st.ReasoningClosed || (st.ReasoningBuf.Len() == 0 && st.ReasoningEnc == "") { -+ return -+ } -+ st.ReasoningOpened = true -+ st.ReasoningIndex = st.NextIndex -+ st.NextIndex++ -+ st.ReasoningItemID = fmt.Sprintf("rs_%s_%d", st.ResponseID, st.ReasoningIndex) -+ item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`) -+ item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) -+ item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex) -+ item, _ = sjson.SetBytes(item, "item.id", st.ReasoningItemID) -+ item, _ = sjson.SetBytes(item, "item.encrypted_content", reasoningEncryptedContent()) -+ out = append(out, emitEvent("response.output_item.added", item)) -+ partAdded := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`) -+ partAdded, _ = sjson.SetBytes(partAdded, "sequence_number", nextSeq()) -+ partAdded, _ = sjson.SetBytes(partAdded, "item_id", st.ReasoningItemID) -+ partAdded, _ = sjson.SetBytes(partAdded, "output_index", st.ReasoningIndex) -+ out = append(out, emitEvent("response.reasoning_summary_part.added", partAdded)) -+ for _, delta := range st.ReasoningPendingDeltas { -+ msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`) -+ msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) -+ msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningItemID) -+ msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex) -+ msg, _ = sjson.SetBytes(msg, "delta", delta) -+ out = append(out, emitEvent("response.reasoning_summary_text.delta", msg)) -+ } -+ st.ReasoningPendingDeltas = nil -+ } -+ - // Helper to finalize reasoning summary events in correct order. - // It emits response.reasoning_summary_text.done followed by - // response.reasoning_summary_part.done exactly once. - finalizeReasoning := func() { -+ openReasoning() - if !st.ReasoningOpened || st.ReasoningClosed { - return - } -@@ -160,13 +242,30 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - itemDone, _ = sjson.SetBytes(itemDone, "sequence_number", nextSeq()) - itemDone, _ = sjson.SetBytes(itemDone, "item.id", st.ReasoningItemID) - itemDone, _ = sjson.SetBytes(itemDone, "output_index", st.ReasoningIndex) -- itemDone, _ = sjson.SetBytes(itemDone, "item.encrypted_content", st.ReasoningEnc) -+ itemDone, _ = sjson.SetBytes(itemDone, "item.encrypted_content", reasoningEncryptedContent()) - itemDone, _ = sjson.SetBytes(itemDone, "item.summary.0.text", full) - out = append(out, emitEvent("response.output_item.done", itemDone)) - -+ st.CompletedReasoning[st.ReasoningIndex] = geminiCompletedReasoningItem{ -+ ID: st.ReasoningItemID, -+ Signature: reasoningEncryptedContent(), -+ Text: full, -+ } - st.ReasoningClosed = true - } - -+ resetReasoning := func() { -+ st.ReasoningOpened = false -+ st.ReasoningClosed = false -+ st.ReasoningIndex = 0 -+ st.ReasoningItemID = "" -+ st.ReasoningEnc = "" -+ st.ReasoningDirection = "" -+ st.ReasoningTargetKind = "" -+ st.ReasoningBuf.Reset() -+ st.ReasoningPendingDeltas = nil -+ } -+ - // Helper to finalize the assistant message in correct order. - // It emits response.output_text.done, response.content_part.done, - // and response.output_item.done exactly once. -@@ -194,9 +293,61 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - final, _ = sjson.SetBytes(final, "item.content.0.text", fullText) - out = append(out, emitEvent("response.output_item.done", final)) - -+ st.CompletedMessages[st.MsgIndex] = geminiCompletedMessageItem{ID: st.CurrentMsgID, Text: fullText} - st.MsgClosed = true - } - -+ emitDetachedReasoning := func(signature, direction, targetKind string) { -+ signature = strings.TrimSpace(signature) -+ if signature == "" || st.SeenReasoningSignatures[signature] { -+ return -+ } -+ finalizeReasoning() -+ finalizeMessage() -+ idx := st.NextIndex -+ st.NextIndex++ -+ placement := "before" -+ if direction == geminiResponsesCarrierPrevious { -+ placement = "after" -+ } -+ itemID := fmt.Sprintf("rs_%s_detached_%s_%d", st.ResponseID, placement, idx) -+ carrierSignature := encodeGeminiResponsesCarrier(signature, direction, targetKind) -+ -+ added := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`) -+ added, _ = sjson.SetBytes(added, "sequence_number", nextSeq()) -+ added, _ = sjson.SetBytes(added, "output_index", idx) -+ added, _ = sjson.SetBytes(added, "item.id", itemID) -+ added, _ = sjson.SetBytes(added, "item.encrypted_content", carrierSignature) -+ out = append(out, emitEvent("response.output_item.added", added)) -+ -+ done := []byte(`{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","encrypted_content":"","summary":[]}}`) -+ done, _ = sjson.SetBytes(done, "sequence_number", nextSeq()) -+ done, _ = sjson.SetBytes(done, "output_index", idx) -+ done, _ = sjson.SetBytes(done, "item.id", itemID) -+ done, _ = sjson.SetBytes(done, "item.encrypted_content", carrierSignature) -+ out = append(out, emitEvent("response.output_item.done", done)) -+ -+ st.DetachedReasoning[idx] = geminiDetachedReasoningItem{Index: idx, ID: itemID, Signature: carrierSignature} -+ st.SeenReasoningSignatures[signature] = true -+ } -+ emitTrailingDetachedReasoning := func(signature string) { -+ switch st.LastSemanticKind { -+ case geminiResponsesCarrierText: -+ emitDetachedReasoning(signature, geminiResponsesCarrierPrevious, geminiResponsesCarrierText) -+ case geminiResponsesCarrierFunction: -+ emitDetachedReasoning(signature, geminiResponsesCarrierPrevious, geminiResponsesCarrierFunction) -+ default: -+ emitDetachedReasoning(signature, geminiResponsesCarrierStandalone, geminiResponsesCarrierAny) -+ } -+ } -+ -+ if doneOnly { -+ if st.Started { -+ openReasoning() -+ } -+ return out -+ } -+ - // Initialize per-response fields and emit created/in_progress once - if !st.Started { - st.ResponseID = root.Get("responseId").String() -@@ -234,55 +385,152 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - // Handle parts (text/thought/functionCall) - if parts := root.Get("candidates.0.content.parts"); parts.Exists() && parts.IsArray() { - parts.ForEach(func(_, part gjson.Result) bool { -+ signature := strings.TrimSpace(part.Get("thoughtSignature").String()) -+ if signature == "" { -+ signature = strings.TrimSpace(part.Get("thought_signature").String()) -+ } -+ functionCall := part.Get("functionCall") -+ text := part.Get("text") -+ isThought := part.Get("thought").Bool() -+ if functionCall.Exists() && st.PendingReasoningSignature != "" { -+ emitTrailingDetachedReasoning(st.PendingReasoningSignature) -+ st.PendingReasoningSignature = "" -+ } -+ reasoningActive := (st.ReasoningOpened && !st.ReasoningClosed) || (!st.ReasoningOpened && (st.ReasoningBuf.Len() > 0 || st.ReasoningEnc != "")) -+ if signature != "" && !isThought { -+ if reasoningActive { -+ switch { -+ case st.ReasoningEnc == "" || st.ReasoningEnc == signature: -+ st.ReasoningEnc = signature -+ switch { -+ case functionCall.Exists(): -+ st.ReasoningDirection = geminiResponsesCarrierNext -+ st.ReasoningTargetKind = geminiResponsesCarrierFunction -+ case text.Exists() && text.String() != "": -+ st.ReasoningDirection = geminiResponsesCarrierNext -+ st.ReasoningTargetKind = geminiResponsesCarrierText -+ default: -+ st.ReasoningDirection = geminiResponsesCarrierStandalone -+ st.ReasoningTargetKind = geminiResponsesCarrierText -+ } -+ st.SeenReasoningSignatures[signature] = true -+ default: -+ finalizeReasoning() -+ if functionCall.Exists() { -+ emitDetachedReasoning(signature, geminiResponsesCarrierNext, geminiResponsesCarrierFunction) -+ } else if !st.SeenReasoningSignatures[signature] { -+ st.PendingReasoningSignature = signature -+ } -+ } -+ if text.Exists() && text.String() == "" && !functionCall.Exists() { -+ finalizeReasoning() -+ return true -+ } -+ } else { -+ switch { -+ case functionCall.Exists(): -+ emitDetachedReasoning(signature, geminiResponsesCarrierNext, geminiResponsesCarrierFunction) -+ case text.Exists() && text.String() != "": -+ if st.PendingReasoningSignature != "" && st.PendingReasoningSignature != signature { -+ emitTrailingDetachedReasoning(st.PendingReasoningSignature) -+ st.PendingReasoningSignature = "" -+ } -+ if !st.SeenReasoningSignatures[signature] { -+ st.PendingReasoningSignature = signature -+ } -+ case text.Exists() && text.String() == "": -+ if st.PendingReasoningSignature != "" { -+ pendingSignature := st.PendingReasoningSignature -+ st.PendingReasoningSignature = "" -+ if pendingSignature != signature { -+ emitTrailingDetachedReasoning(pendingSignature) -+ } -+ } -+ if st.MsgOpened || len(st.FuncDone) > 0 { -+ emitTrailingDetachedReasoning(signature) -+ } else if !st.SeenReasoningSignatures[signature] { -+ st.PendingReasoningSignature = signature -+ } -+ return true -+ } -+ } -+ } -+ - // Reasoning text -- if part.Get("thought").Bool() { -- if st.ReasoningClosed { -- // Ignore any late thought chunks after reasoning is finalized. -- return true -+ if isThought { -+ if st.PendingReasoningSignature != "" && st.MsgOpened && !st.MsgClosed { -+ emitTrailingDetachedReasoning(st.PendingReasoningSignature) -+ st.PendingReasoningSignature = "" - } -- if sig := part.Get("thoughtSignature"); sig.Exists() && sig.String() != "" && sig.String() != geminiResponsesThoughtSignature { -- st.ReasoningEnc = sig.String() -- } else if sig = part.Get("thought_signature"); sig.Exists() && sig.String() != "" && sig.String() != geminiResponsesThoughtSignature { -- st.ReasoningEnc = sig.String() -+ incomingSignature := "" -+ if signature != "" && signature != geminiResponsesThoughtSignature { -+ if st.PendingReasoningSignature != "" { -+ if st.PendingReasoningSignature != signature { -+ emitDetachedReasoning(st.PendingReasoningSignature, geminiResponsesCarrierStandalone, geminiResponsesCarrierAny) -+ } -+ st.PendingReasoningSignature = "" -+ } -+ incomingSignature = signature -+ } else if st.PendingReasoningSignature != "" { -+ incomingSignature = st.PendingReasoningSignature -+ st.PendingReasoningSignature = "" - } -- if !st.ReasoningOpened { -- st.ReasoningOpened = true -- st.ReasoningIndex = st.NextIndex -- st.NextIndex++ -- st.ReasoningItemID = fmt.Sprintf("rs_%s_%d", st.ResponseID, st.ReasoningIndex) -- item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","status":"in_progress","encrypted_content":"","summary":[]}}`) -- item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) -- item, _ = sjson.SetBytes(item, "output_index", st.ReasoningIndex) -- item, _ = sjson.SetBytes(item, "item.id", st.ReasoningItemID) -- item, _ = sjson.SetBytes(item, "item.encrypted_content", st.ReasoningEnc) -- out = append(out, emitEvent("response.output_item.added", item)) -- partAdded := []byte(`{"type":"response.reasoning_summary_part.added","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`) -- partAdded, _ = sjson.SetBytes(partAdded, "sequence_number", nextSeq()) -- partAdded, _ = sjson.SetBytes(partAdded, "item_id", st.ReasoningItemID) -- partAdded, _ = sjson.SetBytes(partAdded, "output_index", st.ReasoningIndex) -- out = append(out, emitEvent("response.reasoning_summary_part.added", partAdded)) -+ if st.ReasoningOpened && !st.ReasoningClosed && incomingSignature != "" && st.ReasoningEnc != "" && incomingSignature != st.ReasoningEnc { -+ finalizeReasoning() -+ resetReasoning() -+ } -+ if st.ReasoningClosed { -+ finalizeMessage() -+ resetReasoning() -+ } else if !st.ReasoningOpened && st.ReasoningBuf.Len() == 0 && st.MsgOpened && !st.MsgClosed { -+ finalizeMessage() -+ } -+ if incomingSignature != "" { -+ st.ReasoningEnc = incomingSignature -+ st.ReasoningDirection = geminiResponsesCarrierStandalone -+ st.ReasoningTargetKind = geminiResponsesCarrierText -+ st.SeenReasoningSignatures[incomingSignature] = true - } - if t := part.Get("text"); t.Exists() && t.String() != "" { -+ st.LastSemanticKind = geminiResponsesCarrierText - st.ReasoningBuf.WriteString(t.String()) -- msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`) -- msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) -- msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningItemID) -- msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex) -- msg, _ = sjson.SetBytes(msg, "delta", t.String()) -- out = append(out, emitEvent("response.reasoning_summary_text.delta", msg)) -+ if st.ReasoningOpened { -+ msg := []byte(`{"type":"response.reasoning_summary_text.delta","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"delta":""}`) -+ msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) -+ msg, _ = sjson.SetBytes(msg, "item_id", st.ReasoningItemID) -+ msg, _ = sjson.SetBytes(msg, "output_index", st.ReasoningIndex) -+ msg, _ = sjson.SetBytes(msg, "delta", t.String()) -+ out = append(out, emitEvent("response.reasoning_summary_text.delta", msg)) -+ } else { -+ st.ReasoningPendingDeltas = append(st.ReasoningPendingDeltas, t.String()) -+ } -+ } -+ if !st.ReasoningOpened && st.ReasoningEnc != "" { -+ openReasoning() - } - return true - } - - // Assistant visible text - if t := part.Get("text"); t.Exists() && t.String() != "" { -- // Before emitting non-reasoning outputs, finalize reasoning if open. -+ if signature == "" && st.PendingReasoningSignature != "" && st.MsgOpened && !st.MsgClosed { -+ emitTrailingDetachedReasoning(st.PendingReasoningSignature) -+ st.PendingReasoningSignature = "" -+ } -+ // Responses output items are sequential: finish reasoning before -+ // opening the visible message. A signature that arrives later is -+ // emitted as an explicit trailing carrier and recombined on replay. - finalizeReasoning() -+ if st.MsgClosed { -+ st.MsgOpened = false -+ st.MsgClosed = false -+ st.ItemTextBuf.Reset() -+ } - if !st.MsgOpened { - st.MsgOpened = true - st.MsgIndex = st.NextIndex - st.NextIndex++ -- st.CurrentMsgID = fmt.Sprintf("msg_%s_0", st.ResponseID) -+ st.CurrentMsgID = fmt.Sprintf("msg_%s_%d", st.ResponseID, st.MsgIndex) - item := []byte(`{"type":"response.output_item.added","sequence_number":0,"output_index":0,"item":{"id":"","type":"message","status":"in_progress","content":[],"role":"assistant"}}`) - item, _ = sjson.SetBytes(item, "sequence_number", nextSeq()) - item, _ = sjson.SetBytes(item, "output_index", st.MsgIndex) -@@ -295,7 +543,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - out = append(out, emitEvent("response.content_part.added", partAdded)) - st.ItemTextBuf.Reset() - } -- st.TextBuf.WriteString(t.String()) -+ st.LastSemanticKind = geminiResponsesCarrierText - st.ItemTextBuf.WriteString(t.String()) - msg := []byte(`{"type":"response.output_text.delta","sequence_number":0,"item_id":"","output_index":0,"content_index":0,"delta":"","logprobs":[]}`) - msg, _ = sjson.SetBytes(msg, "sequence_number", nextSeq()) -@@ -312,6 +560,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - // Responses streaming requires message done events before the next output_item.added. - finalizeReasoning() - finalizeMessage() -+ st.LastSemanticKind = geminiResponsesCarrierFunction - name := util.RestoreSanitizedToolName(st.SanitizedNameMap, fc.Get("name").String()) - idx := st.NextIndex - st.NextIndex++ -@@ -382,6 +631,10 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - - // Finalization on finishReason - if fr := root.Get("candidates.0.finishReason"); fr.Exists() && fr.String() != "" { -+ if st.PendingReasoningSignature != "" { -+ emitTrailingDetachedReasoning(st.PendingReasoningSignature) -+ st.PendingReasoningSignature = "" -+ } - // Finalize reasoning first to keep ordering tight with last delta - finalizeReasoning() - finalizeMessage() -@@ -503,18 +756,25 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - // Compose outputs in output_index order. - outputsWrapper := []byte(`{"arr":[]}`) - for idx := 0; idx < st.NextIndex; idx++ { -- if st.ReasoningOpened && idx == st.ReasoningIndex { -+ if completedReasoning, ok := st.CompletedReasoning[idx]; ok { - item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]}`) -- item, _ = sjson.SetBytes(item, "id", st.ReasoningItemID) -- item, _ = sjson.SetBytes(item, "encrypted_content", st.ReasoningEnc) -- item, _ = sjson.SetBytes(item, "summary.0.text", st.ReasoningBuf.String()) -+ item, _ = sjson.SetBytes(item, "id", completedReasoning.ID) -+ item, _ = sjson.SetBytes(item, "encrypted_content", completedReasoning.Signature) -+ item, _ = sjson.SetBytes(item, "summary.0.text", completedReasoning.Text) - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) - continue - } -- if st.MsgOpened && idx == st.MsgIndex { -+ if completedMessage, ok := st.CompletedMessages[idx]; ok { - item := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`) -- item, _ = sjson.SetBytes(item, "id", st.CurrentMsgID) -- item, _ = sjson.SetBytes(item, "content.0.text", st.TextBuf.String()) -+ item, _ = sjson.SetBytes(item, "id", completedMessage.ID) -+ item, _ = sjson.SetBytes(item, "content.0.text", completedMessage.Text) -+ outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) -+ continue -+ } -+ if detached, ok := st.DetachedReasoning[idx]; ok { -+ item := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`) -+ item, _ = sjson.SetBytes(item, "id", detached.ID) -+ item, _ = sjson.SetBytes(item, "encrypted_content", detached.Signature) - outputsWrapper, _ = sjson.SetRawBytes(outputsWrapper, "arr.-1", item) - continue - } -@@ -668,8 +928,64 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string - // Build outputs from candidates[0].content.parts - var reasoningText strings.Builder - var reasoningEncrypted string -- var messageText strings.Builder -- var haveMessage bool -+ var reasoningDirection string -+ var reasoningTargetKind string -+ type nonStreamReasoningOutput struct { -+ text string -+ signature string -+ direction string -+ targetKind string -+ } -+ type nonStreamFunctionOutput struct { -+ item []byte -+ signature string -+ } -+ type nonStreamOutputOrder struct { -+ kind string -+ index int -+ } -+ type nonStreamDetachedOutput struct { -+ signature string -+ direction string -+ targetKind string -+ } -+ type nonStreamMessageOutput struct { -+ text string -+ signatures []string -+ } -+ var reasoningOutputs []nonStreamReasoningOutput -+ var functionOutputs []nonStreamFunctionOutput -+ var messageOutputs []nonStreamMessageOutput -+ var outputOrder []nonStreamOutputOrder -+ reasoningOutputSignatures := make(map[string]bool) -+ flushReasoningOutput := func() { -+ if reasoningText.Len() == 0 && reasoningEncrypted == "" { -+ return -+ } -+ reasoningIndex := len(reasoningOutputs) -+ reasoningOutputs = append(reasoningOutputs, nonStreamReasoningOutput{text: reasoningText.String(), signature: reasoningEncrypted, direction: reasoningDirection, targetKind: reasoningTargetKind}) -+ outputOrder = append(outputOrder, nonStreamOutputOrder{kind: "reasoning", index: reasoningIndex}) -+ if reasoningEncrypted != "" { -+ reasoningOutputSignatures[reasoningEncrypted] = true -+ } -+ reasoningText.Reset() -+ reasoningEncrypted = "" -+ reasoningDirection = "" -+ reasoningTargetKind = "" -+ } -+ var detachedReasoningOutputs []nonStreamDetachedOutput -+ var currentMessageText strings.Builder -+ var currentMessageSignatures []string -+ flushMessageOutput := func() { -+ if currentMessageText.Len() == 0 { -+ return -+ } -+ messageIndex := len(messageOutputs) -+ messageOutputs = append(messageOutputs, nonStreamMessageOutput{text: currentMessageText.String(), signatures: append([]string(nil), currentMessageSignatures...)}) -+ outputOrder = append(outputOrder, nonStreamOutputOrder{kind: "message", index: messageIndex}) -+ currentMessageText.Reset() -+ currentMessageSignatures = nil -+ } - - haveOutput := false - ensureOutput := func() { -@@ -683,24 +999,75 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string - ensureOutput() - resp, _ = sjson.SetRawBytes(resp, "output.-1", itemJSON) - } -+ detachedOutputIndex := 0 -+ seenDetachedOutputs := make(map[string]bool) -+ appendDetachedOutput := func(signature, direction, targetKind string) { -+ if signature == "" || seenDetachedOutputs[signature] { -+ return -+ } -+ seenDetachedOutputs[signature] = true -+ placement := "before" -+ if direction == geminiResponsesCarrierPrevious { -+ placement = "after" -+ } -+ itemJSON := []byte(`{"id":"","type":"reasoning","encrypted_content":"","summary":[]}`) -+ itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("rs_%s_detached_%s_%d", strings.TrimPrefix(id, "resp_"), placement, detachedOutputIndex)) -+ itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", encodeGeminiResponsesCarrier(signature, direction, targetKind)) -+ detachedOutputIndex++ -+ appendOutput(itemJSON) -+ } - - if parts := root.Get("candidates.0.content.parts"); parts.Exists() && parts.IsArray() { - parts.ForEach(func(_, p gjson.Result) bool { -+ signature := strings.TrimSpace(p.Get("thoughtSignature").String()) -+ if signature == "" { -+ signature = strings.TrimSpace(p.Get("thought_signature").String()) -+ } - if p.Get("thought").Bool() { -+ flushMessageOutput() -+ if signature != "" && reasoningEncrypted != "" && signature != reasoningEncrypted { -+ flushReasoningOutput() -+ } - if t := p.Get("text"); t.Exists() { - reasoningText.WriteString(t.String()) - } -- if sig := p.Get("thoughtSignature"); sig.Exists() && sig.String() != "" { -- reasoningEncrypted = sig.String() -+ if signature != "" { -+ reasoningEncrypted = signature -+ reasoningDirection = geminiResponsesCarrierStandalone -+ reasoningTargetKind = geminiResponsesCarrierText - } - return true - } - if t := p.Get("text"); t.Exists() && t.String() != "" { -- messageText.WriteString(t.String()) -- haveMessage = true -+ messageSignature := "" -+ if signature != "" { -+ if reasoningText.Len() > 0 && reasoningEncrypted == "" { -+ reasoningEncrypted = signature -+ reasoningDirection = geminiResponsesCarrierNext -+ reasoningTargetKind = geminiResponsesCarrierText -+ } else { -+ messageSignature = signature -+ } -+ } -+ flushReasoningOutput() -+ if len(currentMessageSignatures) > 0 && (messageSignature == "" || currentMessageSignatures[len(currentMessageSignatures)-1] != messageSignature) { -+ flushMessageOutput() -+ } -+ currentMessageText.WriteString(t.String()) -+ if messageSignature != "" && (len(currentMessageSignatures) == 0 || currentMessageSignatures[len(currentMessageSignatures)-1] != messageSignature) { -+ currentMessageSignatures = append(currentMessageSignatures, messageSignature) -+ } - return true - } - if fc := p.Get("functionCall"); fc.Exists() { -+ if reasoningText.Len() > 0 && reasoningEncrypted == "" && signature != "" { -+ reasoningEncrypted = signature -+ reasoningDirection = geminiResponsesCarrierNext -+ reasoningTargetKind = geminiResponsesCarrierFunction -+ signature = "" -+ } -+ flushReasoningOutput() -+ flushMessageOutput() - name := util.RestoreSanitizedToolName(sanitizedNameMap, fc.Get("name").String()) - args := fc.Get("args") - callID := fmt.Sprintf("call_%x_%d", time.Now().UnixNano(), atomic.AddUint64(&funcCallIDCounter, 1)) -@@ -713,34 +1080,106 @@ func ConvertGeminiResponseToOpenAIResponsesNonStream(_ context.Context, _ string - argsStr = args.Raw - } - itemJSON, _ = sjson.SetBytes(itemJSON, "arguments", argsStr) -- appendOutput(itemJSON) -+ functionIndex := len(functionOutputs) -+ functionOutputs = append(functionOutputs, nonStreamFunctionOutput{item: itemJSON, signature: signature}) -+ outputOrder = append(outputOrder, nonStreamOutputOrder{kind: "function", index: functionIndex}) - return true - } -+ if signature != "" { -+ if reasoningText.Len() > 0 { -+ switch { -+ case reasoningEncrypted == "": -+ reasoningEncrypted = signature -+ reasoningDirection = geminiResponsesCarrierStandalone -+ reasoningTargetKind = geminiResponsesCarrierText -+ case reasoningEncrypted != signature: -+ flushReasoningOutput() -+ detachedIndex := len(detachedReasoningOutputs) -+ detachedReasoningOutputs = append(detachedReasoningOutputs, nonStreamDetachedOutput{signature: signature, direction: geminiResponsesCarrierPrevious, targetKind: geminiResponsesCarrierText}) -+ outputOrder = append(outputOrder, nonStreamOutputOrder{kind: "detached", index: detachedIndex}) -+ } -+ } else if currentMessageText.Len() > 0 { -+ if len(currentMessageSignatures) == 0 { -+ currentMessageSignatures = append(currentMessageSignatures, signature) -+ } else if currentMessageSignatures[len(currentMessageSignatures)-1] != signature { -+ flushMessageOutput() -+ detachedIndex := len(detachedReasoningOutputs) -+ detachedReasoningOutputs = append(detachedReasoningOutputs, nonStreamDetachedOutput{signature: signature, direction: geminiResponsesCarrierPrevious, targetKind: geminiResponsesCarrierText}) -+ outputOrder = append(outputOrder, nonStreamOutputOrder{kind: "detached", index: detachedIndex}) -+ } -+ } else if len(functionOutputs) > 0 { -+ detachedIndex := len(detachedReasoningOutputs) -+ detachedReasoningOutputs = append(detachedReasoningOutputs, nonStreamDetachedOutput{signature: signature, direction: geminiResponsesCarrierPrevious, targetKind: geminiResponsesCarrierFunction}) -+ outputOrder = append(outputOrder, nonStreamOutputOrder{kind: "detached", index: detachedIndex}) -+ } else { -+ detachedIndex := len(detachedReasoningOutputs) -+ detachedReasoningOutputs = append(detachedReasoningOutputs, nonStreamDetachedOutput{signature: signature, direction: geminiResponsesCarrierNext, targetKind: geminiResponsesCarrierAny}) -+ outputOrder = append(outputOrder, nonStreamOutputOrder{kind: "detached", index: detachedIndex}) -+ } -+ } - return true - }) - } - -- // Reasoning output item -- if reasoningText.Len() > 0 || reasoningEncrypted != "" { -- rid := strings.TrimPrefix(id, "resp_") -- itemJSON := []byte(`{"id":"","type":"reasoning","encrypted_content":""}`) -- itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("rs_%s", rid)) -- itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", reasoningEncrypted) -- if reasoningText.Len() > 0 { -- summaryJSON := []byte(`{"type":"summary_text","text":""}`) -- summaryJSON, _ = sjson.SetBytes(summaryJSON, "text", reasoningText.String()) -- itemJSON, _ = sjson.SetRawBytes(itemJSON, "summary", []byte(`[]`)) -- itemJSON, _ = sjson.SetRawBytes(itemJSON, "summary.-1", summaryJSON) -- } -- appendOutput(itemJSON) -- } -+ flushReasoningOutput() -+ flushMessageOutput() - -- // Assistant message output item -- if haveMessage { -- itemJSON := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`) -- itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("msg_%s_0", strings.TrimPrefix(id, "resp_"))) -- itemJSON, _ = sjson.SetBytes(itemJSON, "content.0.text", messageText.String()) -- appendOutput(itemJSON) -+ for _, outputItem := range outputOrder { -+ switch outputItem.kind { -+ case "detached": -+ if outputItem.index < 0 || outputItem.index >= len(detachedReasoningOutputs) { -+ continue -+ } -+ detached := detachedReasoningOutputs[outputItem.index] -+ if !reasoningOutputSignatures[detached.signature] { -+ appendDetachedOutput(detached.signature, detached.direction, detached.targetKind) -+ } -+ case "reasoning": -+ if outputItem.index < 0 || outputItem.index >= len(reasoningOutputs) { -+ continue -+ } -+ reasoningOutput := reasoningOutputs[outputItem.index] -+ rid := strings.TrimPrefix(id, "resp_") -+ reasoningID := fmt.Sprintf("rs_%s", rid) -+ if len(reasoningOutputs) > 1 { -+ reasoningID = fmt.Sprintf("rs_%s_%d", rid, outputItem.index) -+ } -+ itemJSON := []byte(`{"id":"","type":"reasoning","encrypted_content":""}`) -+ itemJSON, _ = sjson.SetBytes(itemJSON, "id", reasoningID) -+ encryptedContent := reasoningOutput.signature -+ if encryptedContent != "" && reasoningOutput.direction != "" { -+ encryptedContent = encodeGeminiResponsesCarrier(encryptedContent, reasoningOutput.direction, reasoningOutput.targetKind) -+ } -+ itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", encryptedContent) -+ if reasoningOutput.text != "" { -+ summaryJSON := []byte(`{"type":"summary_text","text":""}`) -+ summaryJSON, _ = sjson.SetBytes(summaryJSON, "text", reasoningOutput.text) -+ itemJSON, _ = sjson.SetRawBytes(itemJSON, "summary", []byte(`[]`)) -+ itemJSON, _ = sjson.SetRawBytes(itemJSON, "summary.-1", summaryJSON) -+ } -+ appendOutput(itemJSON) -+ case "message": -+ if outputItem.index < 0 || outputItem.index >= len(messageOutputs) { -+ continue -+ } -+ messageOutput := messageOutputs[outputItem.index] -+ for _, signature := range messageOutput.signatures { -+ if !reasoningOutputSignatures[signature] { -+ appendDetachedOutput(signature, geminiResponsesCarrierNext, geminiResponsesCarrierText) -+ } -+ } -+ itemJSON := []byte(`{"id":"","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":""}],"role":"assistant"}`) -+ itemJSON, _ = sjson.SetBytes(itemJSON, "id", fmt.Sprintf("msg_%s_%d", strings.TrimPrefix(id, "resp_"), outputItem.index)) -+ itemJSON, _ = sjson.SetBytes(itemJSON, "content.0.text", messageOutput.text) -+ appendOutput(itemJSON) -+ case "function": -+ if outputItem.index < 0 || outputItem.index >= len(functionOutputs) { -+ continue -+ } -+ functionOutput := functionOutputs[outputItem.index] -+ appendDetachedOutput(functionOutput.signature, geminiResponsesCarrierNext, geminiResponsesCarrierFunction) -+ appendOutput(functionOutput.item) -+ } - } - - // usage mapping -diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go -index 715fdfd6017..daa7d7d3a8c 100644 ---- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go -+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response_test.go -@@ -2,10 +2,12 @@ package responses - - import ( - "context" -+ "encoding/base64" - "strings" - "testing" - - "github.com/tidwall/gjson" -+ "github.com/tidwall/sjson" - ) - - func parseSSEEvent(t *testing.T, chunk []byte) (string, gjson.Result) { -@@ -153,6 +155,879 @@ func TestConvertGeminiResponseToOpenAIResponses_UnwrapAndAggregateText(t *testin - } - } - -+func differentResponsesGeminiThoughtSignature(t *testing.T) string { -+ t.Helper() -+ raw, errDecode := base64.StdEncoding.DecodeString(testResponsesGeminiThoughtSignature) -+ if errDecode != nil { -+ t.Fatal(errDecode) -+ } -+ raw[len(raw)-1] ^= 1 -+ return base64.StdEncoding.EncodeToString(raw) -+} -+ -+func decodedResponsesCarrierSignature(t *testing.T, encryptedContent string) string { -+ t.Helper() -+ signature, _, _, marked, ok := decodeGeminiResponsesCarrier(encryptedContent) -+ if marked && !ok { -+ t.Fatalf("invalid Responses carrier envelope: %q", encryptedContent) -+ } -+ return signature -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_ConsecutiveSignedVisibleTextPreservesEverySignature(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"a"}]}}],"modelVersion":"gemini-3.6-flash","responseId":"signed-text"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"b","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]}}],"modelVersion":"gemini-3.6-flash","responseId":"signed-text"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"c","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"signed-text"}}`, -+ } -+ var param any -+ added := make(map[string]string) -+ done := make(map[string]string) -+ var completed gjson.Result -+ for _, line := range in { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if data.Get("item.type").String() == "reasoning" { -+ switch event { -+ case "response.output_item.added": -+ added[data.Get("item.id").String()] = data.Get("item.encrypted_content").String() -+ case "response.output_item.done": -+ done[data.Get("item.id").String()] = data.Get("item.encrypted_content").String() -+ } -+ } -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ } -+ if len(added) != 2 || len(done) != 2 { -+ t.Fatalf("reasoning items added/done = %d/%d, want 2/2", len(added), len(done)) -+ } -+ for id, signature := range added { -+ if done[id] != signature { -+ t.Fatalf("reasoning item %s changed signature from %q to %q", id, signature, done[id]) -+ } -+ } -+ seen := map[string]bool{} -+ completed.ForEach(func(_, item gjson.Result) bool { -+ if item.Get("type").String() == "reasoning" { -+ seen[decodedResponsesCarrierSignature(t, item.Get("encrypted_content").String())] = true -+ } -+ return true -+ }) -+ if !seen[testResponsesGeminiThoughtSignature] || !seen[signature2] { -+ t.Fatalf("completed signatures = %v, want both", seen) -+ } -+ -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw)) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ var visibleParts []gjson.Result -+ for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() { -+ if !part.Get("thought").Bool() && part.Get("text").String() != "" { -+ visibleParts = append(visibleParts, part) -+ } -+ } -+ if len(visibleParts) != 2 || visibleParts[0].Get("text").String() != "ab" || visibleParts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || visibleParts[1].Get("text").String() != "c" || visibleParts[1].Get("thoughtSignature").String() != signature2 { -+ t.Fatalf("signed visible text did not round-trip by segment: %s", translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_ConsecutiveSignedVisibleTextPreservesEverySignature(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"a"},{"text":"b","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"c","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"signed-text-nonstream"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ output := gjson.GetBytes(out, "output") -+ if decodedResponsesCarrierSignature(t, output.Get("0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || output.Get("1.content.0.text").String() != "ab" || decodedResponsesCarrierSignature(t, output.Get("2.encrypted_content").String()) != signature2 || output.Get("3.content.0.text").String() != "c" { -+ t.Fatalf("non-stream signed visible text was not segmented: %s", out) -+ } -+ -+ outputWithoutIDs := []byte(output.Raw) -+ outputWithoutIDs, _ = sjson.DeleteBytes(outputWithoutIDs, "0.id") -+ outputWithoutIDs, _ = sjson.DeleteBytes(outputWithoutIDs, "2.id") -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", outputWithoutIDs) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ var visibleParts []gjson.Result -+ for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() { -+ if !part.Get("thought").Bool() && part.Get("text").String() != "" { -+ visibleParts = append(visibleParts, part) -+ } -+ } -+ if len(visibleParts) != 2 || visibleParts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || visibleParts[1].Get("thoughtSignature").String() != signature2 { -+ t.Fatalf("non-stream signatures did not round-trip after client stripped reasoning IDs: %s", translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_SignedVisibleThenUnsignedPreservesBoundary(t *testing.T) { -+ lines := []string{ -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"signed","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]}}],"responseId":"signed-then-unsigned"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"unsigned"}]},"finishReason":"STOP"}],"responseId":"signed-then-unsigned"}}`, -+ } -+ var param any -+ var completed gjson.Result -+ for _, line := range lines { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ } -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw)) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ parts := gjson.GetBytes(translated, "contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("text").String() != "signed" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("text").String() != "unsigned" || parts[1].Get("thoughtSignature").String() != "" { -+ t.Fatalf("signed/unsigned visible boundary changed: output=%s translated=%s", completed.Raw, translated) -+ } -+ if !strings.Contains(completed.Raw, geminiResponsesCarrierPrefix) || strings.Contains(string(translated), geminiResponsesCarrierPrefix) { -+ t.Fatalf("Responses carrier must exist only on the client-facing wire: output=%s translated=%s", completed.Raw, translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_LeadingCarrierDoesNotCrossSignedThought(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ lines := []string{ -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]}}],"responseId":"leading-before-signed-thought"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"reason","thought":true,"thoughtSignature":"` + signature2 + `"}]}}],"responseId":"leading-before-signed-thought"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer"}]},"finishReason":"STOP"}],"responseId":"leading-before-signed-thought"}}`, -+ } -+ var param any -+ var streamOutput gjson.Result -+ for _, line := range lines { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.completed" { -+ streamOutput = data.Get("response.output") -+ } -+ } -+ } -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"reason","thought":true,"thoughtSignature":"` + signature2 + `"},{"text":"answer"}]},"finishReason":"STOP"}],"responseId":"leading-before-signed-thought-nonstream"}`) -+ nonStream := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ -+ for name, output := range map[string]gjson.Result{"stream": streamOutput, "non-stream": gjson.GetBytes(nonStream, "output")} { -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(output.Raw)) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ parts := gjson.GetBytes(translated, "contents.0.parts").Array() -+ if len(parts) != 3 || !parts[0].Get("text").Exists() || parts[0].Get("text").String() != "" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("text").String() != "reason" || !parts[1].Get("thought").Bool() || parts[1].Get("thoughtSignature").String() != signature2 || parts[2].Get("text").String() != "answer" || parts[2].Get("thoughtSignature").String() != "" { -+ t.Fatalf("%s leading carrier crossed signed thought: output=%s translated=%s", name, output.Raw, translated) -+ } -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_SignedVisibleThenUnsignedPreservesBoundary(t *testing.T) { -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"signed","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"unsigned"}]},"finishReason":"STOP"}],"responseId":"signed-then-unsigned-nonstream"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(gjson.GetBytes(out, "output").Raw)) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ parts := gjson.GetBytes(translated, "contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("text").String() != "signed" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || parts[1].Get("text").String() != "unsigned" || parts[1].Get("thoughtSignature").String() != "" { -+ t.Fatalf("non-stream signed/unsigned visible boundary changed: output=%s translated=%s", gjson.GetBytes(out, "output").Raw, translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_TrailingCarrierDirectionDoesNotDependOnID(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"answer","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"trailing-direction-nonstream"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(gjson.GetBytes(out, "output").Raw)) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ parts := gjson.GetBytes(translated, "contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("text").String() != "answer" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature2 { -+ t.Fatalf("non-stream trailing carrier changed direction: output=%s translated=%s", gjson.GetBytes(out, "output").Raw, translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_TrailingCarrierDirectionSurvivesStrippedIDs(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ lines := []string{ -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]}}],"responseId":"trailing-direction-stream"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"trailing-direction-stream"}}`, -+ } -+ var param any -+ var completed gjson.Result -+ for _, line := range lines { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ } -+ withoutIDs := []byte(completed.Raw) -+ withoutIDs, _ = sjson.DeleteBytes(withoutIDs, "1.id") -+ withoutIDs, _ = sjson.DeleteBytes(withoutIDs, "2.id") -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", withoutIDs) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ parts := gjson.GetBytes(translated, "contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("text").String() != "answer" || parts[0].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || !parts[1].Get("text").Exists() || parts[1].Get("text").String() != "" || parts[1].Get("thoughtSignature").String() != signature2 { -+ t.Fatalf("ID-stripped trailing carrier changed direction: output=%s translated=%s", completed.Raw, translated) -+ } -+ if !strings.Contains(completed.Raw, geminiResponsesCarrierPrefix) || strings.Contains(string(translated), geminiResponsesCarrierPrefix) { -+ t.Fatalf("ID-stripped Responses carrier leaked across protocol boundary: output=%s translated=%s", completed.Raw, translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_VisibleSignatureDoesNotOverwriteSignedThought(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"one","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]}}],"responseId":"signed-thought-visible"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"signed-thought-visible"}}`, -+ } -+ var param any -+ added := make(map[string]string) -+ done := make(map[string]string) -+ var completed gjson.Result -+ for _, line := range in { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if data.Get("item.type").String() == "reasoning" { -+ switch event { -+ case "response.output_item.added": -+ added[data.Get("item.id").String()] = data.Get("item.encrypted_content").String() -+ case "response.output_item.done": -+ done[data.Get("item.id").String()] = data.Get("item.encrypted_content").String() -+ } -+ } -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ } -+ for id, signature := range added { -+ if done[id] != signature { -+ t.Fatalf("reasoning item %s changed signature from %q to %q", id, signature, done[id]) -+ } -+ } -+ if decodedResponsesCarrierSignature(t, completed.Get("0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || decodedResponsesCarrierSignature(t, completed.Get("2.encrypted_content").String()) != signature2 { -+ t.Fatalf("thought/visible signatures were not both preserved: %s", completed.Raw) -+ } -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw)) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ var signatures []string -+ visibleSignature := "" -+ for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() { -+ if signature := part.Get("thoughtSignature").String(); signature != "" { -+ signatures = append(signatures, signature) -+ if part.Get("text").String() == "answer" { -+ visibleSignature = signature -+ } -+ } -+ } -+ if len(signatures) != 2 || visibleSignature != signature2 { -+ t.Fatalf("thought/visible signatures did not round-trip: signatures=%v visible=%q translated=%s", signatures, visibleSignature, translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_FlushesVisibleSignatureBeforeLaterThought(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ const signature3 = "third-distinct-gemini-signature-123456" -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"thought-a","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]}}],"responseId":"visible-before-thought"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer","thoughtSignature":"` + signature2 + `"}]}}],"responseId":"visible-before-thought"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"thought-c","thought":true,"thoughtSignature":"` + signature3 + `"}]},"finishReason":"STOP"}],"responseId":"visible-before-thought"}}`, -+ } -+ var param any -+ var completed gjson.Result -+ for _, line := range in { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ } -+ if decodedResponsesCarrierSignature(t, completed.Get("0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || completed.Get("1.type").String() != "message" || decodedResponsesCarrierSignature(t, completed.Get("2.encrypted_content").String()) != signature2 || decodedResponsesCarrierSignature(t, completed.Get("3.encrypted_content").String()) != signature3 { -+ t.Fatalf("visible signature crossed later thought: %s", completed.Raw) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_FunctionAndTrailingSignaturesRoundTrip(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `","functionCall":{"name":"run_command","args":{"command":"true"}}}]}}],"responseId":"function-trailing"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"function-trailing"}}`, -+ } -+ var param any -+ var completed gjson.Result -+ for _, line := range in { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ } -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw)) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ var signatures []string -+ for _, content := range gjson.GetBytes(translated, "contents").Array() { -+ for _, part := range content.Get("parts").Array() { -+ if signature := part.Get("thoughtSignature").String(); signature != "" { -+ signatures = append(signatures, signature) -+ } -+ } -+ } -+ if len(signatures) != 2 || signatures[0] != testResponsesGeminiThoughtSignature || signatures[1] != signature2 { -+ t.Fatalf("function/trailing signatures = %v; completed=%s translated=%s", signatures, completed.Raw, translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_FunctionAndTrailingSignaturesPreserveOrder(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `","functionCall":{"name":"run_command","args":{"command":"true"}}},{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"function-trailing-nonstream"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ if decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || gjson.GetBytes(out, "output.1.type").String() != "function_call" || decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.2.encrypted_content").String()) != signature2 { -+ t.Fatalf("non-stream function/trailing order malformed: %s", out) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_FunctionThenTrailingSignatureHasStreamParity(t *testing.T) { -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"preamble"},{"functionCall":{"name":"run_command","args":{"command":"true"}}},{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]},"finishReason":"STOP"}],"responseId":"function-trailing-parity"}`) -+ -+ var param any -+ var streamOutput gjson.Result -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, append([]byte("data: "), raw...), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.completed" { -+ streamOutput = data.Get("response.output") -+ } -+ } -+ nonStream := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ nonStreamOutput := gjson.GetBytes(nonStream, "output") -+ for name, output := range map[string]gjson.Result{"stream": streamOutput, "non-stream": nonStreamOutput} { -+ items := output.Array() -+ if len(items) != 3 || items[0].Get("type").String() != "message" || items[1].Get("type").String() != "function_call" || items[2].Get("type").String() != "reasoning" { -+ t.Fatalf("%s function/trailing order malformed: %s", name, output.Raw) -+ } -+ signature, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(items[2].Get("encrypted_content").String()) -+ if !marked || !ok || signature != testResponsesGeminiThoughtSignature || direction != geminiResponsesCarrierPrevious || targetKind != geminiResponsesCarrierFunction { -+ t.Fatalf("%s function/trailing carrier malformed: %s", name, output.Raw) -+ } -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(output.Raw)) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ parts := gjson.GetBytes(translated, "contents.0.parts").Array() -+ if len(parts) != 2 || parts[0].Get("text").String() != "preamble" || parts[1].Get("functionCall.name").String() != "run_command" || parts[1].Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature { -+ t.Fatalf("%s trailing function signature did not replay: %s", name, translated) -+ } -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_TrailingSignatureFollowsPendingReasoning(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"thought","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"reasoning-trailing-nonstream"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ if decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.1.encrypted_content").String()) != signature2 { -+ t.Fatalf("non-stream reasoning/trailing order malformed: %s", out) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_UnsignedThoughtDoesNotStealFunctionSignature(t *testing.T) { -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `","functionCall":{"name":"run_command","args":{"command":"true"}}},{"text":"later thought","thought":true}]},"finishReason":"STOP"}],"responseId":"function-unsigned-thought"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ if decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || gjson.GetBytes(out, "output.1.type").String() != "function_call" || gjson.GetBytes(out, "output.2.summary.0.text").String() != "later thought" || gjson.GetBytes(out, "output.2.encrypted_content").String() != "" { -+ t.Fatalf("unsigned thought stole function signature: %s", out) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_InterleavedThoughtAndTextPreservesOrder(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ line := []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"thought-a","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"answer-a"},{"text":"thought-b","thought":true,"thoughtSignature":"` + signature2 + `"},{"text":"answer-b"}]},"finishReason":"STOP"}],"responseId":"interleaved"}}`) -+ var param any -+ var doneTypes []string -+ var completed gjson.Result -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, line, ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.output_item.done" { -+ doneTypes = append(doneTypes, data.Get("item.type").String()) -+ } -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ if got := strings.Join(doneTypes, ","); got != "reasoning,message,reasoning,message" { -+ t.Fatalf("interleaved done order = %q", got) -+ } -+ if completed.Get("0.summary.0.text").String() != "thought-a" || completed.Get("1.content.0.text").String() != "answer-a" || completed.Get("2.summary.0.text").String() != "thought-b" || completed.Get("3.content.0.text").String() != "answer-b" { -+ t.Fatalf("interleaved completed output malformed: %s", completed.Raw) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_InterleavedThoughtAndTextPreservesOrder(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"thought-a","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"answer-a"},{"text":"thought-b","thought":true,"thoughtSignature":"` + signature2 + `"},{"text":"answer-b"}]},"finishReason":"STOP"}],"responseId":"interleaved-nonstream"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ if got := gjson.GetBytes(out, "output.#").Int(); got != 4 { -+ t.Fatalf("interleaved non-stream output count = %d; output=%s", got, out) -+ } -+ if gjson.GetBytes(out, "output.0.type").String() != "reasoning" || gjson.GetBytes(out, "output.1.type").String() != "message" || gjson.GetBytes(out, "output.2.type").String() != "reasoning" || gjson.GetBytes(out, "output.3.type").String() != "message" { -+ t.Fatalf("interleaved non-stream order malformed: %s", out) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_LeadingEmptyAndSignedTextRoundTripInOrder(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]}}],"responseId":"leading-empty-signed-text"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"leading-empty-signed-text"}}`, -+ } -+ var param any -+ var completed gjson.Result -+ for _, line := range in { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ } -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw)) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ var signatures []string -+ visibleSignature := "" -+ for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() { -+ if signature := part.Get("thoughtSignature").String(); signature != "" { -+ signatures = append(signatures, signature) -+ if part.Get("text").String() == "answer" { -+ visibleSignature = signature -+ } -+ } -+ } -+ if len(signatures) != 2 || signatures[0] != testResponsesGeminiThoughtSignature || visibleSignature != signature2 { -+ t.Fatalf("leading empty/signed text signatures=%v visible=%q translated=%s", signatures, visibleSignature, translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_SignedTextAndTrailingSignatureRoundTripInOrder(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"answer","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]}}],"responseId":"signed-text-trailing"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"signed-text-trailing"}}`, -+ } -+ var param any -+ var completed gjson.Result -+ for _, line := range in { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ } -+ if completed.Get("0.type").String() != "message" || decodedResponsesCarrierSignature(t, completed.Get("1.encrypted_content").String()) != testResponsesGeminiThoughtSignature || decodedResponsesCarrierSignature(t, completed.Get("2.encrypted_content").String()) != signature2 { -+ t.Fatalf("signed text/trailing completed order malformed: %s", completed.Raw) -+ } -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw)) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ var signatures []string -+ for _, part := range gjson.GetBytes(translated, "contents.0.parts").Array() { -+ if signature := part.Get("thoughtSignature").String(); signature != "" { -+ signatures = append(signatures, signature) -+ } -+ } -+ if len(signatures) != 2 || signatures[0] != testResponsesGeminiThoughtSignature || signatures[1] != signature2 { -+ t.Fatalf("signed text/trailing signatures = %v; translated=%s", signatures, translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_PreservesMultipleLeadingEmptySignatures(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ line := []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"leading-empty-signatures"}}`) -+ var param any -+ var completed gjson.Result -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, line, ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ if decodedResponsesCarrierSignature(t, completed.Get("0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || decodedResponsesCarrierSignature(t, completed.Get("1.encrypted_content").String()) != signature2 { -+ t.Fatalf("leading empty signatures were not preserved: %s", completed.Raw) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_SignedTextAndTrailingSignatureRoundTripInOrder(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"answer","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"","thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"signed-text-trailing-nonstream"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ if decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()) != testResponsesGeminiThoughtSignature || gjson.GetBytes(out, "output.1.type").String() != "message" || decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.2.encrypted_content").String()) != signature2 { -+ t.Fatalf("non-stream signed text/trailing order malformed: %s", out) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_DistinctSignedThoughtsUseDistinctItems(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"one","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]}}],"modelVersion":"gemini-3.6-flash","responseId":"signed-thoughts"}}`, -+ `data: {"response":{"candidates":[{"content":{"parts":[{"text":"two","thought":true,"thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"signed-thoughts"}}`, -+ } -+ var param any -+ added := make(map[string]string) -+ done := make(map[string]string) -+ var completed gjson.Result -+ for _, line := range in { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if data.Get("item.type").String() == "reasoning" { -+ switch event { -+ case "response.output_item.added": -+ added[data.Get("item.id").String()] = data.Get("item.encrypted_content").String() -+ case "response.output_item.done": -+ done[data.Get("item.id").String()] = data.Get("item.encrypted_content").String() -+ } -+ } -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ } -+ if len(added) != 2 || len(done) != 2 { -+ t.Fatalf("reasoning items added/done = %d/%d, want 2/2", len(added), len(done)) -+ } -+ for id, signature := range added { -+ if done[id] != signature { -+ t.Fatalf("reasoning item %s changed signature from %q to %q", id, signature, done[id]) -+ } -+ } -+ if got := decodedResponsesCarrierSignature(t, completed.Get("0.encrypted_content").String()); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("first completed signature = %q", got) -+ } -+ if got := decodedResponsesCarrierSignature(t, completed.Get("1.encrypted_content").String()); got != signature2 { -+ t.Fatalf("second completed signature = %q", got) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_DistinctSignedThoughtsUseDistinctItems(t *testing.T) { -+ signature2 := differentResponsesGeminiThoughtSignature(t) -+ raw := []byte(`{"candidates":[{"content":{"parts":[{"text":"one","thought":true,"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"},{"text":"two","thought":true,"thoughtSignature":"` + signature2 + `"}]},"finishReason":"STOP"}],"responseId":"signed-thoughts-nonstream"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ if got := gjson.GetBytes(out, "output.#").Int(); got != 2 { -+ t.Fatalf("reasoning output count = %d, want 2; output=%s", got, out) -+ } -+ if got := decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("first signature = %q; output=%s", got, out) -+ } -+ if got := decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.1.encrypted_content").String()); got != signature2 { -+ t.Fatalf("second signature = %q; output=%s", got, out) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_VisibleSignatureCompletesActiveReasoning(t *testing.T) { -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"hidden thought","thought":true}]}}],"modelVersion":"gemini-3.6-flash","responseId":"resp_active_reasoning"}}`, -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"visible answer","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":15},"modelVersion":"gemini-3.6-flash","responseId":"resp_active_reasoning"}}`, -+ } -+ var param any -+ var out [][]byte -+ for _, line := range in { -+ out = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m)...) -+ } -+ var doneTypes []string -+ var addedID, addedSignature, doneID, doneSignature string -+ for _, chunk := range out { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.output_item.added" && data.Get("item.type").String() == "reasoning" { -+ addedID = data.Get("item.id").String() -+ addedSignature = data.Get("item.encrypted_content").String() -+ } -+ if event != "response.output_item.done" { -+ continue -+ } -+ doneTypes = append(doneTypes, data.Get("item.type").String()) -+ if data.Get("item.type").String() == "reasoning" { -+ doneID = data.Get("item.id").String() -+ doneSignature = data.Get("item.encrypted_content").String() -+ } -+ } -+ if got := strings.Join(doneTypes, ","); got != "reasoning,message" { -+ t.Fatalf("done item order = %q, want reasoning,message", got) -+ } -+ if addedID == "" || addedID != doneID || decodedResponsesCarrierSignature(t, addedSignature) != testResponsesGeminiThoughtSignature || doneSignature != addedSignature { -+ t.Fatalf("reasoning item changed between added and done: added=(%q,%q) done=(%q,%q)", addedID, addedSignature, doneID, doneSignature) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_LateThoughtSignatureIsImmutable(t *testing.T) { -+ signature := differentResponsesGeminiThoughtSignature(t) -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"one","thought":true}]}}],"responseId":"late-thought-signature"}}`, -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"two","thought":true,"thoughtSignature":"` + signature + `"}]},"finishReason":"STOP"}],"responseId":"late-thought-signature"}}`, -+ } -+ var param any -+ var addedID, addedSignature, doneID, doneSignature, doneText string -+ for _, line := range in { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ switch event { -+ case "response.output_item.added": -+ if data.Get("item.type").String() == "reasoning" { -+ addedID = data.Get("item.id").String() -+ addedSignature = data.Get("item.encrypted_content").String() -+ } -+ case "response.output_item.done": -+ if data.Get("item.type").String() == "reasoning" { -+ doneID = data.Get("item.id").String() -+ doneSignature = data.Get("item.encrypted_content").String() -+ doneText = data.Get("item.summary.0.text").String() -+ } -+ } -+ } -+ } -+ if addedID == "" || addedID != doneID || decodedResponsesCarrierSignature(t, addedSignature) != signature || doneSignature != addedSignature || doneText != "onetwo" { -+ t.Fatalf("late thought signature replay malformed: added=(%q,%q) done=(%q,%q,%q)", addedID, addedSignature, doneID, doneSignature, doneText) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_DoneFlushesUnsignedReasoningWithoutCompletion(t *testing.T) { -+ var param any -+ var out [][]byte -+ out = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(`data: {"response":{"candidates":[{"content":{"parts":[{"text":"unsigned thought","thought":true}]}}],"responseId":"done-flush"}}`), ¶m)...) -+ out = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte("[DONE]"), ¶m)...) -+ var addedSignature string -+ var deltas []string -+ doneCount := 0 -+ completedCount := 0 -+ for _, chunk := range out { -+ event, data := parseSSEEvent(t, chunk) -+ switch event { -+ case "response.output_item.added": -+ if data.Get("item.type").String() == "reasoning" { -+ addedSignature = data.Get("item.encrypted_content").String() -+ } -+ case "response.reasoning_summary_text.delta": -+ deltas = append(deltas, data.Get("delta").String()) -+ case "response.output_item.done": -+ doneCount++ -+ case "response.completed": -+ completedCount++ -+ } -+ } -+ if addedSignature != "" || strings.Join(deltas, "") != "unsigned thought" || doneCount != 0 || completedCount != 0 { -+ t.Fatalf("DONE flush malformed: added=%q deltas=%q done=%d completed=%d", addedSignature, deltas, doneCount, completedCount) -+ } -+ if duplicate := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte("[DONE]"), ¶m); len(duplicate) != 0 { -+ t.Fatalf("duplicate DONE emitted %d events", len(duplicate)) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_VisibleSignatureCompletesReasoning(t *testing.T) { -+ raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"hidden thought","thought":true},{"text":"visible answer","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp_nonstream_active"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ if got := gjson.GetBytes(out, "output.0.type").String(); got != "reasoning" { -+ t.Fatalf("output.0.type = %q, want reasoning; output=%s", got, out) -+ } -+ if got := decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("reasoning signature = %q, want %q; output=%s", got, testResponsesGeminiThoughtSignature, out) -+ } -+ if got := gjson.GetBytes(out, "output.1.type").String(); got != "message" { -+ t.Fatalf("output.1.type = %q, want message; output=%s", got, out) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_PreservesTextAroundFunction(t *testing.T) { -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"preface"}]}}],"modelVersion":"gemini-3.6-flash","responseId":"resp_mixed_stream"}}`, -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"run_command","args":{"command":"true"}}}]}}],"modelVersion":"gemini-3.6-flash","responseId":"resp_mixed_stream"}}`, -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"after"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp_mixed_stream"}}`, -+ } -+ var param any -+ var out [][]byte -+ for _, line := range in { -+ out = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m)...) -+ } -+ var doneTypes []string -+ var completed gjson.Result -+ for _, chunk := range out { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.output_item.done" { -+ doneTypes = append(doneTypes, data.Get("item.type").String()) -+ } -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ if got := strings.Join(doneTypes, ","); got != "message,function_call,message" { -+ t.Fatalf("done item order = %q, want message,function_call,message", got) -+ } -+ if got := completed.Get("0.content.0.text").String(); got != "preface" { -+ t.Fatalf("completed first message = %q", got) -+ } -+ if got := completed.Get("2.content.0.text").String(); got != "after" { -+ t.Fatalf("completed trailing message = %q", got) -+ } -+ -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw)) -+ functionOutput := []byte(`{"type":"function_call_output","call_id":"","output":"ok"}`) -+ functionOutput, _ = sjson.SetBytes(functionOutput, "call_id", completed.Get("1.call_id").String()) -+ request, _ = sjson.SetRawBytes(request, "input.-1", functionOutput) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ contents := gjson.GetBytes(translated, "contents").Array() -+ if len(contents) != 2 || contents[0].Get("role").String() != "model" || contents[1].Get("role").String() != "user" { -+ t.Fatalf("mixed turn round-trip roles malformed: %s", translated) -+ } -+ parts := contents[0].Get("parts").Array() -+ if len(parts) != 3 || parts[0].Get("text").String() != "preface" || !parts[1].Get("functionCall").Exists() || parts[2].Get("text").String() != "after" { -+ t.Fatalf("mixed turn model parts malformed: %s", translated) -+ } -+ if !contents[1].Get("parts.0.functionResponse").Exists() { -+ t.Fatalf("function response must immediately follow the combined model turn: %s", translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_SignedTextBeforeSignedFunctionRoundTrips(t *testing.T) { -+ toolRaw, errDecode := base64.StdEncoding.DecodeString(testResponsesGeminiThoughtSignature) -+ if errDecode != nil { -+ t.Fatal(errDecode) -+ } -+ toolRaw[len(toolRaw)-1] ^= 1 -+ toolSignature := base64.StdEncoding.EncodeToString(toolRaw) -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"before "}]}}],"modelVersion":"gemini-3.6-flash","responseId":"resp_signed_mixed"}}`, -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"tool","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]}}],"modelVersion":"gemini-3.6-flash","responseId":"resp_signed_mixed"}}`, -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"` + toolSignature + `","functionCall":{"name":"run_command","args":{"command":"true"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp_signed_mixed"}}`, -+ } -+ var param any -+ var completed gjson.Result -+ for _, line := range in { -+ for _, chunk := range ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) { -+ event, data := parseSSEEvent(t, chunk) -+ if event == "response.completed" { -+ completed = data.Get("response.output") -+ } -+ } -+ } -+ request := []byte(`{"model":"gemini-3.6-flash-high","input":[]}`) -+ request, _ = sjson.SetRawBytes(request, "input", []byte(completed.Raw)) -+ callID := completed.Get("3.call_id").String() -+ functionOutput := []byte(`{"type":"function_call_output","call_id":"","output":"ok"}`) -+ functionOutput, _ = sjson.SetBytes(functionOutput, "call_id", callID) -+ request, _ = sjson.SetRawBytes(request, "input.-1", functionOutput) -+ translated := ConvertOpenAIResponsesRequestToGemini("gemini-3.6-flash-high", request, false) -+ -+ var textSignature, functionSignature string -+ for _, content := range gjson.GetBytes(translated, "contents").Array() { -+ for _, part := range content.Get("parts").Array() { -+ if part.Get("functionCall").Exists() { -+ functionSignature = part.Get("thoughtSignature").String() -+ } else if part.Get("text").String() == "before tool" { -+ textSignature = part.Get("thoughtSignature").String() -+ } -+ } -+ } -+ if textSignature != testResponsesGeminiThoughtSignature { -+ t.Fatalf("text signature = %q, want %q; translated=%s", textSignature, testResponsesGeminiThoughtSignature, translated) -+ } -+ if functionSignature != toolSignature { -+ t.Fatalf("function signature = %q, want %q; completed=%s translated=%s", functionSignature, toolSignature, completed.Raw, translated) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_PreservesTextAroundSignedFunction(t *testing.T) { -+ raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"preface"},{"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `","functionCall":{"name":"run_command","args":{"command":"true"}}},{"text":"after"}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"resp_nonstream_order"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ if got := gjson.GetBytes(out, "output.0.type").String(); got != "message" { -+ t.Fatalf("output.0.type = %q, want message; output=%s", got, out) -+ } -+ if got := gjson.GetBytes(out, "output.1.type").String(); got != "reasoning" { -+ t.Fatalf("output.1.type = %q, want reasoning; output=%s", got, out) -+ } -+ if got := gjson.GetBytes(out, "output.2.type").String(); got != "function_call" { -+ t.Fatalf("output.2.type = %q, want function_call; output=%s", got, out) -+ } -+ if got := gjson.GetBytes(out, "output.3.type").String(); got != "message" { -+ t.Fatalf("output.3.type = %q, want trailing message; output=%s", got, out) -+ } -+ if got := gjson.GetBytes(out, "output.3.content.0.text").String(); got != "after" { -+ t.Fatalf("trailing message = %q, want after; output=%s", got, out) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_DetachedSignatureAfterVisibleText(t *testing.T) { -+ in := []string{ -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"visible answer"}]}}],"modelVersion":"gemini-3.6-flash","responseId":"resp_detached"}}`, -+ `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":15},"modelVersion":"gemini-3.6-flash","responseId":"resp_detached"}}`, -+ } -+ var param any -+ var out [][]byte -+ for _, line := range in { -+ out = append(out, ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m)...) -+ } -+ var doneTypes []string -+ var doneSignature string -+ var completedOutput gjson.Result -+ for _, chunk := range out { -+ event, data := parseSSEEvent(t, chunk) -+ switch event { -+ case "response.output_item.done": -+ doneTypes = append(doneTypes, data.Get("item.type").String()) -+ if data.Get("item.type").String() == "reasoning" { -+ doneSignature = data.Get("item.encrypted_content").String() -+ } -+ case "response.completed": -+ completedOutput = data.Get("response.output") -+ } -+ } -+ if got := strings.Join(doneTypes, ","); got != "message,reasoning" { -+ t.Fatalf("done item order = %q, want message,reasoning", got) -+ } -+ if decodedResponsesCarrierSignature(t, doneSignature) != testResponsesGeminiThoughtSignature { -+ t.Fatalf("detached signature = %q, want %q", doneSignature, testResponsesGeminiThoughtSignature) -+ } -+ if got := decodedResponsesCarrierSignature(t, completedOutput.Get("1.encrypted_content").String()); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("completed detached signature = %q, want %q; output=%s", got, testResponsesGeminiThoughtSignature, completedOutput.Raw) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponses_GeminiToolSignature(t *testing.T) { -+ line := `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"` + testResponsesGeminiThoughtSignature + `","functionCall":{"id":"native-id","name":"run_command","args":{"command":"true"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":15},"modelVersion":"gemini-3.6-flash","responseId":"resp_tool_sig"}}` -+ var param any -+ out := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash-high", nil, nil, []byte(line), ¶m) -+ var doneTypes []string -+ var signature string -+ for _, chunk := range out { -+ event, data := parseSSEEvent(t, chunk) -+ if event != "response.output_item.done" { -+ continue -+ } -+ doneTypes = append(doneTypes, data.Get("item.type").String()) -+ if data.Get("item.type").String() == "reasoning" { -+ signature = data.Get("item.encrypted_content").String() -+ } -+ } -+ if got := strings.Join(doneTypes, ","); got != "reasoning,function_call" { -+ t.Fatalf("tool signature item order = %q, want reasoning,function_call", got) -+ } -+ if decodedResponsesCarrierSignature(t, signature) != testResponsesGeminiThoughtSignature { -+ t.Fatalf("tool signature = %q, want %q", signature, testResponsesGeminiThoughtSignature) -+ } -+} -+ -+func TestConvertGeminiResponseToOpenAIResponsesNonStream_DetachedSignature(t *testing.T) { -+ raw := []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"visible answer"},{"text":"","thoughtSignature":"` + testResponsesGeminiThoughtSignature + `"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"thoughtsTokenCount":3,"totalTokenCount":15},"modelVersion":"gemini-3.6-flash","responseId":"resp_nonstream_detached"}`) -+ out := ConvertGeminiResponseToOpenAIResponsesNonStream(context.Background(), "gemini-3.6-flash-high", nil, nil, raw, nil) -+ if got := gjson.GetBytes(out, "output.0.type").String(); got != "reasoning" { -+ t.Fatalf("output.0.type = %q, want reasoning; output=%s", got, out) -+ } -+ if got := decodedResponsesCarrierSignature(t, gjson.GetBytes(out, "output.0.encrypted_content").String()); got != testResponsesGeminiThoughtSignature { -+ t.Fatalf("detached signature = %q, want %q; output=%s", got, testResponsesGeminiThoughtSignature, out) -+ } -+ if got := gjson.GetBytes(out, "output.1.type").String(); got != "message" { -+ t.Fatalf("output.1.type = %q, want message; output=%s", got, out) -+ } -+} -+ - func TestConvertGeminiResponseToOpenAIResponses_ReasoningEncryptedContent(t *testing.T) { - sig := "RXE0RENrZ0lDeEFDR0FJcVFOZDdjUzlleGFuRktRdFcvSzNyZ2MvWDNCcDQ4RmxSbGxOWUlOVU5kR1l1UHMrMGdkMVp0Vkg3ekdKU0g4YVljc2JjN3lNK0FrdGpTNUdqamI4T3Z0VVNETzdQd3pmcFhUOGl3U3hXUEJvTVFRQ09mWTFyMEtTWGZxUUlJakFqdmFGWk83RW1XRlBKckJVOVpkYzdDKw==" - in := []string{ -@@ -186,10 +1061,10 @@ func TestConvertGeminiResponseToOpenAIResponses_ReasoningEncryptedContent(t *tes - } - } - -- if addedEnc != sig { -+ if decodedResponsesCarrierSignature(t, addedEnc) != sig { - t.Fatalf("unexpected encrypted_content in response.output_item.added: got %q", addedEnc) - } -- if doneEnc != sig { -+ if doneEnc != addedEnc || decodedResponsesCarrierSignature(t, doneEnc) != sig { - t.Fatalf("unexpected encrypted_content in response.output_item.done: got %q", doneEnc) - } - } -diff --git a/internal/translator/gemini/openai/responses/signature_carrier.go b/internal/translator/gemini/openai/responses/signature_carrier.go -new file mode 100644 -index 00000000000..b7a8542ff31 ---- /dev/null -+++ b/internal/translator/gemini/openai/responses/signature_carrier.go -@@ -0,0 +1,182 @@ -+package responses -+ -+import ( -+ "encoding/base64" -+ "encoding/json" -+ "strings" -+ -+ sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature" -+ "github.com/tidwall/gjson" -+ "github.com/tidwall/sjson" -+) -+ -+const ( -+ geminiResponsesCarrierPrefix = "cpa-gemini-responses-carrier-v1:" -+ geminiResponsesCarrierNext = "next" -+ geminiResponsesCarrierPrevious = "previous" -+ geminiResponsesCarrierStandalone = "standalone" -+ geminiResponsesCarrierText = "text" -+ geminiResponsesCarrierFunction = "function" -+ geminiResponsesCarrierAny = "any" -+ -+ geminiResponsesCarrierDirectionField = "_cpa_reasoning_direction" -+ geminiResponsesCarrierTargetField = "_cpa_reasoning_target" -+ geminiResponsesCarrierSignatureField = "_cpa_reasoning_signature" -+ geminiResponsesCarrierSummaryField = "_cpa_reasoning_summary" -+) -+ -+func encodeGeminiResponsesCarrier(rawSignature, direction, targetKind string) string { -+ rawSignature = strings.TrimSpace(rawSignature) -+ if rawSignature == "" { -+ return "" -+ } -+ return geminiResponsesCarrierPrefix + direction + ":" + targetKind + ":" + base64.RawStdEncoding.EncodeToString([]byte(rawSignature)) -+} -+ -+func decodeGeminiResponsesCarrier(rawSignature string) (signatureValue, direction, targetKind string, marked, ok bool) { -+ rawSignature = strings.TrimSpace(rawSignature) -+ if !strings.HasPrefix(rawSignature, geminiResponsesCarrierPrefix) { -+ return rawSignature, "", "", false, true -+ } -+ marked = true -+ if len(rawSignature) > (sigcompat.MaxGeminiThoughtSignatureLen*4/3)+1024 { -+ return "", "", "", true, false -+ } -+ fields := strings.SplitN(strings.TrimPrefix(rawSignature, geminiResponsesCarrierPrefix), ":", 3) -+ if len(fields) != 3 { -+ return "", "", "", true, false -+ } -+ direction, targetKind = fields[0], fields[1] -+ switch direction { -+ case geminiResponsesCarrierNext, geminiResponsesCarrierPrevious, geminiResponsesCarrierStandalone: -+ default: -+ return "", "", "", true, false -+ } -+ switch targetKind { -+ case geminiResponsesCarrierText, geminiResponsesCarrierFunction, geminiResponsesCarrierAny: -+ default: -+ return "", "", "", true, false -+ } -+ decoded, errDecode := base64.RawStdEncoding.DecodeString(fields[2]) -+ if errDecode != nil || len(decoded) == 0 || strings.HasPrefix(string(decoded), geminiResponsesCarrierPrefix) { -+ return "", "", "", true, false -+ } -+ return string(decoded), direction, targetKind, true, true -+} -+ -+func compatibleGeminiResponsesCarrierSignature(rawSignature, targetKind string) (string, bool) { -+ blockKind := sigcompat.SignatureBlockKindGeminiModelPart -+ if targetKind == geminiResponsesCarrierFunction { -+ blockKind = sigcompat.SignatureBlockKindGeminiFunctionCall -+ } -+ normalized, compatible := sigcompat.CompatibleSignatureForProviderBlock(sigcompat.SignatureProviderGemini, rawSignature, blockKind) -+ if !compatible || sigcompat.IsGeminiThoughtSignatureBypass(sigcompat.SignaturePayloadWithoutProviderPrefix(normalized)) { -+ return "", false -+ } -+ return normalized, true -+} -+ -+func geminiResponsesCarrierSemanticTarget(item gjson.Result) string { -+ switch item.Get("type").String() { -+ case "function_call": -+ return geminiResponsesCarrierFunction -+ case "reasoning": -+ if strings.TrimSpace(item.Get("summary.0.text").String()) != "" { -+ return geminiResponsesCarrierText -+ } -+ } -+ if _, ok := openAIResponsesAssistantVisibleText(item); ok { -+ return geminiResponsesCarrierText -+ } -+ return "" -+} -+ -+func geminiResponsesCarrierMatchesAdjacent(items []gjson.Result, index int, direction, targetKind string) bool { -+ step := 1 -+ if direction == geminiResponsesCarrierPrevious { -+ step = -1 -+ } -+ for adjacent := index + step; adjacent >= 0 && adjacent < len(items); adjacent += step { -+ if kind := geminiResponsesCarrierSemanticTarget(items[adjacent]); kind != "" { -+ return targetKind == geminiResponsesCarrierAny || targetKind == kind -+ } -+ if !isOpenAIResponsesDetachedCarrier(items[adjacent]) { -+ return false -+ } -+ } -+ return false -+} -+ -+func stripGeminiResponsesCarrierMetadata(itemJSON []byte) []byte { -+ var fields map[string]json.RawMessage -+ if err := json.Unmarshal(itemJSON, &fields); err != nil { -+ return itemJSON -+ } -+ delete(fields, geminiResponsesCarrierDirectionField) -+ delete(fields, geminiResponsesCarrierTargetField) -+ delete(fields, geminiResponsesCarrierSignatureField) -+ delete(fields, geminiResponsesCarrierSummaryField) -+ stripped, errMarshal := json.Marshal(fields) -+ if errMarshal != nil { -+ return itemJSON -+ } -+ return stripped -+} -+ -+func normalizeGeminiResponsesCarriers(items []gjson.Result) ([]gjson.Result, bool) { -+ normalized := make([]gjson.Result, 0, len(items)) -+ hasValidCarrier := false -+ for itemIndex, originalItem := range items { -+ itemJSON := stripGeminiResponsesCarrierMetadata([]byte(originalItem.Raw)) -+ item := gjson.ParseBytes(itemJSON) -+ if item.Get("type").String() != "reasoning" { -+ normalized = append(normalized, item) -+ continue -+ } -+ rawSignature := strings.TrimSpace(item.Get("encrypted_content").String()) -+ signature, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(rawSignature) -+ if !marked { -+ if rawSignature != "" { -+ _, hasCompatibleRawCarrier := compatibleGeminiResponsesCarrierSignature(rawSignature, geminiResponsesCarrierAny) -+ hasValidCarrier = hasValidCarrier || hasCompatibleRawCarrier -+ } -+ normalized = append(normalized, item) -+ continue -+ } -+ if ok { -+ signature, ok = compatibleGeminiResponsesCarrierSignature(signature, targetKind) -+ } -+ if ok && direction != geminiResponsesCarrierStandalone { -+ ok = geminiResponsesCarrierMatchesAdjacent(items, itemIndex, direction, targetKind) -+ } -+ isDetached := isOpenAIResponsesDetachedCarrier(item) -+ hasSummary := strings.TrimSpace(item.Get("summary.0.text").String()) != "" -+ validSummaryCarrier := hasSummary && ((direction == geminiResponsesCarrierStandalone && (targetKind == geminiResponsesCarrierText || targetKind == geminiResponsesCarrierAny)) || direction == geminiResponsesCarrierNext) -+ if !ok || (!isDetached && !validSummaryCarrier) { -+ if strings.TrimSpace(item.Get("summary.0.text").String()) == "" { -+ continue -+ } -+ itemJSON, _ = sjson.DeleteBytes(itemJSON, "encrypted_content") -+ normalized = append(normalized, gjson.ParseBytes(itemJSON)) -+ continue -+ } -+ hasValidCarrier = true -+ itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", signature) -+ itemJSON, _ = sjson.SetBytes(itemJSON, geminiResponsesCarrierDirectionField, direction) -+ itemJSON, _ = sjson.SetBytes(itemJSON, geminiResponsesCarrierTargetField, targetKind) -+ normalized = append(normalized, gjson.ParseBytes(itemJSON)) -+ } -+ return normalized, hasValidCarrier -+} -+ -+func geminiResponsesCarrierDirection(item gjson.Result) string { -+ return item.Get(geminiResponsesCarrierDirectionField).String() -+} -+ -+func geminiResponsesCarrierTarget(item gjson.Result) string { -+ return item.Get(geminiResponsesCarrierTargetField).String() -+} -+ -+func isOpenAIResponsesDetachedCarrier(item gjson.Result) bool { -+ return item.Get("type").String() == "reasoning" && strings.TrimSpace(item.Get("encrypted_content").String()) != "" && strings.TrimSpace(item.Get("summary.0.text").String()) == "" -+} -diff --git a/internal/translator/gemini/openai/responses/signature_carrier_test.go b/internal/translator/gemini/openai/responses/signature_carrier_test.go -new file mode 100644 -index 00000000000..b2d2a0aac44 ---- /dev/null -+++ b/internal/translator/gemini/openai/responses/signature_carrier_test.go -@@ -0,0 +1,157 @@ -+package responses -+ -+import ( -+ "context" -+ "encoding/base64" -+ "strconv" -+ "strings" -+ "testing" -+ -+ "github.com/tidwall/gjson" -+ "google.golang.org/protobuf/encoding/protowire" -+) -+ -+func TestGeminiResponsesCarrierRoundTrip(t *testing.T) { -+ for _, testCase := range []struct { -+ direction string -+ targetKind string -+ }{ -+ {geminiResponsesCarrierNext, geminiResponsesCarrierText}, -+ {geminiResponsesCarrierPrevious, geminiResponsesCarrierFunction}, -+ {geminiResponsesCarrierStandalone, geminiResponsesCarrierAny}, -+ } { -+ encoded := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, testCase.direction, testCase.targetKind) -+ signature, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(encoded) -+ if !marked || !ok || signature != testResponsesGeminiThoughtSignature || direction != testCase.direction || targetKind != testCase.targetKind { -+ t.Fatalf("carrier round-trip = %q/%q/%q marked=%v ok=%v", signature, direction, targetKind, marked, ok) -+ } -+ } -+} -+ -+func TestNormalizeGeminiResponsesCarriersDropsMalformedEnvelope(t *testing.T) { -+ items := gjson.Parse(`[{"type":"reasoning","encrypted_content":"` + geminiResponsesCarrierPrefix + `previous:text:not-base64!","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"safe"}]}]`).Array() -+ normalized, hasCarrier := normalizeGeminiResponsesCarriers(items) -+ if hasCarrier || len(normalized) != 1 || normalized[0].Get("type").String() != "message" || strings.Contains(normalized[0].Raw, geminiResponsesCarrierPrefix) { -+ t.Fatalf("malformed carrier was preserved: %v", normalized) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_DecodesCarrierForAliasModel(t *testing.T) { -+ carrier := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText) -+ request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"reasoning","encrypted_content":"` + carrier + `","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`) -+ translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false) -+ part := gjson.GetBytes(translated, "contents.0.parts.0") -+ if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierPrefix) { -+ t.Fatalf("alias model did not decode carrier: %s", translated) -+ } -+} -+ -+func TestGeminiResponsesWrappedUUIDFunctionSignatureRoundTrip(t *testing.T) { -+ const providerUUID = "e24830a7-5cd6-42fe-998b-ee539e72b9c3" -+ inner := protowire.AppendTag(nil, 1, protowire.BytesType) -+ inner = protowire.AppendBytes(inner, []byte(providerUUID)) -+ outer := protowire.AppendTag(nil, 2, protowire.BytesType) -+ outer = protowire.AppendBytes(outer, inner) -+ signature := base64.StdEncoding.EncodeToString(outer) -+ -+ providerResponse := `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"` + signature + `","functionCall":{"id":"native-call","name":"run","args":{"command":"true"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"wrapped-uuid"}}` -+ var state any -+ chunks := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash", []byte(`{"model":"alias-without-provider-name"}`), nil, []byte(providerResponse), &state) -+ clientItems := make([]string, 0, 2) -+ callID := "" -+ for _, chunk := range chunks { -+ event, data := parseSSEEvent(t, chunk) -+ if event != "response.output_item.done" { -+ continue -+ } -+ item := data.Get("item") -+ switch item.Get("type").String() { -+ case "reasoning": -+ decoded, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(item.Get("encrypted_content").String()) -+ if !marked || !ok || decoded != signature || direction != geminiResponsesCarrierNext || targetKind != geminiResponsesCarrierFunction { -+ t.Fatalf("provider signature carrier = marked:%v ok:%v direction:%q target:%q", marked, ok, direction, targetKind) -+ } -+ clientItems = append(clientItems, item.Raw) -+ case "function_call": -+ callID = item.Get("call_id").String() -+ clientItems = append(clientItems, item.Raw) -+ } -+ } -+ if len(clientItems) != 2 || callID == "" { -+ t.Fatalf("Responses client items = %v, call ID present=%v", clientItems, callID != "") -+ } -+ clientItems = append(clientItems, `{"type":"function_call_output","call_id":`+strconv.Quote(callID)+`,"output":"ok"}`) -+ request := []byte(`{"model":"alias-without-provider-name","input":[` + strings.Join(clientItems, ",") + `]}`) -+ -+ translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false) -+ var functionPart gjson.Result -+ gjson.GetBytes(translated, "contents").ForEach(func(_, content gjson.Result) bool { -+ content.Get("parts").ForEach(func(_, part gjson.Result) bool { -+ if part.Get("functionCall").Exists() { -+ functionPart = part -+ return false -+ } -+ return true -+ }) -+ return !functionPart.Exists() -+ }) -+ if !functionPart.Exists() || functionPart.Get("functionCall.name").String() != "run" || functionPart.Get("functionCall.args.command").String() != "true" { -+ t.Fatalf("function carrier did not bind to the native call: %s", translated) -+ } -+ if got := functionPart.Get("thoughtSignature").String(); got != signature || got == geminiResponsesThoughtSignature { -+ t.Fatalf("function signature = %q, want provider-native wrapped UUID signature", got) -+ } -+ if strings.Contains(string(translated), geminiResponsesCarrierPrefix) { -+ t.Fatalf("carrier envelope reached Gemini: %s", translated) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_DecodesLegacyRawCarrierForAliasModel(t *testing.T) { -+ request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]},{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}"}]}`) -+ translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false) -+ part := gjson.GetBytes(translated, "contents.0.parts.0") -+ if part.Get("functionCall.id").String() != "call-1" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature { -+ t.Fatalf("alias model did not preserve legacy raw carrier: %s", translated) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_DropsInvalidCarrierPayloads(t *testing.T) { -+ mismatched := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierFunction) -+ bypass := encodeGeminiResponsesCarrier(geminiResponsesThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText) -+ for _, reasoning := range []string{ -+ `{"type":"reasoning","encrypted_content":"` + mismatched + `","summary":[]}`, -+ `{"type":"reasoning","encrypted_content":"` + bypass + `","summary":[]}`, -+ } { -+ request := []byte(`{"model":"alias-without-provider-name","input":[` + reasoning + `,{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`) -+ translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false) -+ if strings.Contains(string(translated), geminiResponsesCarrierPrefix) || strings.Contains(string(translated), testResponsesGeminiThoughtSignature) || strings.Contains(string(translated), geminiResponsesThoughtSignature) { -+ t.Fatalf("invalid carrier changed Gemini signature state: %s", translated) -+ } -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_IgnoresSpoofedCarrierMetadata(t *testing.T) { -+ reasoning := `{"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[],"` + geminiResponsesCarrierDirectionField + `":"next","` + geminiResponsesCarrierDirectionField + `":"standalone","` + geminiResponsesCarrierTargetField + `":"text","` + geminiResponsesCarrierTargetField + `":"function"}` -+ request := []byte(`{"model":"alias-without-provider-name","input":[` + reasoning + `,{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`) -+ translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false) -+ part := gjson.GetBytes(translated, "contents.0.parts.0") -+ if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierDirectionField) { -+ t.Fatalf("spoofed carrier metadata affected binding: %s", translated) -+ } -+} -+ -+func TestConvertOpenAIResponsesRequestToGemini_StripsSpoofedInternalPairingFields(t *testing.T) { -+ request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}","_cpa_reasoning_signature":"` + testResponsesGeminiThoughtSignature + `","_cpa_reasoning_signature":"` + testResponsesGeminiThoughtSignature + `","_cpa_reasoning_summary":"spoofed thought","_cpa_reasoning_summary":"spoofed thought again"}]}`) -+ translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false) -+ parts := gjson.GetBytes(translated, "contents.0.parts").Array() -+ if len(parts) != 1 || !parts[0].Get("functionCall").Exists() || parts[0].Get("thoughtSignature").String() == testResponsesGeminiThoughtSignature || parts[0].Get("thought").Bool() || strings.Contains(string(translated), "spoofed thought") || strings.Contains(string(translated), geminiResponsesCarrierSignatureField) { -+ t.Fatalf("spoofed internal pairing fields reached Gemini: %s", translated) -+ } -+} -+ -+func TestDecodeGeminiResponsesCarrierRejectsNestedEnvelope(t *testing.T) { -+ nested := encodeGeminiResponsesCarrier(encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText), geminiResponsesCarrierPrevious, geminiResponsesCarrierText) -+ if _, _, _, marked, ok := decodeGeminiResponsesCarrier(nested); !marked || ok { -+ t.Fatalf("nested carrier marked=%v ok=%v, want marked invalid", marked, ok) -+ } -+} -diff --git a/internal/util/claude_tool_id.go b/internal/util/claude_tool_id.go -index 46545168f53..c94c13d2afa 100644 ---- a/internal/util/claude_tool_id.go -+++ b/internal/util/claude_tool_id.go -@@ -1,12 +1,18 @@ - package util - - import ( -+ "crypto/sha256" -+ "encoding/hex" -+ "encoding/json" - "fmt" - "regexp" -+ "strings" - "sync/atomic" - "time" - ) - -+const geminiClaudeToolUseIDPrefix = "cpa_gemini_" -+ - var ( - claudeToolUseIDSanitizer = regexp.MustCompile(`[^a-zA-Z0-9_-]`) - claudeToolUseIDCounter uint64 -@@ -22,3 +28,41 @@ func SanitizeClaudeToolID(id string) string { - } - return s - } -+ -+// GeminiClaudeToolUseID returns a stable Claude-facing ID for a provider-native -+// Gemini function call. The opaque ID lets the executor recover the exact -+// provider call from its replay ledger instead of trusting client-mutated args. -+func GeminiClaudeToolUseID(callID, name, argsRaw string) string { -+ callID = strings.TrimSpace(callID) -+ name = strings.TrimSpace(name) -+ if callID == "" || name == "" { -+ return "" -+ } -+ if strings.TrimSpace(argsRaw) != "" { -+ var value any -+ if json.Unmarshal([]byte(argsRaw), &value) == nil { -+ if canonical, errMarshal := json.Marshal(value); errMarshal == nil { -+ argsRaw = string(canonical) -+ } -+ } else { -+ argsRaw = strings.TrimSpace(argsRaw) -+ } -+ } -+ sum := sha256.Sum256([]byte(strings.Join([]string{callID, name, argsRaw}, "\x00"))) -+ return geminiClaudeToolUseIDPrefix + hex.EncodeToString(sum[:16]) -+} -+ -+// IsGeminiClaudeToolUseID reports whether id belongs to the reserved -+// Claude-facing Gemini provenance namespace. -+func IsGeminiClaudeToolUseID(id string) bool { -+ id = strings.TrimSpace(id) -+ if !strings.HasPrefix(id, geminiClaudeToolUseIDPrefix) { -+ return false -+ } -+ digest := strings.TrimPrefix(id, geminiClaudeToolUseIDPrefix) -+ if len(digest) != 32 { -+ return false -+ } -+ _, errDecode := hex.DecodeString(digest) -+ return errDecode == nil -+} -diff --git a/internal/util/claude_tool_id_test.go b/internal/util/claude_tool_id_test.go -new file mode 100644 -index 00000000000..f1950e24a16 ---- /dev/null -+++ b/internal/util/claude_tool_id_test.go -@@ -0,0 +1,21 @@ -+package util -+ -+import "testing" -+ -+func TestGeminiClaudeToolUseIDStableAndBound(t *testing.T) { -+ args := `{"file_path":"/tmp/a","old_string":"x","new_string":"y"}` -+ first := GeminiClaudeToolUseID("native-call-1", "Edit", args) -+ second := GeminiClaudeToolUseID("native-call-1", "Edit", `{"new_string":"y","old_string":"x","file_path":"/tmp/a"}`) -+ if first == "" || first != second || !IsGeminiClaudeToolUseID(first) { -+ t.Fatalf("stable tool id mismatch: first=%q second=%q", first, second) -+ } -+ if changed := GeminiClaudeToolUseID("native-call-1", "Edit", `{"file_path":"/tmp/a","old_string":"x","new_string":"z"}`); changed == first { -+ t.Fatal("tool id must be bound to native call semantics") -+ } -+ if GeminiClaudeToolUseID("", "Edit", args) != "" { -+ t.Fatal("ID-less provider calls must keep the existing fallback path") -+ } -+ if IsGeminiClaudeToolUseID("toolu_client_value") { -+ t.Fatal("ordinary client tool IDs must not be treated as CPA provenance IDs") -+ } -+} diff --git a/pkgs-set/pkgs/cliproxy-responses-done.patch b/pkgs-set/pkgs/cliproxy-responses-done.patch deleted file mode 100644 index ac23078..0000000 --- a/pkgs-set/pkgs/cliproxy-responses-done.patch +++ /dev/null @@ -1,49 +0,0 @@ -diff --git a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go -index 36d30df..fd9edd7 100644 ---- a/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go -+++ b/internal/translator/gemini/openai/responses/gemini_openai-responses_response.go -@@ -19,6 +19,7 @@ type geminiToResponsesState struct { - ResponseID string - CreatedAt int64 - Started bool -+ Completed bool - - // message aggregation - MsgOpened bool -@@ -100,6 +101,9 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - } - } - st := (*param).(*geminiToResponsesState) -+ if st.Completed { -+ return [][]byte{} -+ } - if st.FuncArgsBuf == nil { - st.FuncArgsBuf = make(map[int]*strings.Builder) - } -@@ -121,9 +125,17 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - } - - rawJSON = bytes.TrimSpace(rawJSON) -- if len(rawJSON) == 0 || bytes.Equal(rawJSON, []byte("[DONE]")) { -+ if len(rawJSON) == 0 { - return [][]byte{} - } -+ if bytes.Equal(rawJSON, []byte("[DONE]")) { -+ if !st.Started { -+ return [][]byte{} -+ } -+ // Some Gemini streaming routes terminate with [DONE] without sending -+ // a final candidate chunk. Reuse the normal STOP finalization path. -+ rawJSON = []byte(`{"candidates":[{"finishReason":"STOP"}]}`) -+ } - - root := gjson.ParseBytes(rawJSON) - if !root.Exists() { -@@ -562,6 +574,7 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string, - } - - out = append(out, emitEvent("response.completed", completed)) -+ st.Completed = true - } - - return out diff --git a/pkgs-set/pkgs/cliproxyapi.nix b/pkgs-set/pkgs/cliproxyapi.nix index 3b79cf1..ad01679 100644 --- a/pkgs-set/pkgs/cliproxyapi.nix +++ b/pkgs-set/pkgs/cliproxyapi.nix @@ -1,6 +1,6 @@ { buildGoModule, - fetchFromGitHub, + fetchgit, lib, autoPatchelfHook, ... @@ -8,19 +8,17 @@ buildGoModule { pname = "cliproxyapi"; - version = "7.2.97-pr4525-opus5-gemini36-codex-namespace"; + version = "7.2.104-kimi-codex-spark"; - src = fetchFromGitHub { - owner = "router-for-me"; - repo = "CLIProxyAPI"; - rev = "42f36b94e0805a9897c3aa3be46a2b124be0057e"; - hash = "sha256-Jpcz3wjhXWQJuDpJkhLU4HfDIBRebv921NOle3YvSTo="; + src = fetchgit { + url = "https://knot.gaze.systems/did:plc:tscon7kalxdukaw5nhantswx"; + rev = "ff108acac74f5866d330848a373a4dcdcc487fdb"; + hash = "sha256-Fh720UN67SSDo2vKjBmsdLPF0FLOhZ+lmcQ1BheyVdU="; }; - vendorHash = "sha256-OKZQ+Lyt3nwUhJMNE3zmwXN9vEl9Hx4W/CrzAhHfBTg="; + vendorHash = "sha256-CrDp7MOr+AwJUhTovklXx3F1yaktQlvD7VYhYSY6VvY="; subPackages = [ "cmd/server" ]; - patches = [ ./cliproxy-pr-4525.patch ./cliproxy-opus-5.patch ./cliproxy-gemini-3.6-flash.patch ./cliproxy-codex-namespace.patch ]; - ldflags = [ "-s" "-w" "-X main.Version=7.2.97-pr4525-opus5-gemini36-codex-namespace" ]; + ldflags = [ "-s" "-w" "-X main.Version=7.2.104-kimi-codex-spark" ]; # nativeBuildInputs = [autoPatchelfHook];