From ba3190f5195f7d943d992930f1cb35f63d329d97 Mon Sep 17 00:00:00 2001 From: Jossi02 <02love326@kyonggi.ac.kr> Date: Sun, 18 Oct 2026 01:27:32 +0900 Subject: [PATCH 1/6] fix(google): nest Vertex Argon tool-result images --- .../src/content/docs/guides/providers.md | 5 + .../src/content/docs/ko/guides/providers.md | 5 + src/adapters/google.ts | 13 ++- .../ADR-7279-vertex-argon-tool-images.md | 16 +++ structure/providers/google.md | 7 +- .../google-vertex-thought-signature.test.ts | 109 +++++++++++++++++- 6 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 structure/decisions/ADR-7279-vertex-argon-tool-images.md diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index bbbf9a61325..98665230a38 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -190,6 +190,11 @@ 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. +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..66ebfe9b305 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -121,6 +121,11 @@ 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 5093fe09179..4a3382b58a2 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,7 +559,7 @@ 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) { @@ -1026,6 +1034,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/providers/google.md b/structure/providers/google.md index ca74f843867..2b2bf1c98b1 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -113,9 +113,14 @@ 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`. Other routes and marked orphan results retain sibling `inline_data` parts. +`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) ## 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 9a95896a4c2..26077fd5a9f 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) => @@ -161,3 +161,110 @@ 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([ + ["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("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)" }] }); + }); +}); From af5a3db2420e4931b716bbb7d463358ecb1f90d2 Mon Sep 17 00:00:00 2001 From: Jossi02 <02love326@kyonggi.ac.kr> Date: Sun, 18 Oct 2026 02:05:44 +0900 Subject: [PATCH 2/6] fix(google): separate orphan images from Vertex Argon response batches --- .../src/content/docs/guides/providers.md | 2 + .../src/content/docs/ko/guides/providers.md | 2 + src/adapters/google.ts | 10 ++- .../ADR-7280-vertex-argon-orphan-images.md | 15 ++++ structure/providers/google.md | 6 +- .../google-vertex-thought-signature.test.ts | 82 +++++++++++++++++++ 6 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 structure/decisions/ADR-7280-vertex-argon-orphan-images.md diff --git a/docs-site/src/content/docs/guides/providers.md b/docs-site/src/content/docs/guides/providers.md index 98665230a38..40486d1b240 100644 --- a/docs-site/src/content/docs/guides/providers.md +++ b/docs-site/src/content/docs/guides/providers.md @@ -193,6 +193,8 @@ OpenCodex sends Argon requests and model discovery with the CLI User-Agent 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 diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 66ebfe9b305..90e504c9206 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -124,6 +124,8 @@ Google Antigravity 계정·제공자 할당량 확인은 모델 목록 폴백을 Vertex AI의 `gemini-4-argon`에는 스크린샷 같은 도구 결과 이미지를 해당 함수 응답 안에 담아 전송합니다. 이미지가 있는 도구 호출 뒤에 발생하는 400 오류 "Requests ending with a model turn are not supported"를 방지하며 이미지를 보존합니다. +중복된 결과나 일치하는 호출이 없는 결과의 이미지는 도구 응답 뒤의 별도 메시지로 보존하고, +호출과 연결할 수 없었다는 표시를 남깁니다. 별도의 설정 변경은 필요하지 않으며, 이 처리는 Vertex Argon에만 적용됩니다. ### Google 도구 스키마 손실 진단 diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 4a3382b58a2..68f595dbdbe 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -562,10 +562,14 @@ function messagesToGeminiFormat( 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; 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..96597b41c4d --- /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 original text/image 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 2b2bf1c98b1..26de83ef2da 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -116,11 +116,15 @@ mismatched, and standalone results become marked text instead of unpaired functi 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`. Other routes and marked orphan results retain sibling `inline_data` parts. +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 text/image order 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 26077fd5a9f..5457c4c7153 100644 --- a/tests/adapters/google/google-vertex-thought-signature.test.ts +++ b/tests/adapters/google/google-vertex-thought-signature.test.ts @@ -185,6 +185,24 @@ describe("Vertex Argon tool-result images", () => { 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.each([ ["single image", [image()], [inline()], "[image]"], ["multiple images with text", [{ type: "text", text: "captured" }, image(), image("Yg==", "image/jpeg")], @@ -256,6 +274,70 @@ describe("Vertex Argon tool-result images", () => { ]); }); + 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]]); From 13f86ab959542625d8127ead27602cd1a9528ac5 Mon Sep 17 00:00:00 2001 From: Jossi02 <02love326@kyonggi.ac.kr> Date: Sun, 18 Oct 2026 02:14:59 +0900 Subject: [PATCH 3/6] docs(google): clarify orphan result ordering guarantee --- structure/decisions/ADR-7280-vertex-argon-orphan-images.md | 2 +- structure/providers/google.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/structure/decisions/ADR-7280-vertex-argon-orphan-images.md b/structure/decisions/ADR-7280-vertex-argon-orphan-images.md index 96597b41c4d..401567e7787 100644 --- a/structure/decisions/ADR-7280-vertex-argon-orphan-images.md +++ b/structure/decisions/ADR-7280-vertex-argon-orphan-images.md @@ -9,7 +9,7 @@ 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 original text/image order. + 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 26de83ef2da..721eaddf8e8 100644 --- a/structure/providers/google.md +++ b/structure/providers/google.md @@ -118,7 +118,7 @@ For Vertex `gemini-4-argon`, matched results carry representable data-URL images 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 text/image order without assigning an orphan image to another call. Text-only +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. From 08d49013fbc46c2cdfe9711fdd473e385d9a06f3 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 18 Oct 2026 05:24:26 +0900 Subject: [PATCH 4/6] test(google): cover Argon signature replay with matched and duplicate tool images Seeds a real replay signature, then checks on one continuation that the matched image nests in functionResponse.parts, the duplicate image moves to the following marked user turn, and neither image's bytes leak into text parts. --- .../google-vertex-thought-signature.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/adapters/google/google-vertex-thought-signature.test.ts b/tests/adapters/google/google-vertex-thought-signature.test.ts index 5457c4c7153..42e67f2f944 100644 --- a/tests/adapters/google/google-vertex-thought-signature.test.ts +++ b/tests/adapters/google/google-vertex-thought-signature.test.ts @@ -203,6 +203,27 @@ describe("Vertex Argon tool-result images", () => { 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([ ["single image", [image()], [inline()], "[image]"], ["multiple images with text", [{ type: "text", text: "captured" }, image(), image("Yg==", "image/jpeg")], From 5ff2cdf5880540c6987455564bdb3830856d5d7a Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 18 Oct 2026 05:26:31 +0900 Subject: [PATCH 5/6] test(google): cover streaming continuations and error results for Argon tool images The continuation request now runs with stream on and off, and isError results keep matched-image nesting and orphan-image placement. Both cases fail on the pre-#7280 serializer. --- .../google-vertex-thought-signature.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/adapters/google/google-vertex-thought-signature.test.ts b/tests/adapters/google/google-vertex-thought-signature.test.ts index 42e67f2f944..24ae8a9a38d 100644 --- a/tests/adapters/google/google-vertex-thought-signature.test.ts +++ b/tests/adapters/google/google-vertex-thought-signature.test.ts @@ -224,6 +224,35 @@ describe("Vertex Argon tool-result images", () => { 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=="), + ] }); + }); + test.each([ ["single image", [image()], [inline()], "[image]"], ["multiple images with text", [{ type: "text", text: "captured" }, image(), image("Yg==", "image/jpeg")], From 950cf88d41da7df0ae713a8c239f18a51bd70ee7 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 18 Oct 2026 05:49:44 +0900 Subject: [PATCH 6/6] test(google): cover API-key project endpoints with Argon tool images Combines #7266 and #7280 on one request: a configured-project API key uses the project endpoint with location global, a projectless key stays on express, and in both the matched image nests in functionResponse.parts while the duplicate image moves to the marked orphan turn. Ambient project/location variables are set to conflicting values and restored. --- .../google-vertex-thought-signature.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/tests/adapters/google/google-vertex-thought-signature.test.ts b/tests/adapters/google/google-vertex-thought-signature.test.ts index ab9648f3593..0f8ad956661 100644 --- a/tests/adapters/google/google-vertex-thought-signature.test.ts +++ b/tests/adapters/google/google-vertex-thought-signature.test.ts @@ -290,6 +290,65 @@ describe("Vertex Argon tool-result images", () => { ] }); }); + 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")],