diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 91f8bc099..cf9a7d499 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -577,15 +577,32 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte pendingUsage = usageFromGemini(usageMeta); sawTerminalSignal = true; } - const candidates = root.candidates as { content?: { parts?: unknown[] }; finishReason?: string }[] | undefined; - if (!candidates?.length) return "continue"; + const rawCandidates = root.candidates; + if (rawCandidates === undefined) return "continue"; + if (!Array.isArray(rawCandidates)) { + yield { type: "error", message: "google response contained invalid candidates" }; + return "terminate"; + } + if (rawCandidates.length === 0) return "continue"; + const rawCandidate = rawCandidates[0]; + if (rawCandidate === null || typeof rawCandidate !== "object" || Array.isArray(rawCandidate)) { + // Unlike a root `data: null` keepalive, this is a claimed response candidate. Treat it + // as terminal protocol corruption so the turn cannot complete after silently losing + // a candidate or tool call (#1325). + yield { type: "error", message: "google response contained invalid candidates" }; + return "terminate"; + } + const candidate = rawCandidate as { + content?: { parts?: unknown[] }; + finishReason?: string; + }; - if (typeof candidates[0].finishReason === "string" && candidates[0].finishReason) { - lastFinishReason = candidates[0].finishReason; + if (typeof candidate.finishReason === "string" && candidate.finishReason) { + lastFinishReason = candidate.finishReason; sawTerminalSignal = true; } - const parts = candidates[0].content?.parts as { text?: string; functionCall?: { name: string; args: unknown } }[] | undefined; + const parts = candidate.content?.parts as { text?: string; functionCall?: { name: string; args: unknown } }[] | undefined; // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and // Antigravity use separate model namespaces so opaque provider state cannot cross routes. const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 3e9b38d6c..73c585e16 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -163,6 +163,18 @@ function invalidChoicesEvent(usage?: OcxUsage): Extract { + return { + type: "error", + message: "upstream response contained invalid tool calls", + ...(usage !== undefined ? { usage } : {}), + }; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + function developerSystemText(message: OcxMessage): string | undefined { if (message.role !== "developer") return undefined; if (typeof message.content === "string") return message.content; @@ -916,9 +928,23 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd yield { type: "text_delta", text: delta.content }; } - const toolCalls = delta.tool_calls as { index?: number; id?: string; function?: { name?: string; arguments?: string } }[] | undefined; - if (toolCalls) { - for (const tc of toolCalls) { + const rawToolCalls = delta.tool_calls; + if (rawToolCalls !== undefined) { + // A claimed tool-call payload is not benign padding. Dropping it can leave the + // matching result permanently orphaned, so malformed nested shapes fail closed + // through the adapter error channel instead of escaping as TypeError (#1325). + if (!Array.isArray(rawToolCalls)) { + return yield* terminateWithError(invalidToolCallsEvent(pendingUsage)); + } + for (const rawToolCall of rawToolCalls) { + if (!isRecord(rawToolCall)) { + return yield* terminateWithError(invalidToolCallsEvent(pendingUsage)); + } + const tc = rawToolCall as { + index?: number; + id?: string; + function?: { name?: string; arguments?: string }; + }; const key = typeof tc.index === "number" ? `i:${tc.index}` : tc.id @@ -1073,11 +1099,21 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd const reasoningText = reasoningTextFrom(msg); if (reasoningText !== undefined) events.push({ type: "reasoning_raw_delta", text: reasoningText }); if (typeof msg.content === "string") events.push({ type: "text_delta", text: msg.content }); - const toolCalls = msg.tool_calls as { id: string; function: { name: string; arguments: string } }[] | undefined; - if (toolCalls) { - for (const tc of toolCalls) { - events.push({ type: "tool_call_start", id: tc.id, name: tc.function.name }); - events.push({ type: "tool_call_delta", arguments: tc.function.arguments }); + const rawToolCalls = msg.tool_calls; + if (rawToolCalls !== undefined) { + if (!Array.isArray(rawToolCalls)) return [invalidToolCallsEvent(usage)]; + for (const rawToolCall of rawToolCalls) { + if (!isRecord(rawToolCall) || !isRecord(rawToolCall.function)) { + return [invalidToolCallsEvent(usage)]; + } + const id = rawToolCall.id; + const name = rawToolCall.function.name; + const args = rawToolCall.function.arguments; + if (typeof id !== "string" || typeof name !== "string" || typeof args !== "string") { + return [invalidToolCallsEvent(usage)]; + } + events.push({ type: "tool_call_start", id, name }); + events.push({ type: "tool_call_delta", arguments: args }); events.push({ type: "tool_call_end" }); } } diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index c2c4ebcd4..b9bc5c020 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -115,6 +115,18 @@ describe("google provider hardening", () => { expect(events.some(event => event.type === "done")).toBe(false); }); + test("a malformed nested candidate is a terminal stream error", async () => { + const events = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([{ candidates: [null] }, { candidates: [{ finishReason: "STOP" }] }]), + )); + + expect(events).toEqual([{ + type: "error", + message: "google response contained invalid candidates", + }]); + expect(events.some(event => event.type === "done")).toBe(false); + }); + test("EOF residual data frame without a trailing newline is parsed", async () => { const events = await collect(createGoogleAdapter(provider()).parseStream( new Response('data:{"candidates":[{"content":{"parts":[{"text":"final"}]},"finishReason":"STOP"}]}', { diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 77a8efa14..83d0b923b 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -116,6 +116,25 @@ describe("openai-chat non-stream response hardening", () => { expect(events).toEqual([{ type: "error", message: "upstream response contained invalid choices" }]); }); + + test("rejects malformed nested tool calls without throwing", async () => { + const adapter = createOpenAIChatAdapter(provider()); + for (const toolCalls of [ + { unexpected: true }, + [null], + [{ id: "call_missing_function" }], + ]) { + const events = await adapter.parseResponse!(new Response(JSON.stringify({ + choices: [{ message: { role: "assistant", tool_calls: toolCalls } }], + usage: { prompt_tokens: 7, completion_tokens: 2 }, + }))); + expect(events).toEqual([{ + type: "error", + message: "upstream response contained invalid tool calls", + usage: { inputTokens: 7, outputTokens: 2 }, + }]); + } + }); }); describe("openai-chat stream response hardening", () => { @@ -160,6 +179,26 @@ describe("openai-chat stream response hardening", () => { expect(events.at(-1)).toEqual({ type: "error", message: "malformed upstream SSE data frame" }); expect(events.some(event => event.type === "done")).toBe(false); }); + + test("malformed nested streaming tool calls are terminal errors", async () => { + const adapter = createOpenAIChatAdapter(provider()); + for (const toolCalls of [{ unexpected: true }, [null]]) { + const response = new Response([ + `data: ${JSON.stringify({ + choices: [{ delta: { tool_calls: toolCalls } }], + usage: { prompt_tokens: 7, completion_tokens: 2 }, + })}\n\n`, + "data: [DONE]\n\n", + ].join("")); + + const events = await collect(adapter.parseStream(response)); + expect(events).toEqual([{ + type: "error", + message: "upstream response contained invalid tool calls", + usage: { inputTokens: 7, outputTokens: 2 }, + }]); + } + }); }); describe("openai-chat credential hardening", () => {