diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index bbbf9a61325..40486d1b240 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -190,6 +190,13 @@ eligible Enterprise accounts. Cloud Code Assist serves Argon only to the Antigra OpenCodex sends Argon requests and model discovery with the CLI User-Agent (`antigravity/cli/1.3.4`) and keeps the IDE User-Agent for every other model. +On Vertex AI, `gemini-4-argon` receives images returned by tools, such as screenshots, inside +the matching function response. This avoids the upstream 400 error "Requests ending with a +model turn are not supported" after an image-producing tool call while preserving the images. +Images from duplicate results or results without a matching call are preserved in a separate +message after the tool responses, with a marker explaining that they could not be paired. +No configuration change is needed; this handling is specific to Vertex Argon. + For Gemini on Antigravity, including Argon, provider-marked thought summaries stay separate from the answer: Responses uses reasoning summary items, Chat Completions uses `reasoning_content`, and Messages uses `thinking` blocks. Clients decide how to display those diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 71e0fcb6d1f..90e504c9206 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -121,6 +121,13 @@ ocx logout Google Antigravity 계정·제공자 할당량 확인은 모델 목록 폴백을 포함해 고정된 Google 회계 엔드포인트를 사용합니다. 해당 목적지의 투명 Fake-IP DNS를 지원하며 TLS 검증, 리다이렉트 거부, 사설 주소 검사는 유지합니다. 사용자 지정 base URL은 모델 요청에만 적용되며 할당량 목적지는 바꾸지 않습니다. `NO_PROXY`는 기존 직접 연결 정책을 유지합니다. +Vertex AI의 `gemini-4-argon`에는 스크린샷 같은 도구 결과 이미지를 해당 함수 응답 안에 담아 +전송합니다. 이미지가 있는 도구 호출 뒤에 발생하는 400 오류 +"Requests ending with a model turn are not supported"를 방지하며 이미지를 보존합니다. +중복된 결과나 일치하는 호출이 없는 결과의 이미지는 도구 응답 뒤의 별도 메시지로 보존하고, +호출과 연결할 수 없었다는 표시를 남깁니다. +별도의 설정 변경은 필요하지 않으며, 이 처리는 Vertex Argon에만 적용됩니다. + ### Google 도구 스키마 손실 진단 Google 도구 선언은 선택된 엔드포인트 클래스에 맞춰 컴파일됩니다. `ocx debug provider on`, diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 6bcafa0ce4d..ed55b5870f5 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -333,13 +333,20 @@ function geminiToolResultParts( msg: OcxToolResultMessage, wireName: string, wireCallId: string, + nestToolResultImages: boolean, ): unknown[] { const functionResponse: Record = { name: wireName, response: { result: geminiToolResultText(msg.content) }, id: wireCallId, }; - return [{ functionResponse }, ...toolResultImageParts(msg.content)]; + const images = toolResultImageParts(msg.content); + // Vertex Argon rejects image siblings beside a functionResponse as an unsupported model tail. + if (nestToolResultImages && images.length > 0) { + functionResponse.parts = images; + return [{ functionResponse }]; + } + return [{ functionResponse }, ...images]; } function geminiMissingToolResultPart(wireName: string, wireCallId: string): unknown { @@ -370,6 +377,7 @@ function messagesToGeminiFormat( identityModelId: string, stripRejectedClaudeSdkParagraph = false, isCloudCodeAssist = false, + nestToolResultImages = false, ): { systemInstruction?: unknown; contents: unknown[]; replayedCallIds: string[] } { // Neutralize Codex's GPT-5 identity line (Gemini/Antigravity share this path) so a routed model // never misreports as GPT-5/OpenAI, and never leaks the proxy identity upstream. @@ -551,13 +559,17 @@ function messagesToGeminiFormat( const responseParts: unknown[] = []; for (const call of toolCalls) { const result = resultsById.get(call.wireCallId); - if (result) responseParts.push(...geminiToolResultParts(result, call.wireName, call.wireCallId)); + if (result) responseParts.push(...geminiToolResultParts(result, call.wireName, call.wireCallId, nestToolResultImages)); else responseParts.push(geminiMissingToolResultPart(call.wireName, call.wireCallId)); } - for (const orphan of orphanResults) { - responseParts.push(...geminiOrphanToolResultParts(orphan)); - } + const orphanParts = orphanResults.flatMap(geminiOrphanToolResultParts); contents.push({ role: "user", parts: responseParts }); + // Orphan images cannot share Argon's response batch or claim another call's ownership. + if (nestToolResultImages && orphanParts.some(part => typeof part === "object" && part !== null && "inline_data" in part)) { + contents.push({ role: "user", parts: orphanParts }); + } else { + responseParts.push(...orphanParts); + } i = j - 1; } break; @@ -1026,6 +1038,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte identityModelId, stripRejectedClaudeSdkParagraph, isCloudCodeAssist, + provider.googleMode === "vertex" && parsed.modelId === "gemini-4-argon", ); lastInjectedCallIds = [...replayedCallIds]; lastReasoningReplayScope = parsed._reasoningReplayScope; diff --git a/structure/decisions/ADR-7279-vertex-argon-tool-images.md b/structure/decisions/ADR-7279-vertex-argon-tool-images.md new file mode 100644 index 00000000000..88b7f6940fc --- /dev/null +++ b/structure/decisions/ADR-7279-vertex-argon-tool-images.md @@ -0,0 +1,16 @@ +# ADR-7279: decision recorded under "Google tool-result adjacency repair" + +- Contract owner: [providers/google.md](../providers/google.md#google-tool-result-adjacency-repair) +- Purpose: allow Vertex `gemini-4-argon` to continue after a tool returns an image (#7279). +- Prior constraint: tool images were sibling `inline_data` parts beside `functionResponse`. + A synthetic live request ending in `user` still received HTTP 400, "Requests ending with + a model turn are not supported"; adding final user text did not repair it. +- Alternatives: discard images, move them to a separate user turn, or nest them inside the + response. Discarding loses information; separate turns obscure ownership in parallel results. +- Choice: nest matched result images in `functionResponse.parts` only for Vertex + `gemini-4-argon`, preserving the existing text, IDs, names, order and image bytes. The nested + synthetic request succeeded and reported IMAGE input tokens. Other routes retain their existing + representation because this incompatibility was reproduced only on Vertex Argon. +- Consequences: no new configuration, retry or shared-history mutation. Orphan results still + degrade to marked text and sibling images; existing adjacency and thought-signature repairs + remain intact. Revisit the model scope when another route has equivalent upstream evidence. diff --git a/structure/decisions/ADR-7280-vertex-argon-orphan-images.md b/structure/decisions/ADR-7280-vertex-argon-orphan-images.md new file mode 100644 index 00000000000..401567e7787 --- /dev/null +++ b/structure/decisions/ADR-7280-vertex-argon-orphan-images.md @@ -0,0 +1,15 @@ +# ADR-7280: decision recorded under "Google tool-result adjacency repair" + +- Contract owner: [providers/google.md](../providers/google.md#google-tool-result-adjacency-repair) +- Purpose: preserve adjacent orphan images without reopening the Vertex Argon 400 from #7279. +- Prior constraint: ADR-7279 nested matched images, but duplicate or mismatched result images + still shared their user turn with the function responses. Both cases reproduced the same + upstream 400; moving the orphan content to a following user turn succeeded. +- Alternatives: discard orphan images, attach them to a matched response, or preserve them in + a separate turn. The first loses content; the second invents ownership. +- Choice: for Vertex `gemini-4-argon` only, move the entire adjacent orphan batch to a following + user turn when it contains a representable image. Keep the complete response batch adjacent + to its calls and preserve the orphan markers, image bytes, and orphan result order. +- Consequences: no history mutation, invented success, configuration or retry. Text-only and + standalone orphans keep their prior shape; other Google routes are unchanged. The existing + Vertex regression file covers duplicates, mismatches, missing results and route isolation. diff --git a/structure/providers/google.md b/structure/providers/google.md index b4a7383e676..050021d8257 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -132,9 +132,18 @@ Google-family requests serialize a model tool-call turn and its results as one a `model -> user` pair. The user turn contains exactly one `functionResponse` for every representable call in original call order. Missing results use an explicit unknown-history marker; duplicate, mismatched, and standalone results become marked text instead of unpaired function responses. -Representable data-URL images remain sibling `inline_data` parts in either case. +For Vertex `gemini-4-argon`, matched results carry representable data-URL images inside +`functionResponse.parts`, preserving each image's call ownership. Sibling images on that route can +produce a misleading "Requests ending with a model turn are not supported" error even when the +last turn is `user`. When an adjacent orphan batch contains representable images on that route, +the entire marked orphan batch follows the complete function-response batch in a separate user +turn, preserving orphan result order and image bytes without assigning an orphan image to another call. Text-only +orphans, standalone orphans and other routes retain their existing representation. +`tests/adapters/google/google-vertex-thought-signature.test.ts` covers the scoped wire format. > Decision record: [ADR-0058](../decisions/ADR-0058-google-tool-result-adjacency-repair.md) +> Decision record: [ADR-7279](../decisions/ADR-7279-vertex-argon-tool-images.md) +> Decision record: [ADR-7280](../decisions/ADR-7280-vertex-argon-orphan-images.md) ## Google opening functionCall repair diff --git a/tests/adapters/google/google-vertex-thought-signature.test.ts b/tests/adapters/google/google-vertex-thought-signature.test.ts index 585067de081..0f8ad956661 100644 --- a/tests/adapters/google/google-vertex-thought-signature.test.ts +++ b/tests/adapters/google/google-vertex-thought-signature.test.ts @@ -5,7 +5,7 @@ import { applyAntigravityReplay, } from "../../../src/adapters/google-antigravity-replay"; import { antigravitySessionId } from "../../../src/adapters/google-antigravity-wire"; -import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; +import type { AdapterEvent, OcxContentPart, OcxParsedRequest, OcxProviderConfig } from "../../../src/types"; import { withTestTranslatorBudget } from "../../helpers/translator-budget"; const createGoogleAdapter = (...args: Parameters) => @@ -198,3 +198,301 @@ describe("Vertex thought-signature continuation (#1254)", () => { expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBeUndefined(); }); }); + +describe("Vertex Argon tool-result images", () => { + const image = (data = "YQ==", mediaType = "image/png"): OcxContentPart => + ({ type: "image", imageUrl: `data:${mediaType};base64,${data}` }); + const inline = (data = "YQ==", mime_type = "image/png") => ({ inline_data: { mime_type, data } }); + + async function wire( + messages = continuation().context.messages, + googleMode: OcxProviderConfig["googleMode"] = "vertex", + modelId = "gemini-4-argon", + ) { + const built = await createGoogleAdapter({ ...provider, googleMode, project: "test-project" }) + .buildRequest({ ...request(messages, false), modelId }); + const body = JSON.parse(built.body); + return body.request ?? body; + } + + function history(content: string | OcxContentPart[]) { + const messages = continuation().context.messages; + messages[2] = { role: "toolResult", toolCallId: "call_shell_1", toolName: "shell_command", + content, isError: false, timestamp: 0 }; + return messages; + } + + test.each([false, true])("Argon image continuation replays the original signature (stream=%s)", async (stream) => { + __resetAntigravityReplayCache(); + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest({ ...firstTurn(stream), modelId: "gemini-4-argon" }); + const json = JSON.stringify(vertexResponseBody()); + const response = new Response(stream ? `data: ${json}\n\n` : json); + const events: AdapterEvent[] = []; + if (stream) { + for await (const event of adapter.parseStream(response)) events.push(event); + } else events.push(...await adapter.parseResponse!(response)); + expect(events.some(event => event.type === "tool_call_start")).toBe(true); + const followup = await createGoogleAdapter(provider).buildRequest({ + ...request(history([image()]), false), modelId: "gemini-4-argon", + }); + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + expect(JSON.parse(followup.body).contents.at(-1).parts[0].functionResponse.parts).toEqual([inline()]); + }); + + test("Argon continuation replays the signature, nests the matched image and separates a duplicate image", async () => { + __resetAntigravityReplayCache(); + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest({ ...firstTurn(false), modelId: "gemini-4-argon" }); + await adapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + const messages = history([image()]); + messages.push({ role: "toolResult", toolCallId: "call_shell_1", toolName: "shell_command", + content: [{ type: "text", text: "again" }, image("Yg==")], isError: false, timestamp: 0 }); + const followup = await createGoogleAdapter(provider).buildRequest({ ...request(messages, false), modelId: "gemini-4-argon" }); + const contents = JSON.parse(followup.body as string).contents; + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + expect(contents.at(-2)).toEqual({ role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: "[image]" }, parts: [inline()], + } }] }); + expect(contents.at(-1)).toEqual({ role: "user", parts: [ + { text: "[tool_result without adjacent tool_use: shell_command (call_shell_1)]\nagain[image]" }, inline("Yg=="), + ] }); + const textParts = contents.flatMap((c: { parts: Array<{ text?: string }> }) => c.parts).filter((p: { text?: string }) => typeof p.text === "string"); + expect(textParts.some((p: { text: string }) => p.text.includes("YQ==") || p.text.includes("Yg=="))).toBe(false); + }); + + test.each([false, true])("streaming and non-streaming continuations nest Argon images identically (stream=%p)", async (stream) => { + __resetAntigravityReplayCache(); + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest({ ...firstTurn(false), modelId: "gemini-4-argon" }); + await adapter.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + const followup = await createGoogleAdapter(provider).buildRequest({ + ...request(history([image()]), stream), modelId: "gemini-4-argon", + }); + expect(followup.url).toContain(stream ? ":streamGenerateContent" : ":generateContent"); + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + expect(JSON.parse(followup.body as string).contents.at(-1)).toEqual({ role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: "[image]" }, parts: [inline()], + } }] }); + }); + + test("error results keep matched-image ownership and orphan-image placement", async () => { + const messages = history([{ type: "text", text: "failed" }, image()]); + (messages[2] as { isError: boolean }).isError = true; + messages.push({ role: "toolResult", toolCallId: "call_shell_1", toolName: "shell_command", + content: [{ type: "text", text: "failed again" }, image("Yg==")], isError: true, timestamp: 0 }); + const body = await wire(messages); + expect(body.contents.at(-2)).toEqual({ role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: "failed[image]" }, parts: [inline()], + } }] }); + expect(body.contents.at(-1)).toEqual({ role: "user", parts: [ + { text: "[tool_result without adjacent tool_use: shell_command (call_shell_1)]\nfailed again[image]" }, inline("Yg=="), + ] }); + }); + + const withAmbientVertexEnv = async (run: () => Promise) => { + const saved = { p: process.env.GOOGLE_CLOUD_PROJECT, g: process.env.GCLOUD_PROJECT, l: process.env.GOOGLE_CLOUD_LOCATION }; + try { + process.env.GOOGLE_CLOUD_PROJECT = "ambient-project"; + process.env.GCLOUD_PROJECT = "ambient-gcloud-project"; + process.env.GOOGLE_CLOUD_LOCATION = "europe-west4"; + await run(); + } finally { + if (saved.p === undefined) delete process.env.GOOGLE_CLOUD_PROJECT; else process.env.GOOGLE_CLOUD_PROJECT = saved.p; + if (saved.g === undefined) delete process.env.GCLOUD_PROJECT; else process.env.GCLOUD_PROJECT = saved.g; + if (saved.l === undefined) delete process.env.GOOGLE_CLOUD_LOCATION; else process.env.GOOGLE_CLOUD_LOCATION = saved.l; + } + }; + const expectApiKeyAuth = (built: { headers?: Record }) => { + const headers = Object.fromEntries(Object.entries(built.headers ?? {}).map(([k, v]) => [k.toLowerCase(), v])); + expect(headers["x-goog-api-key"]).toBe("vertex-test-key"); + expect(headers.authorization).toBeUndefined(); + }; + const orphanTurn = { role: "user", parts: [ + { text: "[tool_result without adjacent tool_use: shell_command (call_shell_1)]\nagain[image]" }, inline("Yg=="), + ] }; + const nestedTurn = { role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: "[image]" }, parts: [inline()], + } }] }; + function withDuplicate() { + const messages = history([image()]); + messages.push({ role: "toolResult", toolCallId: "call_shell_1", toolName: "shell_command", + content: [{ type: "text", text: "again" }, image("Yg==")], isError: false, timestamp: 0 }); + return messages; + } + + test("API-key project endpoint (#7266) carries Argon image nesting and signature replay (#7280)", async () => { + await withAmbientVertexEnv(async () => { + __resetAntigravityReplayCache(); + const keyed = { ...provider, project: "configured-project" } as OcxProviderConfig; + const first = createGoogleAdapter(keyed); + await first.buildRequest({ ...firstTurn(false), modelId: "gemini-4-argon" }); + await first.parseResponse!(new Response(JSON.stringify(vertexResponseBody()))); + const followup = await createGoogleAdapter(keyed).buildRequest({ ...request(withDuplicate(), false), modelId: "gemini-4-argon" }); + expect(followup.url).toBe("https://aiplatform.googleapis.com/v1/projects/configured-project/locations/global/publishers/google/models/gemini-4-argon:generateContent"); + expectApiKeyAuth(followup); + const contents = JSON.parse(followup.body as string).contents; + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + expect(contents.at(-2)).toEqual(nestedTurn); + expect(contents.at(-1)).toEqual(orphanTurn); + }); + }); + + test("projectless API key stays on the express endpoint while nesting Argon images", async () => { + await withAmbientVertexEnv(async () => { + const built = await createGoogleAdapter(provider).buildRequest({ ...request(withDuplicate(), false), modelId: "gemini-4-argon" }); + expect(built.url).toBe("https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-4-argon:generateContent"); + expectApiKeyAuth(built); + const contents = JSON.parse(built.body as string).contents; + expect(contents.at(-2)).toEqual(nestedTurn); + expect(contents.at(-1)).toEqual(orphanTurn); + }); + }); + + test.each([ + ["single image", [image()], [inline()], "[image]"], + ["multiple images with text", [{ type: "text", text: "captured" }, image(), image("Yg==", "image/jpeg")], + [inline(), inline("Yg==", "image/jpeg")], "captured[image][image]"], + ] as const)("nests %s inside the matching functionResponse", async (_label, images, expected, text) => { + const body = await wire(history([...images])); + expect(body.contents.at(-1)).toEqual({ role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: text }, parts: [...expected], + } }] }); + }); + + test("reversed parallel results retain image ownership and collision-safe call ids", async () => { + const messages = continuation().context.messages; + messages[1] = { role: "assistant", timestamp: 0, content: [ + { type: "toolCall", id: "call:a", name: "first", arguments: {} }, + { type: "toolCall", id: "call/a", name: "second", arguments: {} }, + ] }; + messages.splice(2, 1, + { role: "toolResult", toolCallId: "call/a", toolName: "second", content: [image("Yg==")], + isError: false, timestamp: 0 }, + { role: "toolResult", toolCallId: "call:a", toolName: "first", isError: false, timestamp: 0, + content: [{ type: "text", text: "first" }, image(), image("Yw==")] }, + ); + const body = await wire(messages); + const [first, second] = body.contents[1].parts; + expect(first.functionCall.id).not.toBe(second.functionCall.id); + expect(body.contents.at(-1).parts).toEqual([ + { functionResponse: { id: first.functionCall.id, name: "first", + response: { result: "first[image][image]" }, parts: [inline(), inline("Yw==")] } }, + { functionResponse: { id: second.functionCall.id, name: "second", + response: { result: "[image]" }, parts: [inline("Yg==")] } }, + ]); + }); + + test.each([ + ["plain text", "plain text"], ["empty string", ""], ["empty parts", []], + ] as const)("text-only output retains its existing shape (%s)", async (_label, content) => { + const body = await wire(history(typeof content === "string" ? content : [...content])); + expect(body.contents.at(-1).parts).toEqual([{ functionResponse: { + id: "call_shell_1", name: "shell_command", + response: { result: typeof content === "string" && content ? content : "(empty tool output)" }, + } }]); + }); + + test.each([ + ["vertex", "gemini-3.1-pro"], + ["vertex", "gemini-4-argon-preview"], + ["ai-studio", "gemini-4-argon"], + ["cloud-code-assist", "gemini-4-argon"], + ] as const)("other routes retain image siblings (%s, %s)", async (mode, model) => { + const body = await wire(history([image()]), mode, model); + expect(body.contents.at(-1).parts).toEqual([ + { functionResponse: { id: "call_shell_1", name: "shell_command", response: { result: "[image]" } } }, + inline(), + ]); + }); + + test("ordinary user and orphan result images retain their existing shape", async () => { + const body = await wire([ + { role: "user", content: [image()], timestamp: 0 }, + { role: "toolResult", toolCallId: "orphan", toolName: "inspect", content: [image("Yg==")], + isError: false, timestamp: 0 }, + ]); + expect(body.contents).toEqual([ + { role: "user", parts: [inline()] }, + { role: "user", parts: [ + { text: "[tool_result without adjacent tool_use: inspect (orphan)]\n[image]" }, inline("Yg=="), + ] }, + ]); + }); + + test.each(["call_shell_1", "unknown"])("separates orphan images after the complete response batch (%s)", async (id) => { + const messages = history([image()]); + messages.push( + { role: "toolResult", toolCallId: id, toolName: "inspect", content: "before", isError: false, timestamp: 0 }, + { role: "toolResult", toolCallId: id, toolName: "inspect", + content: [{ type: "text", text: "orphan" }, image("Yg=="), image("Yw==", "image/jpeg")], + isError: false, timestamp: 0 }, + { role: "toolResult", toolCallId: id, toolName: "inspect", content: "after", isError: false, timestamp: 0 }, + ); + const original = structuredClone(messages); + const body = await wire(messages); + expect(body.contents.slice(2)).toEqual([ + { role: "user", parts: [{ functionResponse: { + id: "call_shell_1", name: "shell_command", response: { result: "[image]" }, parts: [inline()], + } }] }, + { role: "user", parts: [ + { text: `[tool_result without adjacent tool_use: inspect (${id})]\nbefore` }, + { text: `[tool_result without adjacent tool_use: inspect (${id})]\norphan[image][image]` }, + inline("Yg=="), inline("Yw==", "image/jpeg"), + { text: `[tool_result without adjacent tool_use: inspect (${id})]\nafter` }, + ] }, + ]); + expect(messages).toEqual(original); + }); + + test("orphan images follow missing responses without claiming a successful call", async () => { + const messages = continuation().context.messages; + messages[2] = { role: "toolResult", toolCallId: "unknown", toolName: "inspect", content: [image()], + isError: false, timestamp: 0 }; + const body = await wire(messages); + expect(body.contents[2].parts).toEqual([{ functionResponse: { + id: "call_shell_1", name: "shell_command", + response: { result: "[missing tool_result for this tool_use in history]" }, + } }]); + expect(body.contents[3]).toEqual({ role: "user", parts: [ + { text: "[tool_result without adjacent tool_use: inspect (unknown)]\n[image]" }, inline(), + ] }); + }); + + test.each(["vertex", "ai-studio", "cloud-code-assist"] as const)("text-only orphans retain their existing batch (%s)", async (mode) => { + const messages = history("done"); + messages.push({ role: "toolResult", toolCallId: "unknown", toolName: "inspect", content: "orphan", + isError: false, timestamp: 0 }); + const body = await wire(messages, mode); + expect(body.contents.at(-1).parts).toEqual([ + { functionResponse: { id: "call_shell_1", name: "shell_command", response: { result: "done" } } }, + { text: "[tool_result without adjacent tool_use: inspect (unknown)]\norphan" }, + ]); + }); + + test.each([ + ["vertex", "gemini-3.1-pro"], ["vertex", "gemini-4-argon-preview"], + ["ai-studio", "gemini-4-argon"], ["cloud-code-assist", "gemini-4-argon"], + ] as const)("other routes retain orphan image siblings (%s, %s)", async (mode, model) => { + const messages = history("done"); + messages.push({ role: "toolResult", toolCallId: "unknown", toolName: "inspect", content: [image()], + isError: false, timestamp: 0 }); + const body = await wire(messages, mode, model); + expect(body.contents.at(-1).parts).toEqual([ + { functionResponse: { id: "call_shell_1", name: "shell_command", response: { result: "done" } } }, + { text: "[tool_result without adjacent tool_use: inspect (unknown)]\n[image]" }, inline(), + ]); + }); + + test("opening call, missing result and model-tail repairs stay intact", async () => { + const messages = continuation().context.messages; + const body = await wire([messages[1]]); + expect(body.contents[0]).toEqual({ role: "user", parts: [{ text: "(continue)" }] }); + expect(body.contents.at(-1).parts).toEqual([{ functionResponse: { + id: "call_shell_1", name: "shell_command", + response: { result: "[missing tool_result for this tool_use in history]" }, + } }]); + const tail = await wire([{ role: "assistant", content: [{ type: "text", text: "working" }], timestamp: 0 }]); + expect(tail.contents.at(-1)).toEqual({ role: "user", parts: [{ text: "(continue)" }] }); + }); +});