From f48970b43ad56897814f0f50349c1f1a04f0caba Mon Sep 17 00:00:00 2001 From: N Rohith Reddy Date: Thu, 16 Jul 2026 16:06:08 -0400 Subject: [PATCH 1/5] fix(proxy): enable Gemini SWITCH bounded-cost handover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ProviderSummarizer rejected FormatGemini at PrepareAnthropic, so every Gemini SWITCH fell back to full history. Add Gemini→Anthropic summarizer ingest and strip orphan functionResponse on RewriteEnvelope so mid-tool switches stay Google-legal. Fixes #755 Signed-off-by: N Rohith Reddy Co-authored-by: Cursor --- internal/proxy/gemini_handover_test.go | 138 ++++++++++++ internal/proxy/handover_internal_test.go | 24 ++ internal/proxy/turnloop.go | 3 +- internal/router/handover/summarizer_test.go | 67 ++++++ .../translate/crossformat_request_test.go | 34 ++- internal/translate/emit_anthropic.go | 5 + .../translate/emit_anthropic_from_gemini.go | 213 ++++++++++++++++++ internal/translate/handover.go | 47 +++- 8 files changed, 524 insertions(+), 7 deletions(-) create mode 100644 internal/proxy/gemini_handover_test.go create mode 100644 internal/translate/emit_anthropic_from_gemini.go diff --git a/internal/proxy/gemini_handover_test.go b/internal/proxy/gemini_handover_test.go new file mode 100644 index 000000000..cff5e5522 --- /dev/null +++ b/internal/proxy/gemini_handover_test.go @@ -0,0 +1,138 @@ +package proxy_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "workweave/router/internal/providers" + "workweave/router/internal/proxy" + "workweave/router/internal/router" + "workweave/router/internal/router/sessionpin" + "workweave/router/internal/translate" +) + +// Gemini SWITCH with a working summarizer must RewriteEnvelope so the +// forwarded body is summary-bounded (not full history). +func TestGeminiSwitch_SummarizerBoundsHistory(t *testing.T) { + t.Parallel() + + chunk := strings.Repeat("aaaa ", 8000) + body := []byte(`{ + "model":"gemini-3.1-pro-preview", + "contents":[ + {"role":"user","parts":[{"text":"` + chunk + `"}]}, + {"role":"model","parts":[{"text":"ack"}]}, + {"role":"user","parts":[{"text":"now continue with step 2"}]} + ] +}`) + + store := newFakePinStore() + store.hasPin = true + store.pin = sessionpin.Pin{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-pro-preview", + Reason: "cluster:v0.2", + PinnedUntil: time.Now().Add(time.Hour), + LastInputTokens: 10000, + LastTurnEndedAt: time.Now().Add(-30 * time.Second), + } + fr := &fakeRouter{decision: router.Decision{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-flash-lite-preview", + Reason: "cluster:v0.2", + }} + sz := &fakeSummarizer{summary: "Prior Gemini chat summarized."} + googleUp := &fakeProvider{} + + svc := proxy.NewService( + fr, + map[string]providers.Client{providers.ProviderGoogle: googleUp}, + nil, false, nil, store, false, + providers.ProviderGoogle, "gemini-3.1-flash-lite-preview", nil, + ).WithSummarizer(sz) + + ctx := authedCtx(uuid.New().String()) + rec := httptest.NewRecorder() + httpReq := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.1-pro-preview:generateContent", strings.NewReader("")) + require.NoError(t, svc.ProxyGeminiGenerateContent(ctx, body, rec, httpReq)) + + assert.Equal(t, "gemini-3.1-flash-lite-preview", rec.Header().Get(proxy.HeaderRouterModel)) + assert.Equal(t, int32(1), sz.calls.Load(), "summarizer must be invoked on Gemini SWITCH") + require.NotEmpty(t, googleUp.proxyBodies) + + contents := gjson.GetBytes(googleUp.proxyBodies[0], "contents").Array() + require.Len(t, contents, 2, "expect [summary, latestUser]") + assert.Equal(t, "model", contents[0].Get("role").String()) + assert.True(t, strings.HasPrefix(contents[0].Get("parts.0.text").String(), translate.HandoverSummaryTag)) + assert.Equal(t, "user", contents[1].Get("role").String()) + assert.Equal(t, "now continue with step 2", contents[1].Get("parts.0.text").String()) + assert.NotContains(t, string(googleUp.proxyBodies[0]), chunk[:40], "long prior user text must be elided") +} + +// Mid-tool Gemini SWITCH must not forward an orphan functionResponse after rewrite. +func TestGeminiSwitch_MidToolStripsOrphanFunctionResponse(t *testing.T) { + t.Parallel() + + chunk := strings.Repeat("aaaa ", 8000) + body := []byte(`{ + "model":"gemini-3.1-pro-preview", + "contents":[ + {"role":"user","parts":[{"text":"` + chunk + `"}]}, + {"role":"model","parts":[{"functionCall":{"name":"edit","args":{"path":"a.go"}}}]}, + {"role":"user","parts":[{"functionResponse":{"name":"edit","response":{"result":"ok"}}}]} + ] +}`) + + store := newFakePinStore() + store.hasPin = true + store.pin = sessionpin.Pin{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-pro-preview", + Reason: "cluster:v0.2", + PinnedUntil: time.Now().Add(time.Hour), + LastInputTokens: 10000, + LastTurnEndedAt: time.Now().Add(-30 * time.Second), + } + fr := &fakeRouter{decision: router.Decision{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-flash-lite-preview", + Reason: "cluster:v0.2", + }} + sz := &fakeSummarizer{summary: "User asked to edit a.go; tool ran."} + googleUp := &fakeProvider{} + + svc := proxy.NewService( + fr, + map[string]providers.Client{providers.ProviderGoogle: googleUp}, + nil, false, nil, store, false, + providers.ProviderGoogle, "gemini-3.1-flash-lite-preview", nil, + ).WithSummarizer(sz) + + ctx := authedCtx(uuid.New().String()) + rec := httptest.NewRecorder() + httpReq := httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.1-pro-preview:generateContent", strings.NewReader("")) + require.NoError(t, svc.ProxyGeminiGenerateContent(ctx, body, rec, httpReq)) + + require.NotEmpty(t, googleUp.proxyBodies) + contents := gjson.GetBytes(googleUp.proxyBodies[0], "contents") + frCount := 0 + contents.ForEach(func(_, entry gjson.Result) bool { + entry.Get("parts").ForEach(func(_, part gjson.Result) bool { + if part.Get("functionResponse").Exists() { + frCount++ + } + return true + }) + return true + }) + assert.Equal(t, 0, frCount, "orphan functionResponse must not reach Google after handover rewrite") + assert.Equal(t, 1, len(contents.Array()), "functionResponse-only latest user drops; only summary remains") +} diff --git a/internal/proxy/handover_internal_test.go b/internal/proxy/handover_internal_test.go index 251a83a3d..38ded9a9b 100644 --- a/internal/proxy/handover_internal_test.go +++ b/internal/proxy/handover_internal_test.go @@ -164,3 +164,27 @@ func TestProviderSummarizer_NilEnvelopeReturnsError(t *testing.T) { _, _, err := s.Summarize(context.Background(), nil) require.Error(t, err) } + +func TestProviderSummarizer_AcceptsGeminiEnvelope(t *testing.T) { + t.Parallel() + + env, err := translate.ParseGemini([]byte(`{ + "systemInstruction": {"parts": [{"text": "sys"}]}, + "contents": [ + {"role": "user", "parts": [{"text": "edit a.go"}]}, + {"role": "model", "parts": [{"functionCall": {"name": "edit", "args": {"path": "a.go"}}}]}, + {"role": "user", "parts": [{"functionResponse": {"name": "edit", "response": {"result": "ok"}}}]} + ] +}`)) + require.NoError(t, err) + + fake := &fakeHandoverProvider{ + respBody: canonicalAnthropicResponse, + respStatus: http.StatusOK, + } + s := NewProviderSummarizer(fake, "", 200*time.Millisecond) + + got, _, err := s.Summarize(context.Background(), env) + require.NoError(t, err, "ProviderSummarizer must ingest Gemini envelopes for SWITCH handover") + assert.Equal(t, "Refactor in progress: step 1 done, step 2 pending.", got) +} diff --git a/internal/proxy/turnloop.go b/internal/proxy/turnloop.go index 6c234fa01..98f251e67 100644 --- a/internal/proxy/turnloop.go +++ b/internal/proxy/turnloop.go @@ -539,7 +539,8 @@ func (s *Service) runTurnLoop( // MainLoop parity. Kill switch preserves the legacy #82 verbatim-reuse path. // The #82 noisy-embedding concern is stale under only_user_message embed mode: // translate.userPromptTextGJSON strips tool_result blocks from the embed input. - // Switches degrade safely — handover.RewriteEnvelope strips orphaned tool_results. + // Switches degrade safely — handover.RewriteEnvelope strips orphaned + // tool_results / functionResponse parts (Anthropic / Gemini). if !s.scoreToolResultTurns && res.TurnType == turntype.ToolResult && pinFound { decision := pinDecision(pin) res.Decision = decision diff --git a/internal/router/handover/summarizer_test.go b/internal/router/handover/summarizer_test.go index 137c74fd9..6812e90eb 100644 --- a/internal/router/handover/summarizer_test.go +++ b/internal/router/handover/summarizer_test.go @@ -327,6 +327,73 @@ func TestRewriteEnvelope_StripsToolResultsFromLatestUser(t *testing.T) { assert.Equal(t, "assistant", msgs[0].Get("role").String()) } +func TestRewriteEnvelope_GeminiStripsOrphanFunctionResponseFromLatestUser(t *testing.T) { + t.Parallel() + + const body = `{ + "contents": [ + {"role": "user", "parts": [{"text": "run edit"}]}, + {"role": "model", "parts": [{"functionCall": {"name": "edit", "args": {}}}]}, + {"role": "user", "parts": [{"functionResponse": {"name": "edit", "response": {"result": "applied"}}}]} + ] +}` + env, err := translate.ParseGemini([]byte(body)) + require.NoError(t, err) + + elided := handover.RewriteEnvelope(env, "User asked to run edit.") + assert.Equal(t, 3, elided, "functionResponse-only latest user must be dropped") + + prep, err := env.PrepareGemini(http.Header{}, translate.EmitOptions{TargetModel: "gemini-3.1-pro"}) + require.NoError(t, err) + contents := gjson.GetBytes(prep.Body, "contents").Array() + require.Len(t, contents, 1, "only summary when latest user was purely functionResponse") + assert.Equal(t, "model", contents[0].Get("role").String()) + contents[0].Get("parts").ForEach(func(_, part gjson.Result) bool { + assert.False(t, part.Get("functionResponse").Exists()) + return true + }) +} + +func TestRewriteEnvelope_GeminiMixedTextKeepsTextDropsFunctionResponse(t *testing.T) { + t.Parallel() + + const body = `{ + "contents": [ + {"role": "user", "parts": [{"text": "run edit"}]}, + {"role": "model", "parts": [{"functionCall": {"name": "edit", "args": {}}}]}, + {"role": "user", "parts": [ + {"functionResponse": {"name": "edit", "response": {"result": "applied"}}}, + {"text": "also continue with step 2"} + ]} + ] +}` + env, err := translate.ParseGemini([]byte(body)) + require.NoError(t, err) + + handover.RewriteEnvelope(env, "recap") + prep, err := env.PrepareGemini(http.Header{}, translate.EmitOptions{TargetModel: "gemini-3.1-pro"}) + require.NoError(t, err) + + fr, text := 0, "" + gjson.GetBytes(prep.Body, "contents").ForEach(func(_, entry gjson.Result) bool { + if entry.Get("role").String() != "user" { + return true + } + entry.Get("parts").ForEach(func(_, part gjson.Result) bool { + if part.Get("functionResponse").Exists() { + fr++ + } + if t := part.Get("text").String(); t != "" { + text = t + } + return true + }) + return true + }) + assert.Equal(t, 0, fr, "orphan functionResponse must be stripped from mixed latest user") + assert.Equal(t, "also continue with step 2", text) +} + func TestRewriteEnvelope_GeminiCollapsesToSummaryPlusLastUser(t *testing.T) { t.Parallel() diff --git a/internal/translate/crossformat_request_test.go b/internal/translate/crossformat_request_test.go index 59810d759..4e5d360e6 100644 --- a/internal/translate/crossformat_request_test.go +++ b/internal/translate/crossformat_request_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" ) var openAISimpleConversation = []byte(`{ @@ -1141,16 +1142,41 @@ func TestCrossFormat_AnthropicToGemini_EmptyToolInput(t *testing.T) { require.True(t, ok, "args must be a JSON object") assert.Empty(t, args) } -func TestCrossFormat_GeminiToAnthropic_IsUnsupported(t *testing.T) { +func TestCrossFormat_GeminiToAnthropic_TextAndTools(t *testing.T) { body := []byte(`{ "model": "gemini-2.5-pro", - "contents": [{"role": "user", "parts": [{"text": "hello"}]}] + "systemInstruction": {"parts": [{"text": "be helpful"}]}, + "contents": [ + {"role": "user", "parts": [{"text": "edit a.go"}]}, + {"role": "model", "parts": [{"functionCall": {"name": "edit", "args": {"path": "a.go"}}}]}, + {"role": "user", "parts": [{"functionResponse": {"name": "edit", "response": {"result": "ok"}}}]}, + {"role": "user", "parts": [{"text": "thanks"}]} + ] }`) env, err := translate.ParseGemini(body) require.NoError(t, err) - _, err = env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-sonnet-4-20250514"}) - assert.Error(t, err, "Gemini→Anthropic request translation is not implemented and must return an error") + prep, err := env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-haiku-4-5"}) + require.NoError(t, err, "Gemini→Anthropic must succeed for summarizer ingest") + + assert.Equal(t, "claude-haiku-4-5", gjson.GetBytes(prep.Body, "model").String()) + assert.Equal(t, "be helpful", gjson.GetBytes(prep.Body, "system.0.text").String()) + + msgs := gjson.GetBytes(prep.Body, "messages").Array() + require.Len(t, msgs, 4) + assert.Equal(t, "user", msgs[0].Get("role").String()) + assert.Equal(t, "edit a.go", msgs[0].Get("content.0.text").String()) + assert.Equal(t, "assistant", msgs[1].Get("role").String()) + assert.Equal(t, "tool_use", msgs[1].Get("content.0.type").String()) + assert.Equal(t, "edit", msgs[1].Get("content.0.name").String()) + toolID := msgs[1].Get("content.0.id").String() + require.NotEmpty(t, toolID) + assert.Equal(t, "user", msgs[2].Get("role").String()) + assert.Equal(t, "tool_result", msgs[2].Get("content.0.type").String()) + assert.Equal(t, toolID, msgs[2].Get("content.0.tool_use_id").String(), "functionResponse must pair with prior functionCall id") + assert.Equal(t, "ok", msgs[2].Get("content.0.content").String()) + assert.Equal(t, "user", msgs[3].Get("role").String()) + assert.Equal(t, "thanks", msgs[3].Get("content.0.text").String()) } func TestCrossFormat_GeminiToOpenAI_IsUnsupported(t *testing.T) { diff --git a/internal/translate/emit_anthropic.go b/internal/translate/emit_anthropic.go index deb288c3b..f9378fefe 100644 --- a/internal/translate/emit_anthropic.go +++ b/internal/translate/emit_anthropic.go @@ -28,6 +28,11 @@ func (e *RequestEnvelope) PrepareAnthropic(in http.Header, opts EmitOptions) (pr if err != nil { return providers.PreparedRequest{}, fmt.Errorf("marshal anthropic body: %w", err) } + case FormatGemini: + body, err = e.buildAnthropicFromGemini(opts) + if err != nil { + return providers.PreparedRequest{}, fmt.Errorf("build anthropic from gemini: %w", err) + } default: return providers.PreparedRequest{}, fmt.Errorf("unsupported source format for Anthropic emit: %d", e.format) } diff --git a/internal/translate/emit_anthropic_from_gemini.go b/internal/translate/emit_anthropic_from_gemini.go new file mode 100644 index 000000000..3e5b84170 --- /dev/null +++ b/internal/translate/emit_anthropic_from_gemini.go @@ -0,0 +1,213 @@ +package translate + +import ( + "strconv" + + "github.com/tidwall/gjson" +) + +// buildAnthropicFromGemini converts a native Gemini generateContent body into +// an Anthropic Messages request. Used by the handover/compaction summarizer +// (ProviderSummarizer → PrepareAnthropic); product Gemini→non-Google dispatch +// remains deferred at the proxy layer. +func (e *RequestEnvelope) buildAnthropicFromGemini(opts EmitOptions) ([]byte, error) { + jw := newJSONWriter() + jw.Obj() + jw.Key("model") + jw.Str(opts.TargetModel) + + if r := gjson.GetBytes(e.body, "stream"); r.Exists() { + jw.Key("stream") + jw.Raw(r.Raw) + } + + writeAnthropicFromGeminiContents(jw, e.body) + writeAnthropicMaxTokens(jw, e.body, opts.TargetModel) + + jw.EndObj() + return jw.Bytes(), nil +} + +// writeAnthropicFromGeminiContents maps systemInstruction + contents[] onto +// Anthropic system + messages. functionCall/functionResponse become +// tool_use/tool_result with synthetic ids paired by tool name order. +func writeAnthropicFromGeminiContents(jw *jsonWriter, body []byte) { + if sys := geminiSystemText(body); sys != "" { + sb := newJSONWriter() + sb.Obj() + sb.Key("type") + sb.Str("text") + sb.Key("text") + sb.Str(sys) + sb.EndObj() + jw.Key("system") + jw.Arr() + jw.Raw(string(sb.Bytes())) + jw.EndArr() + } + + contents := gjson.GetBytes(body, "contents") + if !contents.IsArray() { + return + } + + // name → most recent synthetic tool_use id, so functionResponse can pair. + toolIDsByName := make(map[string]string) + toolSeq := 0 + + jw.Key("messages") + jw.Arr() + contents.ForEach(func(_, entry gjson.Result) bool { + role := entry.Get("role").String() + parts := entry.Get("parts") + switch role { + case "model": + raw, next := buildAnthropicAssistantFromGeminiParts(parts, toolIDsByName, toolSeq) + toolSeq = next + if raw != "" { + jw.Raw(raw) + } + default: // "user", empty, or unrecognized — treat as user + if raw := buildAnthropicUserFromGeminiParts(parts, toolIDsByName); raw != "" { + jw.Raw(raw) + } + } + return true + }) + jw.EndArr() +} + +func buildAnthropicAssistantFromGeminiParts(parts gjson.Result, toolIDsByName map[string]string, toolSeq int) (string, int) { + if !parts.IsArray() { + return "", toolSeq + } + var blocks []string + parts.ForEach(func(_, part gjson.Result) bool { + if text := part.Get("text").String(); text != "" { + blocks = append(blocks, anthropicTextBlock(text)) + return true + } + call := part.Get("functionCall") + if !call.Exists() { + call = part.Get("function_call") + } + if !call.Exists() { + return true + } + name := call.Get("name").String() + if name == "" { + return true + } + id := sanitizeToolUseID("gemini_" + name + "_" + strconv.Itoa(toolSeq)) + toolSeq++ + toolIDsByName[name] = id + args := call.Get("args") + if !args.Exists() { + args = call.Get("arguments") + } + blocks = append(blocks, anthropicToolUseBlock(id, name, args)) + return true + }) + if len(blocks) == 0 { + return "", toolSeq + } + jw := newJSONWriter() + jw.Obj() + jw.Key("role") + jw.Str("assistant") + jw.Key("content") + jw.Arr() + for _, b := range blocks { + jw.Raw(b) + } + jw.EndArr() + jw.EndObj() + return string(jw.Bytes()), toolSeq +} + +func buildAnthropicUserFromGeminiParts(parts gjson.Result, toolIDsByName map[string]string) string { + if !parts.IsArray() { + return "" + } + var blocks []string + parts.ForEach(func(_, part gjson.Result) bool { + if text := part.Get("text").String(); text != "" { + blocks = append(blocks, anthropicTextBlock(text)) + return true + } + resp := part.Get("functionResponse") + if !resp.Exists() { + resp = part.Get("function_response") + } + if !resp.Exists() { + return true + } + name := resp.Get("name").String() + id := toolIDsByName[name] + if id == "" { + // No matching prior functionCall in this conversion — still emit a + // tool_result so the summarizer sees the tool output text. + id = sanitizeToolUseID("gemini_" + name + "_orphan") + } + blocks = append(blocks, anthropicToolResultBlock(id, geminiFunctionResponseText(resp))) + return true + }) + if len(blocks) == 0 { + return "" + } + jw := newJSONWriter() + jw.Obj() + jw.Key("role") + jw.Str("user") + jw.Key("content") + jw.Arr() + for _, b := range blocks { + jw.Raw(b) + } + jw.EndArr() + jw.EndObj() + return string(jw.Bytes()) +} + +func anthropicTextBlock(text string) string { + jw := newJSONWriter() + jw.Obj() + jw.Key("type") + jw.Str("text") + jw.Key("text") + jw.Str(text) + jw.EndObj() + return string(jw.Bytes()) +} + +func anthropicToolUseBlock(id, name string, args gjson.Result) string { + jw := newJSONWriter() + jw.Obj() + jw.Key("type") + jw.Str("tool_use") + jw.Key("id") + jw.Str(id) + jw.Key("name") + jw.Str(name) + jw.Key("input") + if args.Exists() && args.IsObject() { + jw.Raw(args.Raw) + } else { + jw.Raw("{}") + } + jw.EndObj() + return string(jw.Bytes()) +} + +func anthropicToolResultBlock(toolUseID, content string) string { + jw := newJSONWriter() + jw.Obj() + jw.Key("type") + jw.Str("tool_result") + jw.Key("tool_use_id") + jw.Str(sanitizeToolUseID(toolUseID)) + jw.Key("content") + jw.Str(content) + jw.EndObj() + return string(jw.Bytes()) +} diff --git a/internal/translate/handover.go b/internal/translate/handover.go index 6d7d7da08..282e11344 100644 --- a/internal/translate/handover.go +++ b/internal/translate/handover.go @@ -278,8 +278,13 @@ func (e *RequestEnvelope) rewriteGeminiForHandover(summary string) int { rebuilt = append(rebuilt, string(summaryRaw)) preserved := 0 if latestUser.Exists() { - rebuilt = append(rebuilt, latestUser.Raw) - preserved = 1 + // Strip functionResponse parts: the summary has no functionCall parts, + // so any functionResponse would be orphaned (Google 400). + cleaned := stripGeminiFunctionResponseEntry(latestUser) + if cleaned != "" { + rebuilt = append(rebuilt, cleaned) + preserved = 1 + } } elided := max(len(all)-preserved, 0) @@ -293,6 +298,44 @@ func (e *RequestEnvelope) rewriteGeminiForHandover(summary string) int { return elided } +// stripGeminiFunctionResponseEntry removes functionResponse parts from a +// Gemini contents entry (nil known set = strip all). Returns "" if nothing +// remains — matching stripAnthropicToolResultMsg for Anthropic handover. +func stripGeminiFunctionResponseEntry(entry gjson.Result) string { + parts := entry.Get("parts") + if !parts.IsArray() { + return entry.Raw + } + hasFR := false + parts.ForEach(func(_, part gjson.Result) bool { + if part.Get("functionResponse").Exists() || part.Get("function_response").Exists() { + hasFR = true + return false + } + return true + }) + if !hasFR { + return entry.Raw + } + var kept []string + parts.ForEach(func(_, part gjson.Result) bool { + if part.Get("functionResponse").Exists() || part.Get("function_response").Exists() { + return true + } + kept = append(kept, part.Raw) + return true + }) + if len(kept) == 0 { + return "" + } + newParts := "[" + strings.Join(kept, ",") + "]" + out, err := sjson.SetRawBytes([]byte(entry.Raw), "parts", []byte(newParts)) + if err != nil { + return entry.Raw + } + return string(out) +} + func (e *RequestEnvelope) trimGeminiLastN(n int) int { contents := gjson.GetBytes(e.body, "contents") if !contents.IsArray() { From 029cb5c5f9214da83ab304bdcb53c17ad054cd68 Mon Sep 17 00:00:00 2001 From: N Rohith Reddy Date: Thu, 16 Jul 2026 16:09:51 -0400 Subject: [PATCH 2/5] test(proxy): cover real Gemini summarizer SWITCH and failure fallback Add e2e cases that exercise ProviderSummarizer ingest on Gemini SWITCH and assert full-history fallback still runs when summarization fails. Signed-off-by: N Rohith Reddy Co-authored-by: Cursor --- internal/proxy/gemini_handover_test.go | 144 +++++++++++++++++++++++++ 1 file changed, 144 insertions(+) diff --git a/internal/proxy/gemini_handover_test.go b/internal/proxy/gemini_handover_test.go index cff5e5522..5edf186c4 100644 --- a/internal/proxy/gemini_handover_test.go +++ b/internal/proxy/gemini_handover_test.go @@ -1,9 +1,12 @@ package proxy_test import ( + "context" + "io" "net/http" "net/http/httptest" "strings" + "sync/atomic" "testing" "time" @@ -136,3 +139,144 @@ func TestGeminiSwitch_MidToolStripsOrphanFunctionResponse(t *testing.T) { assert.Equal(t, 0, frCount, "orphan functionResponse must not reach Google after handover rewrite") assert.Equal(t, 1, len(contents.Array()), "functionResponse-only latest user drops; only summary remains") } + +type recordingSummarizerClient struct { + calls atomic.Int32 + lastBody []byte + respBody string + respStatus int +} + +func (c *recordingSummarizerClient) Proxy(_ context.Context, _ router.Decision, prep providers.PreparedRequest, w http.ResponseWriter, _ *http.Request) error { + c.calls.Add(1) + c.lastBody = append([]byte(nil), prep.Body...) + status := c.respStatus + if status == 0 { + status = http.StatusOK + } + w.WriteHeader(status) + _, _ = io.WriteString(w, c.respBody) + return nil +} +func (c *recordingSummarizerClient) Passthrough(context.Context, providers.PreparedRequest, http.ResponseWriter, *http.Request) error { + return nil +} + +func TestGeminiSwitch_RealProviderSummarizerBoundsHistory(t *testing.T) { + t.Parallel() + chunk := strings.Repeat("bbbb ", 8000) + body := []byte(`{ + "model":"gemini-3.1-pro-preview", + "systemInstruction":{"parts":[{"text":"be careful"}]}, + "contents":[ + {"role":"user","parts":[{"text":"` + chunk + `"}]}, + {"role":"model","parts":[{"functionCall":{"name":"edit","args":{"path":"a.go"}}}]}, + {"role":"user","parts":[{"functionResponse":{"name":"edit","response":{"result":"ok"}}}]}, + {"role":"user","parts":[{"text":"continue please"}]} + ] +}`) + sumClient := &recordingSummarizerClient{ + respBody: `{ + "id":"msg_test","type":"message","role":"assistant","model":"claude-haiku-4-5", + "content":[{"type":"text","text":"Edited a.go successfully; user wants to continue."}], + "usage":{"input_tokens":10,"output_tokens":8} +}`, + respStatus: http.StatusOK, + } + ps := proxy.NewProviderSummarizer(sumClient, proxy.DefaultHandoverModel, time.Second) + googleUp := &fakeProvider{} + store := newFakePinStore() + store.hasPin = true + store.pin = sessionpin.Pin{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-pro-preview", + Reason: "cluster:v0.2", + PinnedUntil: time.Now().Add(time.Hour), + LastInputTokens: 10000, + LastTurnEndedAt: time.Now().Add(-30 * time.Second), + } + fr := &fakeRouter{decision: router.Decision{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-flash-lite-preview", + Reason: "cluster:v0.2", + }} + svc := proxy.NewService( + fr, + map[string]providers.Client{providers.ProviderGoogle: googleUp}, + nil, false, nil, store, false, + providers.ProviderGoogle, "gemini-3.1-flash-lite-preview", nil, + ).WithSummarizer(ps) + + ctx := authedCtx(uuid.New().String()) + rec := httptest.NewRecorder() + httpReq := httptest.NewRequest(http.MethodPost, "/v1beta/models/x:generateContent", strings.NewReader("")) + require.NoError(t, svc.ProxyGeminiGenerateContent(ctx, body, rec, httpReq)) + + assert.Equal(t, "gemini-3.1-flash-lite-preview", rec.Header().Get(proxy.HeaderRouterModel)) + require.Equal(t, int32(1), sumClient.calls.Load(), "real ProviderSummarizer must reach Anthropic upstream") + + sumBody := sumClient.lastBody + require.NotEmpty(t, sumBody) + assert.Equal(t, "claude-haiku-4-5", gjson.GetBytes(sumBody, "model").String()) + assert.Equal(t, "be careful", gjson.GetBytes(sumBody, "system.0.text").String()) + assert.Contains(t, gjson.GetBytes(sumBody, "messages").Raw, `"tool_use"`) + assert.Contains(t, gjson.GetBytes(sumBody, "messages").Raw, `"tool_result"`) + assert.NotContains(t, string(sumBody), "functionCall", "Gemini wire must be converted before Anthropic upstream") + + require.NotEmpty(t, googleUp.proxyBodies) + contents := gjson.GetBytes(googleUp.proxyBodies[0], "contents").Array() + require.Len(t, contents, 2, "bounded [summary, latestUser]") + assert.True(t, strings.HasPrefix(contents[0].Get("parts.0.text").String(), translate.HandoverSummaryTag)) + assert.Contains(t, contents[0].Get("parts.0.text").String(), "Edited a.go") + assert.Equal(t, "continue please", contents[1].Get("parts.0.text").String()) + assert.NotContains(t, string(googleUp.proxyBodies[0]), "functionResponse") + assert.NotContains(t, string(googleUp.proxyBodies[0]), chunk[:40]) +} + +func TestGeminiSwitch_SummarizerFailureKeepsFullHistory(t *testing.T) { + t.Parallel() + chunk := strings.Repeat("cccc ", 8000) + body := []byte(`{ + "model":"gemini-3.1-pro-preview", + "contents":[ + {"role":"user","parts":[{"text":"` + chunk + `"}]}, + {"role":"model","parts":[{"functionCall":{"name":"edit","args":{}}}]}, + {"role":"user","parts":[{"functionResponse":{"name":"edit","response":{"result":"ok"}}}]} + ] +}`) + sz := &fakeSummarizer{errOnCall: assert.AnError} + googleUp := &fakeProvider{} + store := newFakePinStore() + store.hasPin = true + store.pin = sessionpin.Pin{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-pro-preview", + Reason: "cluster:v0.2", + PinnedUntil: time.Now().Add(time.Hour), + LastInputTokens: 10000, + LastTurnEndedAt: time.Now().Add(-30 * time.Second), + } + fr := &fakeRouter{decision: router.Decision{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-flash-lite-preview", + Reason: "cluster:v0.2", + }} + svc := proxy.NewService( + fr, + map[string]providers.Client{providers.ProviderGoogle: googleUp}, + nil, false, nil, store, false, + providers.ProviderGoogle, "gemini-3.1-flash-lite-preview", nil, + ).WithSummarizer(sz) + + ctx := authedCtx(uuid.New().String()) + rec := httptest.NewRecorder() + httpReq := httptest.NewRequest(http.MethodPost, "/v1beta/models/x:generateContent", strings.NewReader("")) + require.NoError(t, svc.ProxyGeminiGenerateContent(ctx, body, rec, httpReq)) + + require.NotEmpty(t, googleUp.proxyBodies) + raw := string(googleUp.proxyBodies[0]) + assert.Contains(t, raw, "functionCall", "failure must keep full history including functionCall") + assert.Contains(t, raw, "functionResponse", "failure must keep full history including functionResponse") + assert.NotContains(t, raw, translate.HandoverSummaryTag, "RewriteEnvelope must not run on summarizer failure") + assert.Equal(t, int32(1), sz.calls.Load()) +} From dd9b0fcb9aaa9969590f7ce68fe400595fcaf935 Mon Sep 17 00:00:00 2001 From: N Rohith Reddy Date: Thu, 16 Jul 2026 16:16:43 -0400 Subject: [PATCH 3/5] test(proxy): harden Gemini handover coverage and trim orphan strip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add edge-case Gemini→Anthropic convert tests, empty-summary/race SWITCH cases, and compaction ingest coverage. Also strip orphan functionResponse parts in trimGeminiLastN so TrimLastN matches Anthropic/OpenAI parity. Signed-off-by: N Rohith Reddy Co-authored-by: Cursor --- internal/proxy/gemini_handover_test.go | 102 ++++++++++ internal/proxy/handover_internal_test.go | 23 +++ internal/router/handover/summarizer_test.go | 29 +++ .../gemini_to_anthropic_edge_test.go | 174 ++++++++++++++++++ internal/translate/handover.go | 84 +++++++-- 5 files changed, 397 insertions(+), 15 deletions(-) create mode 100644 internal/translate/gemini_to_anthropic_edge_test.go diff --git a/internal/proxy/gemini_handover_test.go b/internal/proxy/gemini_handover_test.go index 5edf186c4..037479bee 100644 --- a/internal/proxy/gemini_handover_test.go +++ b/internal/proxy/gemini_handover_test.go @@ -280,3 +280,105 @@ func TestGeminiSwitch_SummarizerFailureKeepsFullHistory(t *testing.T) { assert.NotContains(t, raw, translate.HandoverSummaryTag, "RewriteEnvelope must not run on summarizer failure") assert.Equal(t, int32(1), sz.calls.Load()) } + +func TestGeminiSwitch_EmptySummaryKeepsFullHistory(t *testing.T) { + t.Parallel() + chunk := strings.Repeat("dddd ", 8000) + body := []byte(`{ + "model":"gemini-3.1-pro-preview", + "contents":[ + {"role":"user","parts":[{"text":"` + chunk + `"}]}, + {"role":"model","parts":[{"text":"ack"}]}, + {"role":"user","parts":[{"text":"continue"}]} + ] +}`) + sz := &fakeSummarizer{summary: ""} // success with empty text → same fallback as error + googleUp := &fakeProvider{} + store := newFakePinStore() + store.hasPin = true + store.pin = sessionpin.Pin{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-pro-preview", + Reason: "cluster:v0.2", + PinnedUntil: time.Now().Add(time.Hour), + LastInputTokens: 10000, + LastTurnEndedAt: time.Now().Add(-30 * time.Second), + } + fr := &fakeRouter{decision: router.Decision{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-flash-lite-preview", + Reason: "cluster:v0.2", + }} + svc := proxy.NewService( + fr, + map[string]providers.Client{providers.ProviderGoogle: googleUp}, + nil, false, nil, store, false, + providers.ProviderGoogle, "gemini-3.1-flash-lite-preview", nil, + ).WithSummarizer(sz) + + ctx := authedCtx(uuid.New().String()) + rec := httptest.NewRecorder() + httpReq := httptest.NewRequest(http.MethodPost, "/v1beta/models/x:generateContent", strings.NewReader("")) + require.NoError(t, svc.ProxyGeminiGenerateContent(ctx, body, rec, httpReq)) + + require.NotEmpty(t, googleUp.proxyBodies) + raw := string(googleUp.proxyBodies[0]) + assert.Contains(t, raw, chunk[:40], "empty summary must preserve full history") + assert.NotContains(t, raw, translate.HandoverSummaryTag) + assert.Equal(t, int32(1), sz.calls.Load()) + assert.Equal(t, "gemini-3.1-flash-lite-preview", rec.Header().Get(proxy.HeaderRouterModel)) +} + +func TestGeminiSwitch_ConcurrentRequestsRaceSafe(t *testing.T) { + t.Parallel() + chunk := strings.Repeat("eeee ", 4000) + body := []byte(`{ + "model":"gemini-3.1-pro-preview", + "contents":[ + {"role":"user","parts":[{"text":"` + chunk + `"}]}, + {"role":"model","parts":[{"text":"ack"}]}, + {"role":"user","parts":[{"text":"go"}]} + ] +}`) + const n = 16 + done := make(chan struct{}, n) + for i := 0; i < n; i++ { + go func() { + defer func() { done <- struct{}{} }() + store := newFakePinStore() + store.hasPin = true + store.pin = sessionpin.Pin{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-pro-preview", + Reason: "cluster:v0.2", + PinnedUntil: time.Now().Add(time.Hour), + LastInputTokens: 10000, + LastTurnEndedAt: time.Now().Add(-30 * time.Second), + } + fr := &fakeRouter{decision: router.Decision{ + Provider: providers.ProviderGoogle, + Model: "gemini-3.1-flash-lite-preview", + Reason: "cluster:v0.2", + }} + sz := &fakeSummarizer{summary: "race-safe summary"} + googleUp := &fakeProvider{} + svc := proxy.NewService( + fr, + map[string]providers.Client{providers.ProviderGoogle: googleUp}, + nil, false, nil, store, false, + providers.ProviderGoogle, "gemini-3.1-flash-lite-preview", nil, + ).WithSummarizer(sz) + ctx := authedCtx(uuid.New().String()) + rec := httptest.NewRecorder() + httpReq := httptest.NewRequest(http.MethodPost, "/v1beta/models/x:generateContent", strings.NewReader("")) + require.NoError(t, svc.ProxyGeminiGenerateContent(ctx, body, rec, httpReq)) + require.NotEmpty(t, googleUp.proxyBodies) + contents := gjson.GetBytes(googleUp.proxyBodies[0], "contents").Array() + require.Len(t, contents, 2) + assert.True(t, strings.HasPrefix(contents[0].Get("parts.0.text").String(), translate.HandoverSummaryTag)) + }() + } + for i := 0; i < n; i++ { + <-done + } +} diff --git a/internal/proxy/handover_internal_test.go b/internal/proxy/handover_internal_test.go index 38ded9a9b..bb6a6f448 100644 --- a/internal/proxy/handover_internal_test.go +++ b/internal/proxy/handover_internal_test.go @@ -188,3 +188,26 @@ func TestProviderSummarizer_AcceptsGeminiEnvelope(t *testing.T) { require.NoError(t, err, "ProviderSummarizer must ingest Gemini envelopes for SWITCH handover") assert.Equal(t, "Refactor in progress: step 1 done, step 2 pending.", got) } + +func TestProviderSummarizer_CompactionAcceptsGeminiEnvelope(t *testing.T) { + t.Parallel() + + env, err := translate.ParseGemini([]byte(`{ + "contents": [ + {"role": "user", "parts": [{"text": "long prior context"}]}, + {"role": "model", "parts": [{"text": "ack"}]}, + {"role": "user", "parts": [{"text": "continue"}]} + ] +}`)) + require.NoError(t, err) + + fake := &fakeHandoverProvider{ + respBody: canonicalAnthropicResponse, + respStatus: http.StatusOK, + } + s := NewProviderSummarizer(fake, "", 200*time.Millisecond) + + got, _, err := s.SummarizeForCompaction(context.Background(), env, "claude-haiku-4-5", 512) + require.NoError(t, err, "compaction path must also ingest Gemini envelopes") + assert.Equal(t, "Refactor in progress: step 1 done, step 2 pending.", got) +} diff --git a/internal/router/handover/summarizer_test.go b/internal/router/handover/summarizer_test.go index 6812e90eb..22963f12f 100644 --- a/internal/router/handover/summarizer_test.go +++ b/internal/router/handover/summarizer_test.go @@ -510,6 +510,35 @@ func TestTrimLastN_GeminiNoOpWhenUnderLimit(t *testing.T) { require.Len(t, contents, 2) } +func TestTrimLastN_GeminiStripsOrphanFunctionResponse(t *testing.T) { + t.Parallel() + + // TrimLastN(2) keeps only the trailing functionResponse user turn — + // without orphan strip that would 400 at Google (no matching functionCall). + const body = `{ + "contents": [ + {"role": "user", "parts": [{"text": "edit a.go"}]}, + {"role": "model", "parts": [{"functionCall": {"name": "edit", "args": {}}}]}, + {"role": "user", "parts": [{"functionResponse": {"name": "edit", "response": {"result": "ok"}}}]}, + {"role": "model", "parts": [{"text": "done"}]}, + {"role": "user", "parts": [{"functionResponse": {"name": "edit", "response": {"result": "stale"}}}]} + ] +}` + env, err := translate.ParseGemini([]byte(body)) + require.NoError(t, err) + + elided := handover.TrimLastN(env, 2) + assert.Equal(t, 3, elided) + + prep, err := env.PrepareGemini(http.Header{}, translate.EmitOptions{TargetModel: "gemini-3.1-pro"}) + require.NoError(t, err) + contents := gjson.GetBytes(prep.Body, "contents").Array() + require.Len(t, contents, 1, "orphan functionResponse-only user must be dropped") + assert.Equal(t, "model", contents[0].Get("role").String()) + assert.Equal(t, "done", contents[0].Get("parts.0.text").String()) + assert.NotContains(t, string(prep.Body), "functionResponse") +} + // Regression: mid-session model switch + TrimLastN can orphan tool_result // blocks, which the Anthropic→Gemini translation turned into an empty // functionResponse.name (Gemini 400). diff --git a/internal/translate/gemini_to_anthropic_edge_test.go b/internal/translate/gemini_to_anthropic_edge_test.go new file mode 100644 index 000000000..e62d32dfe --- /dev/null +++ b/internal/translate/gemini_to_anthropic_edge_test.go @@ -0,0 +1,174 @@ +package translate_test + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" + + "workweave/router/internal/translate" +) + +func TestGeminiToAnthropic_ParallelToolsPairByNameOrder(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "contents": [ + {"role": "user", "parts": [{"text": "do both"}]}, + {"role": "model", "parts": [ + {"functionCall": {"name": "edit", "args": {"path": "a.go"}}}, + {"functionCall": {"name": "read", "args": {"path": "b.go"}}} + ]}, + {"role": "user", "parts": [ + {"functionResponse": {"name": "edit", "response": {"result": "edited"}}}, + {"functionResponse": {"name": "read", "response": {"result": "contents"}}} + ]} + ] + }`) + env, err := translate.ParseGemini(body) + require.NoError(t, err) + + prep, err := env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-haiku-4-5"}) + require.NoError(t, err) + + msgs := gjson.GetBytes(prep.Body, "messages").Array() + require.Len(t, msgs, 3) + editID := msgs[1].Get("content.0.id").String() + readID := msgs[1].Get("content.1.id").String() + require.NotEmpty(t, editID) + require.NotEmpty(t, readID) + assert.NotEqual(t, editID, readID) + assert.Equal(t, editID, msgs[2].Get("content.0.tool_use_id").String()) + assert.Equal(t, readID, msgs[2].Get("content.1.tool_use_id").String()) + assert.Equal(t, "edited", msgs[2].Get("content.0.content").String()) + assert.Equal(t, "contents", msgs[2].Get("content.1.content").String()) +} + +func TestGeminiToAnthropic_SnakeCaseAliases(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "contents": [ + {"role": "user", "parts": [{"text": "go"}]}, + {"role": "model", "parts": [{"function_call": {"name": "edit", "arguments": {"path": "x.go"}}}]}, + {"role": "user", "parts": [{"function_response": {"name": "edit", "response": {"output": "done"}}}]} + ] + }`) + env, err := translate.ParseGemini(body) + require.NoError(t, err) + + prep, err := env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-haiku-4-5"}) + require.NoError(t, err) + + msgs := gjson.GetBytes(prep.Body, "messages").Array() + require.Len(t, msgs, 3) + assert.Equal(t, "tool_use", msgs[1].Get("content.0.type").String()) + assert.Equal(t, "edit", msgs[1].Get("content.0.name").String()) + assert.Equal(t, "x.go", msgs[1].Get("content.0.input.path").String()) + toolID := msgs[1].Get("content.0.id").String() + assert.Equal(t, "tool_result", msgs[2].Get("content.0.type").String()) + assert.Equal(t, toolID, msgs[2].Get("content.0.tool_use_id").String()) + assert.Equal(t, "done", msgs[2].Get("content.0.content").String()) +} + +func TestGeminiToAnthropic_OrphanFunctionResponseStillEmitsToolResult(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "contents": [ + {"role": "user", "parts": [{"functionResponse": {"name": "edit", "response": {"result": "late"}}}]} + ] + }`) + env, err := translate.ParseGemini(body) + require.NoError(t, err) + + prep, err := env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-haiku-4-5"}) + require.NoError(t, err) + + msgs := gjson.GetBytes(prep.Body, "messages").Array() + require.Len(t, msgs, 1) + assert.Equal(t, "tool_result", msgs[0].Get("content.0.type").String()) + assert.Contains(t, msgs[0].Get("content.0.tool_use_id").String(), "orphan") + assert.Equal(t, "late", msgs[0].Get("content.0.content").String()) +} + +func TestGeminiToAnthropic_NestedResponseFallsBackToRawJSON(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "contents": [ + {"role": "model", "parts": [{"functionCall": {"name": "search", "args": {}}}]}, + {"role": "user", "parts": [{"functionResponse": {"name": "search", "response": {"hits": [{"id": 1}, {"id": 2}]}}}]} + ] + }`) + env, err := translate.ParseGemini(body) + require.NoError(t, err) + + prep, err := env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-haiku-4-5"}) + require.NoError(t, err) + + content := gjson.GetBytes(prep.Body, "messages.1.content.0.content").String() + assert.Contains(t, content, `"hits"`) + assert.Contains(t, content, `"id": 1`) +} + +func TestGeminiToAnthropic_PreservesStreamFlag(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "stream": true, + "contents": [{"role": "user", "parts": [{"text": "hi"}]}] + }`) + env, err := translate.ParseGemini(body) + require.NoError(t, err) + + prep, err := env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-haiku-4-5"}) + require.NoError(t, err) + assert.True(t, gjson.GetBytes(prep.Body, "stream").Bool()) +} + +func TestGeminiToAnthropic_DoesNotCopyGeminiToolsArray(t *testing.T) { + t.Parallel() + + body := []byte(`{ + "tools": [{"functionDeclarations": [{"name": "edit", "parameters": {"type": "object"}}]}], + "contents": [{"role": "user", "parts": [{"text": "hi"}]}] + }`) + env, err := translate.ParseGemini(body) + require.NoError(t, err) + + prep, err := env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-haiku-4-5"}) + require.NoError(t, err) + assert.False(t, gjson.GetBytes(prep.Body, "tools").Exists(), "summarizer ingest must not forward Gemini tools schemas") +} + +func TestGeminiToAnthropic_SameNameToolsPairToLatestCall(t *testing.T) { + t.Parallel() + + // Two edit calls; name→id map keeps the latest. Documented pairing rule. + body := []byte(`{ + "contents": [ + {"role": "model", "parts": [ + {"functionCall": {"name": "edit", "args": {"n": 1}}}, + {"functionCall": {"name": "edit", "args": {"n": 2}}} + ]}, + {"role": "user", "parts": [ + {"functionResponse": {"name": "edit", "response": {"result": "first"}}}, + {"functionResponse": {"name": "edit", "response": {"result": "second"}}} + ]} + ] + }`) + env, err := translate.ParseGemini(body) + require.NoError(t, err) + + prep, err := env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-haiku-4-5"}) + require.NoError(t, err) + + msgs := gjson.GetBytes(prep.Body, "messages").Array() + require.Len(t, msgs, 2) + latestID := msgs[0].Get("content.1.id").String() + assert.Equal(t, latestID, msgs[1].Get("content.0.tool_use_id").String()) + assert.Equal(t, latestID, msgs[1].Get("content.1.tool_use_id").String()) +} diff --git a/internal/translate/handover.go b/internal/translate/handover.go index 282e11344..c40d495ca 100644 --- a/internal/translate/handover.go +++ b/internal/translate/handover.go @@ -280,7 +280,7 @@ func (e *RequestEnvelope) rewriteGeminiForHandover(summary string) int { if latestUser.Exists() { // Strip functionResponse parts: the summary has no functionCall parts, // so any functionResponse would be orphaned (Google 400). - cleaned := stripGeminiFunctionResponseEntry(latestUser) + cleaned := stripGeminiFunctionResponseEntry(latestUser, nil) if cleaned != "" { rebuilt = append(rebuilt, cleaned) preserved = 1 @@ -298,29 +298,44 @@ func (e *RequestEnvelope) rewriteGeminiForHandover(summary string) int { return elided } -// stripGeminiFunctionResponseEntry removes functionResponse parts from a -// Gemini contents entry (nil known set = strip all). Returns "" if nothing +// stripGeminiFunctionResponseEntry removes functionResponse parts whose name +// is not in knownNames (nil knownNames = strip all). Returns "" if nothing // remains — matching stripAnthropicToolResultMsg for Anthropic handover. -func stripGeminiFunctionResponseEntry(entry gjson.Result) string { +func stripGeminiFunctionResponseEntry(entry gjson.Result, knownNames map[string]struct{}) string { parts := entry.Get("parts") if !parts.IsArray() { return entry.Raw } - hasFR := false + hasOrphans := false parts.ForEach(func(_, part gjson.Result) bool { - if part.Get("functionResponse").Exists() || part.Get("function_response").Exists() { - hasFR = true + resp := part.Get("functionResponse") + if !resp.Exists() { + resp = part.Get("function_response") + } + if !resp.Exists() { + return true + } + name := resp.Get("name").String() + if _, ok := knownNames[name]; !ok { + hasOrphans = true return false } return true }) - if !hasFR { + if !hasOrphans { return entry.Raw } var kept []string parts.ForEach(func(_, part gjson.Result) bool { - if part.Get("functionResponse").Exists() || part.Get("function_response").Exists() { - return true + resp := part.Get("functionResponse") + if !resp.Exists() { + resp = part.Get("function_response") + } + if resp.Exists() { + name := resp.Get("name").String() + if _, ok := knownNames[name]; !ok { + return true + } } kept = append(kept, part.Raw) return true @@ -346,17 +361,56 @@ func (e *RequestEnvelope) trimGeminiLastN(n int) int { return 0 } keep := all[len(all)-n:] - rebuilt := make([]string, 0, len(keep)) - for _, m := range keep { - rebuilt = append(rebuilt, m.Raw) - } + rebuilt := stripOrphanedGeminiFunctionResponses(keep) newContents := "[" + strings.Join(rebuilt, ",") + "]" out, err := sjson.SetRawBytes(e.body, "contents", []byte(newContents)) if err != nil { return 0 } e.body = out - return len(all) - len(keep) + return len(all) - n +} + +// stripOrphanedGeminiFunctionResponses drops functionResponse parts whose +// tool name has no matching functionCall among the set's model turns; user +// entries left empty afterward are omitted entirely. +func stripOrphanedGeminiFunctionResponses(entries []gjson.Result) []string { + known := collectGeminiFunctionCallNames(entries) + result := make([]string, 0, len(entries)) + for _, entry := range entries { + role := entry.Get("role").String() + if role == "model" { + result = append(result, entry.Raw) + continue + } + cleaned := stripGeminiFunctionResponseEntry(entry, known) + if cleaned != "" { + result = append(result, cleaned) + } + } + return result +} + +// collectGeminiFunctionCallNames returns the set of functionCall names present +// in model turns. +func collectGeminiFunctionCallNames(entries []gjson.Result) map[string]struct{} { + names := make(map[string]struct{}) + for _, entry := range entries { + if entry.Get("role").String() != "model" { + continue + } + entry.Get("parts").ForEach(func(_, part gjson.Result) bool { + call := part.Get("functionCall") + if !call.Exists() { + call = part.Get("function_call") + } + if name := call.Get("name").String(); name != "" { + names[name] = struct{}{} + } + return true + }) + } + return names } // stripOrphanedAnthropicToolResults drops tool_result blocks whose tool_use_id From 2fabf4035229f029830221a4c1bbce9e7c5fae15 Mon Sep 17 00:00:00 2001 From: N Rohith Reddy Date: Thu, 16 Jul 2026 16:25:40 -0400 Subject: [PATCH 4/5] style: tighten Gemini handover test comments to behavior asserts Drop caller/task-flavored narration so comments match the one-line asserted-behavior convention used elsewhere in these packages. Signed-off-by: N Rohith Reddy Co-authored-by: Cursor --- internal/proxy/gemini_handover_test.go | 29 ++++++++++--------- internal/proxy/handover_internal_test.go | 6 ++-- internal/router/handover/summarizer_test.go | 3 +- .../translate/crossformat_request_test.go | 2 +- .../translate/emit_anthropic_from_gemini.go | 8 ++--- .../gemini_to_anthropic_edge_test.go | 4 +-- internal/translate/handover.go | 4 +-- 7 files changed, 29 insertions(+), 27 deletions(-) diff --git a/internal/proxy/gemini_handover_test.go b/internal/proxy/gemini_handover_test.go index 037479bee..4adf42827 100644 --- a/internal/proxy/gemini_handover_test.go +++ b/internal/proxy/gemini_handover_test.go @@ -22,8 +22,7 @@ import ( "workweave/router/internal/translate" ) -// Gemini SWITCH with a working summarizer must RewriteEnvelope so the -// forwarded body is summary-bounded (not full history). +// Gemini SWITCH with a working summarizer forwards [summary, latestUser]. func TestGeminiSwitch_SummarizerBoundsHistory(t *testing.T) { t.Parallel() @@ -80,7 +79,7 @@ func TestGeminiSwitch_SummarizerBoundsHistory(t *testing.T) { assert.NotContains(t, string(googleUp.proxyBodies[0]), chunk[:40], "long prior user text must be elided") } -// Mid-tool Gemini SWITCH must not forward an orphan functionResponse after rewrite. +// Mid-tool Gemini SWITCH strips orphan functionResponse from the forwarded body. func TestGeminiSwitch_MidToolStripsOrphanFunctionResponse(t *testing.T) { t.Parallel() @@ -136,8 +135,8 @@ func TestGeminiSwitch_MidToolStripsOrphanFunctionResponse(t *testing.T) { }) return true }) - assert.Equal(t, 0, frCount, "orphan functionResponse must not reach Google after handover rewrite") - assert.Equal(t, 1, len(contents.Array()), "functionResponse-only latest user drops; only summary remains") + assert.Equal(t, 0, frCount, "no functionResponse in forwarded body") + assert.Equal(t, 1, len(contents.Array()), "only summary remains") } type recordingSummarizerClient struct { @@ -162,6 +161,7 @@ func (c *recordingSummarizerClient) Passthrough(context.Context, providers.Prepa return nil } +// ProviderSummarizer converts Gemini history and bounds the SWITCH body. func TestGeminiSwitch_RealProviderSummarizerBoundsHistory(t *testing.T) { t.Parallel() chunk := strings.Repeat("bbbb ", 8000) @@ -213,7 +213,7 @@ func TestGeminiSwitch_RealProviderSummarizerBoundsHistory(t *testing.T) { require.NoError(t, svc.ProxyGeminiGenerateContent(ctx, body, rec, httpReq)) assert.Equal(t, "gemini-3.1-flash-lite-preview", rec.Header().Get(proxy.HeaderRouterModel)) - require.Equal(t, int32(1), sumClient.calls.Load(), "real ProviderSummarizer must reach Anthropic upstream") + require.Equal(t, int32(1), sumClient.calls.Load(), "summarizer Proxy was called") sumBody := sumClient.lastBody require.NotEmpty(t, sumBody) @@ -221,11 +221,11 @@ func TestGeminiSwitch_RealProviderSummarizerBoundsHistory(t *testing.T) { assert.Equal(t, "be careful", gjson.GetBytes(sumBody, "system.0.text").String()) assert.Contains(t, gjson.GetBytes(sumBody, "messages").Raw, `"tool_use"`) assert.Contains(t, gjson.GetBytes(sumBody, "messages").Raw, `"tool_result"`) - assert.NotContains(t, string(sumBody), "functionCall", "Gemini wire must be converted before Anthropic upstream") + assert.NotContains(t, string(sumBody), "functionCall", "no Gemini functionCall in Anthropic summarizer body") require.NotEmpty(t, googleUp.proxyBodies) contents := gjson.GetBytes(googleUp.proxyBodies[0], "contents").Array() - require.Len(t, contents, 2, "bounded [summary, latestUser]") + require.Len(t, contents, 2, "expect [summary, latestUser]") assert.True(t, strings.HasPrefix(contents[0].Get("parts.0.text").String(), translate.HandoverSummaryTag)) assert.Contains(t, contents[0].Get("parts.0.text").String(), "Edited a.go") assert.Equal(t, "continue please", contents[1].Get("parts.0.text").String()) @@ -233,6 +233,7 @@ func TestGeminiSwitch_RealProviderSummarizerBoundsHistory(t *testing.T) { assert.NotContains(t, string(googleUp.proxyBodies[0]), chunk[:40]) } +// Summarizer error keeps the full prior Gemini contents unchanged. func TestGeminiSwitch_SummarizerFailureKeepsFullHistory(t *testing.T) { t.Parallel() chunk := strings.Repeat("cccc ", 8000) @@ -275,12 +276,13 @@ func TestGeminiSwitch_SummarizerFailureKeepsFullHistory(t *testing.T) { require.NotEmpty(t, googleUp.proxyBodies) raw := string(googleUp.proxyBodies[0]) - assert.Contains(t, raw, "functionCall", "failure must keep full history including functionCall") - assert.Contains(t, raw, "functionResponse", "failure must keep full history including functionResponse") - assert.NotContains(t, raw, translate.HandoverSummaryTag, "RewriteEnvelope must not run on summarizer failure") + assert.Contains(t, raw, "functionCall", "full history keeps functionCall") + assert.Contains(t, raw, "functionResponse", "full history keeps functionResponse") + assert.NotContains(t, raw, translate.HandoverSummaryTag, "no handover summary tag on failure") assert.Equal(t, int32(1), sz.calls.Load()) } +// Empty summarizer text keeps the full prior Gemini contents unchanged. func TestGeminiSwitch_EmptySummaryKeepsFullHistory(t *testing.T) { t.Parallel() chunk := strings.Repeat("dddd ", 8000) @@ -292,7 +294,7 @@ func TestGeminiSwitch_EmptySummaryKeepsFullHistory(t *testing.T) { {"role":"user","parts":[{"text":"continue"}]} ] }`) - sz := &fakeSummarizer{summary: ""} // success with empty text → same fallback as error + sz := &fakeSummarizer{summary: ""} googleUp := &fakeProvider{} store := newFakePinStore() store.hasPin = true @@ -323,12 +325,13 @@ func TestGeminiSwitch_EmptySummaryKeepsFullHistory(t *testing.T) { require.NotEmpty(t, googleUp.proxyBodies) raw := string(googleUp.proxyBodies[0]) - assert.Contains(t, raw, chunk[:40], "empty summary must preserve full history") + assert.Contains(t, raw, chunk[:40], "full history preserved") assert.NotContains(t, raw, translate.HandoverSummaryTag) assert.Equal(t, int32(1), sz.calls.Load()) assert.Equal(t, "gemini-3.1-flash-lite-preview", rec.Header().Get(proxy.HeaderRouterModel)) } +// Concurrent Gemini SWITCH requests each get a summary-bounded body. func TestGeminiSwitch_ConcurrentRequestsRaceSafe(t *testing.T) { t.Parallel() chunk := strings.Repeat("eeee ", 4000) diff --git a/internal/proxy/handover_internal_test.go b/internal/proxy/handover_internal_test.go index bb6a6f448..03eb58373 100644 --- a/internal/proxy/handover_internal_test.go +++ b/internal/proxy/handover_internal_test.go @@ -165,6 +165,7 @@ func TestProviderSummarizer_NilEnvelopeReturnsError(t *testing.T) { require.Error(t, err) } +// Summarize accepts a Gemini envelope. func TestProviderSummarizer_AcceptsGeminiEnvelope(t *testing.T) { t.Parallel() @@ -185,10 +186,11 @@ func TestProviderSummarizer_AcceptsGeminiEnvelope(t *testing.T) { s := NewProviderSummarizer(fake, "", 200*time.Millisecond) got, _, err := s.Summarize(context.Background(), env) - require.NoError(t, err, "ProviderSummarizer must ingest Gemini envelopes for SWITCH handover") + require.NoError(t, err) assert.Equal(t, "Refactor in progress: step 1 done, step 2 pending.", got) } +// SummarizeForCompaction accepts a Gemini envelope. func TestProviderSummarizer_CompactionAcceptsGeminiEnvelope(t *testing.T) { t.Parallel() @@ -208,6 +210,6 @@ func TestProviderSummarizer_CompactionAcceptsGeminiEnvelope(t *testing.T) { s := NewProviderSummarizer(fake, "", 200*time.Millisecond) got, _, err := s.SummarizeForCompaction(context.Background(), env, "claude-haiku-4-5", 512) - require.NoError(t, err, "compaction path must also ingest Gemini envelopes") + require.NoError(t, err) assert.Equal(t, "Refactor in progress: step 1 done, step 2 pending.", got) } diff --git a/internal/router/handover/summarizer_test.go b/internal/router/handover/summarizer_test.go index 22963f12f..823e5b77a 100644 --- a/internal/router/handover/summarizer_test.go +++ b/internal/router/handover/summarizer_test.go @@ -510,11 +510,10 @@ func TestTrimLastN_GeminiNoOpWhenUnderLimit(t *testing.T) { require.Len(t, contents, 2) } +// TrimLastN drops a trailing orphan functionResponse-only user turn. func TestTrimLastN_GeminiStripsOrphanFunctionResponse(t *testing.T) { t.Parallel() - // TrimLastN(2) keeps only the trailing functionResponse user turn — - // without orphan strip that would 400 at Google (no matching functionCall). const body = `{ "contents": [ {"role": "user", "parts": [{"text": "edit a.go"}]}, diff --git a/internal/translate/crossformat_request_test.go b/internal/translate/crossformat_request_test.go index 4e5d360e6..0f21c2db0 100644 --- a/internal/translate/crossformat_request_test.go +++ b/internal/translate/crossformat_request_test.go @@ -1157,7 +1157,7 @@ func TestCrossFormat_GeminiToAnthropic_TextAndTools(t *testing.T) { require.NoError(t, err) prep, err := env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-haiku-4-5"}) - require.NoError(t, err, "Gemini→Anthropic must succeed for summarizer ingest") + require.NoError(t, err) assert.Equal(t, "claude-haiku-4-5", gjson.GetBytes(prep.Body, "model").String()) assert.Equal(t, "be helpful", gjson.GetBytes(prep.Body, "system.0.text").String()) diff --git a/internal/translate/emit_anthropic_from_gemini.go b/internal/translate/emit_anthropic_from_gemini.go index 3e5b84170..0779b1ccf 100644 --- a/internal/translate/emit_anthropic_from_gemini.go +++ b/internal/translate/emit_anthropic_from_gemini.go @@ -7,9 +7,8 @@ import ( ) // buildAnthropicFromGemini converts a native Gemini generateContent body into -// an Anthropic Messages request. Used by the handover/compaction summarizer -// (ProviderSummarizer → PrepareAnthropic); product Gemini→non-Google dispatch -// remains deferred at the proxy layer. +// an Anthropic Messages request. Product Gemini→non-Google dispatch remains +// deferred at the proxy layer. func (e *RequestEnvelope) buildAnthropicFromGemini(opts EmitOptions) ([]byte, error) { jw := newJSONWriter() jw.Obj() @@ -145,8 +144,7 @@ func buildAnthropicUserFromGeminiParts(parts gjson.Result, toolIDsByName map[str name := resp.Get("name").String() id := toolIDsByName[name] if id == "" { - // No matching prior functionCall in this conversion — still emit a - // tool_result so the summarizer sees the tool output text. + // No matching prior functionCall — still emit a tool_result with the tool output. id = sanitizeToolUseID("gemini_" + name + "_orphan") } blocks = append(blocks, anthropicToolResultBlock(id, geminiFunctionResponseText(resp))) diff --git a/internal/translate/gemini_to_anthropic_edge_test.go b/internal/translate/gemini_to_anthropic_edge_test.go index e62d32dfe..254fbf5d9 100644 --- a/internal/translate/gemini_to_anthropic_edge_test.go +++ b/internal/translate/gemini_to_anthropic_edge_test.go @@ -141,13 +141,13 @@ func TestGeminiToAnthropic_DoesNotCopyGeminiToolsArray(t *testing.T) { prep, err := env.PrepareAnthropic(http.Header{}, translate.EmitOptions{TargetModel: "claude-haiku-4-5"}) require.NoError(t, err) - assert.False(t, gjson.GetBytes(prep.Body, "tools").Exists(), "summarizer ingest must not forward Gemini tools schemas") + assert.False(t, gjson.GetBytes(prep.Body, "tools").Exists(), "tools array is not copied") } func TestGeminiToAnthropic_SameNameToolsPairToLatestCall(t *testing.T) { t.Parallel() - // Two edit calls; name→id map keeps the latest. Documented pairing rule. + // Same-name tool calls pair each functionResponse to the latest functionCall id. body := []byte(`{ "contents": [ {"role": "model", "parts": [ diff --git a/internal/translate/handover.go b/internal/translate/handover.go index c40d495ca..1b033f8af 100644 --- a/internal/translate/handover.go +++ b/internal/translate/handover.go @@ -279,7 +279,7 @@ func (e *RequestEnvelope) rewriteGeminiForHandover(summary string) int { preserved := 0 if latestUser.Exists() { // Strip functionResponse parts: the summary has no functionCall parts, - // so any functionResponse would be orphaned (Google 400). + // so any functionResponse would be orphaned. cleaned := stripGeminiFunctionResponseEntry(latestUser, nil) if cleaned != "" { rebuilt = append(rebuilt, cleaned) @@ -300,7 +300,7 @@ func (e *RequestEnvelope) rewriteGeminiForHandover(summary string) int { // stripGeminiFunctionResponseEntry removes functionResponse parts whose name // is not in knownNames (nil knownNames = strip all). Returns "" if nothing -// remains — matching stripAnthropicToolResultMsg for Anthropic handover. +// remains — matching stripAnthropicToolResultMsg. func stripGeminiFunctionResponseEntry(entry gjson.Result, knownNames map[string]struct{}) string { parts := entry.Get("parts") if !parts.IsArray() { From e1f07d33e14702690431d764598b8d569e951eb3 Mon Sep 17 00:00:00 2001 From: N Rohith Reddy Date: Thu, 16 Jul 2026 16:35:14 -0400 Subject: [PATCH 5/5] fix(translate): pair Gemini TrimLastN functionResponse by order Name-only matching let a stale same-name functionResponse survive when a later kept functionCall reused the tool name. Walk the kept window in order and consume unmatched calls so each response needs a preceding call. Signed-off-by: N Rohith Reddy Co-authored-by: Cursor --- internal/router/handover/summarizer_test.go | 32 ++++++++ internal/translate/handover.go | 84 +++++++++++++++------ 2 files changed, 92 insertions(+), 24 deletions(-) diff --git a/internal/router/handover/summarizer_test.go b/internal/router/handover/summarizer_test.go index 823e5b77a..4bf631d09 100644 --- a/internal/router/handover/summarizer_test.go +++ b/internal/router/handover/summarizer_test.go @@ -538,6 +538,38 @@ func TestTrimLastN_GeminiStripsOrphanFunctionResponse(t *testing.T) { assert.NotContains(t, string(prep.Body), "functionResponse") } +// TrimLastN drops a stale same-name functionResponse that precedes the kept functionCall. +func TestTrimLastN_GeminiStripsStaleSameNameFunctionResponse(t *testing.T) { + t.Parallel() + + // TrimLastN(3) keeps [stale FR edit, model FC edit, valid FR edit]. + // A name-only match would keep the stale FR because a later FC reuses "edit". + const body = `{ + "contents": [ + {"role": "user", "parts": [{"text": "first edit"}]}, + {"role": "model", "parts": [{"functionCall": {"name": "edit", "args": {"n": 1}}}]}, + {"role": "user", "parts": [{"functionResponse": {"name": "edit", "response": {"result": "stale"}}}]}, + {"role": "model", "parts": [{"functionCall": {"name": "edit", "args": {"n": 2}}}]}, + {"role": "user", "parts": [{"functionResponse": {"name": "edit", "response": {"result": "ok"}}}]} + ] +}` + env, err := translate.ParseGemini([]byte(body)) + require.NoError(t, err) + + elided := handover.TrimLastN(env, 3) + assert.Equal(t, 2, elided) + + prep, err := env.PrepareGemini(http.Header{}, translate.EmitOptions{TargetModel: "gemini-3.1-pro"}) + require.NoError(t, err) + contents := gjson.GetBytes(prep.Body, "contents").Array() + require.Len(t, contents, 2, "stale FR dropped; keep [model FC, valid FR]") + assert.Equal(t, "model", contents[0].Get("role").String()) + assert.Equal(t, "edit", contents[0].Get("parts.0.functionCall.name").String()) + assert.Equal(t, "user", contents[1].Get("role").String()) + assert.Equal(t, "ok", contents[1].Get("parts.0.functionResponse.response.result").String()) + assert.NotContains(t, string(prep.Body), "stale") +} + // Regression: mid-session model switch + TrimLastN can orphan tool_result // blocks, which the Anthropic→Gemini translation turned into an empty // functionResponse.name (Gemini 400). diff --git a/internal/translate/handover.go b/internal/translate/handover.go index 1b033f8af..633911daf 100644 --- a/internal/translate/handover.go +++ b/internal/translate/handover.go @@ -371,19 +371,20 @@ func (e *RequestEnvelope) trimGeminiLastN(n int) int { return len(all) - n } -// stripOrphanedGeminiFunctionResponses drops functionResponse parts whose -// tool name has no matching functionCall among the set's model turns; user -// entries left empty afterward are omitted entirely. +// stripOrphanedGeminiFunctionResponses walks entries in order and drops +// functionResponse parts that lack a preceding unmatched functionCall of the +// same name in the kept window. A later same-name call must not resurrect a +// stale response from a trimmed turn. User entries left empty are omitted. func stripOrphanedGeminiFunctionResponses(entries []gjson.Result) []string { - known := collectGeminiFunctionCallNames(entries) + pending := make(map[string]int) result := make([]string, 0, len(entries)) for _, entry := range entries { - role := entry.Get("role").String() - if role == "model" { + if entry.Get("role").String() == "model" { + addGeminiPendingFunctionCalls(pending, entry) result = append(result, entry.Raw) continue } - cleaned := stripGeminiFunctionResponseEntry(entry, known) + cleaned := stripGeminiFunctionResponseEntryConsuming(entry, pending) if cleaned != "" { result = append(result, cleaned) } @@ -391,26 +392,61 @@ func stripOrphanedGeminiFunctionResponses(entries []gjson.Result) []string { return result } -// collectGeminiFunctionCallNames returns the set of functionCall names present -// in model turns. -func collectGeminiFunctionCallNames(entries []gjson.Result) map[string]struct{} { - names := make(map[string]struct{}) - for _, entry := range entries { - if entry.Get("role").String() != "model" { - continue +// addGeminiPendingFunctionCalls increments the unmatched functionCall count +// for each named call in a model entry. +func addGeminiPendingFunctionCalls(pending map[string]int, entry gjson.Result) { + entry.Get("parts").ForEach(func(_, part gjson.Result) bool { + call := part.Get("functionCall") + if !call.Exists() { + call = part.Get("function_call") } - entry.Get("parts").ForEach(func(_, part gjson.Result) bool { - call := part.Get("functionCall") - if !call.Exists() { - call = part.Get("function_call") - } - if name := call.Get("name").String(); name != "" { - names[name] = struct{}{} - } + if name := call.Get("name").String(); name != "" { + pending[name]++ + } + return true + }) +} + +// stripGeminiFunctionResponseEntryConsuming keeps functionResponse parts that +// can be paired with a preceding unmatched functionCall (decrementing the +// pending count). Unmatched responses are dropped. Returns "" if nothing remains. +func stripGeminiFunctionResponseEntryConsuming(entry gjson.Result, pending map[string]int) string { + parts := entry.Get("parts") + if !parts.IsArray() { + return entry.Raw + } + var kept []string + changed := false + parts.ForEach(func(_, part gjson.Result) bool { + resp := part.Get("functionResponse") + if !resp.Exists() { + resp = part.Get("function_response") + } + if !resp.Exists() { + kept = append(kept, part.Raw) return true - }) + } + name := resp.Get("name").String() + if pending[name] > 0 { + pending[name]-- + kept = append(kept, part.Raw) + return true + } + changed = true + return true + }) + if !changed { + return entry.Raw } - return names + if len(kept) == 0 { + return "" + } + newParts := "[" + strings.Join(kept, ",") + "]" + out, err := sjson.SetRawBytes([]byte(entry.Raw), "parts", []byte(newParts)) + if err != nil { + return entry.Raw + } + return string(out) } // stripOrphanedAnthropicToolResults drops tool_result blocks whose tool_use_id