diff --git a/internal/proxy/gemini_handover_test.go b/internal/proxy/gemini_handover_test.go new file mode 100644 index 000000000..4adf42827 --- /dev/null +++ b/internal/proxy/gemini_handover_test.go @@ -0,0 +1,387 @@ +package proxy_test + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "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 forwards [summary, latestUser]. +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 strips orphan functionResponse from the forwarded body. +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, "no functionResponse in forwarded body") + assert.Equal(t, 1, len(contents.Array()), "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 +} + +// ProviderSummarizer converts Gemini history and bounds the SWITCH body. +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(), "summarizer Proxy was called") + + 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", "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, "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()) + assert.NotContains(t, string(googleUp.proxyBodies[0]), "functionResponse") + 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) + 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", "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) + 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: ""} + 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], "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) + 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 251a83a3d..03eb58373 100644 --- a/internal/proxy/handover_internal_test.go +++ b/internal/proxy/handover_internal_test.go @@ -164,3 +164,52 @@ func TestProviderSummarizer_NilEnvelopeReturnsError(t *testing.T) { _, _, err := s.Summarize(context.Background(), nil) require.Error(t, err) } + +// Summarize accepts a Gemini envelope. +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) + 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() + + 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) + 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..4bf631d09 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() @@ -443,6 +510,66 @@ 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() + + 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") +} + +// 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/crossformat_request_test.go b/internal/translate/crossformat_request_test.go index 59810d759..0f21c2db0 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) + + 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..0779b1ccf --- /dev/null +++ b/internal/translate/emit_anthropic_from_gemini.go @@ -0,0 +1,211 @@ +package translate + +import ( + "strconv" + + "github.com/tidwall/gjson" +) + +// buildAnthropicFromGemini converts a native Gemini generateContent body into +// 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() + 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 — still emit a tool_result with the tool output. + 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/gemini_to_anthropic_edge_test.go b/internal/translate/gemini_to_anthropic_edge_test.go new file mode 100644 index 000000000..254fbf5d9 --- /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(), "tools array is not copied") +} + +func TestGeminiToAnthropic_SameNameToolsPairToLatestCall(t *testing.T) { + t.Parallel() + + // Same-name tool calls pair each functionResponse to the latest functionCall id. + 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 6d7d7da08..633911daf 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. + cleaned := stripGeminiFunctionResponseEntry(latestUser, nil) + if cleaned != "" { + rebuilt = append(rebuilt, cleaned) + preserved = 1 + } } elided := max(len(all)-preserved, 0) @@ -293,6 +298,59 @@ func (e *RequestEnvelope) rewriteGeminiForHandover(summary string) int { return elided } +// stripGeminiFunctionResponseEntry removes functionResponse parts whose name +// is not in knownNames (nil knownNames = strip all). Returns "" if nothing +// remains — matching stripAnthropicToolResultMsg. +func stripGeminiFunctionResponseEntry(entry gjson.Result, knownNames map[string]struct{}) string { + parts := entry.Get("parts") + if !parts.IsArray() { + return entry.Raw + } + hasOrphans := false + parts.ForEach(func(_, part gjson.Result) bool { + 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 !hasOrphans { + return entry.Raw + } + var kept []string + parts.ForEach(func(_, part gjson.Result) bool { + 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 + }) + 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() { @@ -303,17 +361,92 @@ 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 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 { + pending := make(map[string]int) + result := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.Get("role").String() == "model" { + addGeminiPendingFunctionCalls(pending, entry) + result = append(result, entry.Raw) + continue + } + cleaned := stripGeminiFunctionResponseEntryConsuming(entry, pending) + if cleaned != "" { + result = append(result, cleaned) + } + } + return result +} + +// 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") + } + 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 + } + 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