From 77edfe4aefd3c7b54d9a088bf07395e908532966 Mon Sep 17 00:00:00 2001 From: dawn <90008@klbr.net> Date: Mon, 27 Jul 2026 17:34:44 +0300 Subject: [PATCH] awa --- hosts/pupos/default.nix | 1 + hosts/pupos/modules/cliproxy.nix | 2 +- pkgs-set/pkgs/cliproxy-codex-namespace.patch | 73 + pkgs-set/pkgs/cliproxy-opus-5.patch | 26 + pkgs-set/pkgs/cliproxy-pr-4525.patch | 10647 +++++++++++++++++ pkgs-set/pkgs/cliproxyapi.nix | 9 +- 6 files changed, 10754 insertions(+), 4 deletions(-) create mode 100644 pkgs-set/pkgs/cliproxy-codex-namespace.patch create mode 100644 pkgs-set/pkgs/cliproxy-opus-5.patch create mode 100644 pkgs-set/pkgs/cliproxy-pr-4525.patch diff --git a/hosts/pupos/default.nix b/hosts/pupos/default.nix index 234e110..7608789 100644 --- a/hosts/pupos/default.nix +++ b/hosts/pupos/default.nix @@ -48,6 +48,7 @@ enable = true; controlServer = "https://vpn.klbr.net"; authKeyFile = "/home/dawn/.config/tailscale/ours-authkey"; + extraUpFlags = ["--ssh"]; port = 1059; }; }; diff --git a/hosts/pupos/modules/cliproxy.nix b/hosts/pupos/modules/cliproxy.nix index b95bd6f..4860822 100644 --- a/hosts/pupos/modules/cliproxy.nix +++ b/hosts/pupos/modules/cliproxy.nix @@ -18,7 +18,7 @@ User = "dawn"; Group = "users"; WorkingDirectory = "/var/lib/cliproxy"; - ExecStart = "${terra.cliproxyapi}/bin/cli-proxy-api -config /var/lib/cliproxy/config.yaml"; + ExecStart = "${terra.cliproxyapi}/bin/cli-proxy-api -config /var/lib/cliproxy/config.yaml -local-model"; Restart = "on-failure"; RestartSec = 5; NoNewPrivileges = true; diff --git a/pkgs-set/pkgs/cliproxy-codex-namespace.patch b/pkgs-set/pkgs/cliproxy-codex-namespace.patch new file mode 100644 index 0000000..95c30bf --- /dev/null +++ b/pkgs-set/pkgs/cliproxy-codex-namespace.patch @@ -0,0 +1,73 @@ +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-opus-5.patch b/pkgs-set/pkgs/cliproxy-opus-5.patch new file mode 100644 index 0000000..71b7948 --- /dev/null +++ b/pkgs-set/pkgs/cliproxy-opus-5.patch @@ -0,0 +1,26 @@ +--- 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 new file mode 100644 index 0000000..82ff274 --- /dev/null +++ b/pkgs-set/pkgs/cliproxy-pr-4525.patch @@ -0,0 +1,10647 @@ +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/cliproxyapi.nix b/pkgs-set/pkgs/cliproxyapi.nix index 509633c..71a2ce5 100644 --- a/pkgs-set/pkgs/cliproxyapi.nix +++ b/pkgs-set/pkgs/cliproxyapi.nix @@ -2,12 +2,13 @@ buildGoModule, fetchFromGitHub, lib, + autoPatchelfHook, ... }: buildGoModule { pname = "cliproxyapi"; - version = "7.2.97-patched"; + version = "7.2.97-pr4525-opus5-codex-namespace"; src = fetchFromGitHub { owner = "router-for-me"; @@ -16,10 +17,12 @@ buildGoModule { hash = "sha256-Jpcz3wjhXWQJuDpJkhLU4HfDIBRebv921NOle3YvSTo="; }; - patches = [ ./cliproxy-responses-done.patch ]; vendorHash = "sha256-OKZQ+Lyt3nwUhJMNE3zmwXN9vEl9Hx4W/CrzAhHfBTg="; subPackages = [ "cmd/server" ]; - ldflags = [ "-s" "-w" "-X main.Version=7.2.97-patched" ]; + patches = [ ./cliproxy-pr-4525.patch ./cliproxy-opus-5.patch ./cliproxy-codex-namespace.patch ]; + ldflags = [ "-s" "-w" "-X main.Version=7.2.97-pr4525-opus5-codex-namespace" ]; + + # nativeBuildInputs = [autoPatchelfHook]; postInstall = "mv $out/bin/server $out/bin/cli-proxy-api"; } -- 2.51.2