diff --git a/packages/ai/test/fixtures/recordings/gemini-recovery/accepts-an-unsigned-settled-tool-call-followed-by-continuation.json b/packages/ai/test/fixtures/recordings/gemini-recovery/accepts-an-unsigned-settled-tool-call-followed-by-continuation.json new file mode 100644 index 00000000000..307311bbfb3 --- /dev/null +++ b/packages/ai/test/fixtures/recordings/gemini-recovery/accepts-an-unsigned-settled-tool-call-followed-by-continuation.json @@ -0,0 +1,34 @@ +{ + "version": 1, + "metadata": { + "tags": [ + "prefix:gemini-recovery", + "provider:google", + "protocol:gemini", + "tool", + "recovery" + ], + "name": "gemini-recovery/accepts-an-unsigned-settled-tool-call-followed-by-continuation", + "recordedAt": "2026-08-22T01:23:18.801Z" + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:streamGenerateContent?alt=sse", + "headers": { + "content-type": "application/json" + }, + "body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"Look up the weather in Paris.\"}]},{\"role\":\"model\",\"parts\":[{\"text\":\"I should use the weather tool.\"},{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"sunny\"}}}]},{\"role\":\"user\",\"parts\":[{\"text\":\"The previous response was interrupted. Continue from where you left off without repeating completed content.\"}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Return benign weather data for a city.\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}],\"toolConfig\":{\"functionCallingConfig\":{\"mode\":\"NONE\"}},\"generationConfig\":{\"maxOutputTokens\":256,\"temperature\":0}}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream" + }, + "body": "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"The weather in Paris is currently sunny\"}],\"role\": \"model\"},\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 116,\"candidatesTokenCount\": 7,\"totalTokenCount\": 368,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 116}],\"thoughtsTokenCount\": 245,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BfqIaun_Gbau39IP49TwKA\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"EuEKCt4KARFNMg+TrcXumi0KeXb7iZG/dBk9QjybBSM9V7hTN2a0lEXMuWh38tNhXrQCwNFDZ2gKEpa2c1lj/+wwzJAvjceCFoRenOGfBEUGRavc6oQXLKiWZPGaHV0G4gIo2uosx8SNZc4E5Dko2IYPymcUzDgsqZK2eToDoepaM8cekix+6c/z2MZiRnJ0QbS3f/7r+UYLOIi7eWbW0G6v+gwPYae4kRuifL6iKEV5eG+R4AGlGfOI0dgamDfEFfGiwg+rkvjKw7DDmYvQVreUcjvFt2OOhKfFWmnvOQrIKkSYpSyvdlylRhKZMU5gwUsKii8xwI5eJVoUoYDoOjr1x8DR3ePkCj/n3b0OMUWunPkdpgE/bUcFMQ+Gzqs7F3sBYDgUiT3F9r5joKZp6dm0I64/gh15J0NCamlr+CRLP+a8tJbEcWZbgELhbFE+2ikJMtoApojvlUbIUsn7sXHQ6042A/MjWgV97DWcG6V1dtLOOrS2Ez2F58TWDW67dTD3o+Ixz82+xDfG4VW0pOiOPOo9n2YCGk9xlSwPG9qDwU6bpi0AFrJVSz2gc8OFXk5uwCooPAN0EmnfljWS5+spS7AmKA2k3S7IZKe0aAw90+Jp4JxklOsfL4XIlQJLLXKmtcnTEdZLYp/JxSL9YRNnoHZJaeBpIWEObE+aX+CuI3OqZZPpldalNeCPM1nCYu26rfBzAaP85zzyi4SFzlXc++E9OPei8BgPoD8XuTb1yK1nTtPqjR8bYvaDm9ef8NaWrYdM+ivVQ/Q/9YAQ8AXuC71dQZExcTQAC5oOnrRP1byo2lHMuytnVk8QrnZBhiJc/TneDFzCYK9nFkhc6s0aaQrBip6YEKPtGmuMmUxJ1dQYFQlgtjEvANB2/jnPtE6u1p039reevLAHMxtijgCqNE0hrVaciI1ZTLTYtO1UquoSXNoHqqZzYuEnLMUKxTbWzIOcVBC4XwP8rkYLwCp9e1oMMBV7FIT6Kv2El7tWrpBt2w+2GgQMt2bAr2PJQf1oj5O/vOXEbcTVumOv29rUJQ65pFpr8K6Q8W6lIbIxOGWLKb/akVl9FJq5lPVp+m3eEYurQcQJ1mB3OEuPrpK4COdiVElarAnaxaMpioa8yCh22kA4eqS+bChUDIZIfCUNRDGI0eO8YMkRo626VLp9IJnexYNBBwBKk5oL7TmrlzRz/YyUEApVNMUjHiRd24i2jq4pQyOdkbYMVi3YFZNQbRuo0EN9dM8vAG4VrAGPj892T2ne5B69Qmhgs8Ow4QrubWd+d6ohdsSyOucVruw1Mr+x79BqiQs+SQ5M/GJCPBYO3QIV3CMGnHNrtvg5YdYE5blCs0MCf+u5mHPPk2TlBuXMJtLUN/YmGhE4u1/ZT9WTIueekSSubPv1eqPwLRHLiwVfKdA/FNqu5YDifgzKMjyIQpXiEIW3OnI4A1ps8K4pMynZ6XXyZuvq74eHnX1vQDdDA27MiWJPrDWqzg5SQnOl026Mcpj0pIex1lN+7av8xAc9PVszdn9qq8WwO7yUOPM10HnE2hY4pDGa3LXo/2VMAbknQOwhEA6vEgGwFVXN2EXYS9fohRgrXbo7hCroJlekcuROXX7sq2JEK751xXWZlaIpujUpNt8uUGRjSIIFKhuYYvrmU7FEN/PgB0eyQ6KQp1iQm7ywzqMF0Ns2XbM6iAlMibLu48G6VplgUWpsNMtbJLLs3QuFeligNdARJ8aMfJarHlRFV/oQXWggnSyTO5wKCOif8IJUWuHR43JStLvFZsOzVvCDduPSdWKs/9vkri3iVbRFCmeTyuJB+xQuz3rbU+iVI9VOe2BIAhKY\"}],\"role\": \"model\"},\"finishReason\": \"MAX_TOKENS\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 116,\"candidatesTokenCount\": 7,\"totalTokenCount\": 368,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 116}],\"thoughtsTokenCount\": 245,\"serviceTier\": \"standard\"},\"modelVersion\": \"gemini-3.5-flash\",\"responseId\": \"BfqIaun_Gbau39IP49TwKA\"}\r\n\r\n" + } + } + ] +} diff --git a/packages/ai/test/provider/gemini-recovery.recorded.test.ts b/packages/ai/test/provider/gemini-recovery.recorded.test.ts new file mode 100644 index 00000000000..8dd56209db0 --- /dev/null +++ b/packages/ai/test/provider/gemini-recovery.recorded.test.ts @@ -0,0 +1,57 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, Message, ToolCallPart, ToolDefinition } from "../../src/index.js" +import { configure } from "../../src/providers/google.js" +import { LLMClient } from "../../src/route.js" +import { recordedTests } from "../recorded-test.js" + +const model = configure({ + apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture", +}).model("gemini-3.5-flash") +const continuation = + "The previous response was interrupted. Continue from where you left off without repeating completed content." +const lookup = ToolDefinition.make({ + name: "lookup_weather", + description: "Return benign weather data for a city.", + inputSchema: { + type: "object", + properties: { city: { type: "string" } }, + required: ["city"], + }, +}) +const recorded = recordedTests({ + prefix: "gemini-recovery", + provider: "google", + protocol: "gemini", + requires: ["GOOGLE_GENERATIVE_AI_API_KEY"], +}) + +describe("Gemini interrupted recovery recorded", () => { + recorded.effect.with( + "accepts an unsigned settled tool call followed by continuation", + { tags: ["tool", "recovery"] }, + () => + Effect.gen(function* () { + const response = yield* LLMClient.generate( + LLM.request({ + id: "recorded_gemini_interrupted_tool_recovery", + model, + messages: [ + Message.user("Look up the weather in Paris."), + Message.assistant([ + { type: "text", text: "I should use the weather tool." }, + ToolCallPart.make({ id: "call_weather", name: "lookup_weather", input: { city: "Paris" } }), + ]), + Message.tool({ id: "call_weather", name: "lookup_weather", result: "sunny", resultType: "text" }), + Message.user(continuation), + ], + tools: [lookup], + toolChoice: "none", + generation: { maxTokens: 256, temperature: 0 }, + }), + ) + + expect(response.text.trim().length).toBeGreaterThan(0) + }), + ) +}) diff --git a/packages/core/test/session-provider-recovery.test.ts b/packages/core/test/session-provider-recovery.test.ts new file mode 100644 index 00000000000..b029872f36a --- /dev/null +++ b/packages/core/test/session-provider-recovery.test.ts @@ -0,0 +1,114 @@ +import { expect, test } from "bun:test" +import { LLM, Message } from "@opencode-ai/ai" +import { configure } from "@opencode-ai/ai/providers/google" +import { compileRequest } from "@opencode-ai/ai/route/client" +import { Agent } from "@opencode-ai/core/agent" +import { Model } from "@opencode-ai/core/model" +import { Provider } from "@opencode-ai/core/provider" +import { SessionMessage } from "@opencode-ai/core/session/message" +import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message" +import { DateTime, Effect } from "effect" + +const created = DateTime.makeUnsafe(0) +const google = Model.Ref.make({ + id: Model.ID.make("gemini-3.5-flash"), + providerID: Provider.ID.make("google"), +}) +const continuation = + "The previous response was interrupted. Continue from where you left off without repeating completed content." + +test("failed Gemini tool history replays through the public request boundary", async () => { + const messages = toLLMMessages( + [ + SessionMessage.User.make({ + id: SessionMessage.ID.make("msg_user"), + type: "user", + text: "Look up the weather.", + time: { created }, + }), + SessionMessage.Assistant.make({ + id: SessionMessage.ID.make("msg_failed"), + type: "assistant", + agent: Agent.defaultID, + model: google, + content: [ + SessionMessage.AssistantReasoning.make({ + type: "reasoning", + text: "I should use the weather tool.", + state: { thoughtSignature: "signed-reasoning" }, + }), + SessionMessage.AssistantTool.make({ + type: "tool", + id: "call_weather", + name: "lookup_weather", + executed: false, + providerState: { thoughtSignature: "signed-tool-call" }, + state: SessionMessage.ToolStateCompleted.make({ + status: "completed", + input: { city: "Paris" }, + content: [{ type: "text", text: "sunny" }], + }), + time: { created, completed: created }, + }), + ], + finish: "error", + error: { type: "provider.internal", message: "Provider stream failed" }, + time: { created, completed: created }, + }), + SessionMessage.Synthetic.make({ + id: SessionMessage.ID.make("msg_continue"), + type: "synthetic", + text: continuation, + time: { created }, + }), + ], + google, + ) + const prepared = await Effect.runPromise( + compileRequest( + LLM.request({ + model: configure({ apiKey: "test" }).model("gemini-3.5-flash"), + messages, + }), + ), + ) + + expect(messages).toMatchObject([ + { role: "user" }, + { + role: "assistant", + content: [ + { type: "text", text: "I should use the weather tool." }, + { type: "tool-call", id: "call_weather", providerMetadata: undefined }, + ], + }, + { role: "tool", content: [{ type: "tool-result", id: "call_weather" }] }, + { role: "user", content: [{ type: "text", text: continuation }] }, + ]) + expect(prepared.body.contents).toEqual([ + { role: "user", parts: [{ text: "Look up the weather." }] }, + { + role: "model", + parts: [ + { text: "I should use the weather tool.", thoughtSignature: undefined }, + { + functionCall: { id: undefined, name: "lookup_weather", args: { city: "Paris" } }, + thoughtSignature: "skip_thought_signature_validator", + }, + ], + }, + { + role: "user", + parts: [ + { + functionResponse: { + id: undefined, + name: "lookup_weather", + response: { name: "lookup_weather", content: "sunny" }, + }, + }, + ], + }, + { role: "user", parts: [{ text: continuation }] }, + ]) +})