mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 14:43:37 -04:00
test(core): cover Gemini partial recovery
This commit is contained in:
+34
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -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 }] },
|
||||
])
|
||||
})
|
||||
Reference in New Issue
Block a user