diff --git a/convex/ai/resilience.test.ts b/convex/ai/resilience.test.ts index 6ef05c60..ef777abb 100644 --- a/convex/ai/resilience.test.ts +++ b/convex/ai/resilience.test.ts @@ -1,12 +1,11 @@ import { APICallError } from "@ai-sdk/provider"; import { describe, expect, it } from "vitest"; +import { buildByokErrorMessage, classifyByokError, withByokErrorSanitization } from "./byokErrors"; import { - buildByokErrorMessage, - classifyByokError, + buildProviderTransientMessage, + classifyTransientError, isTransientError, - withByokErrorSanitization, -} from "./resilience"; -import { buildProviderTransientMessage, classifyTransientError } from "./transientErrors"; +} from "./transientErrors"; function apiCallError(overrides: { statusCode?: number; diff --git a/convex/ai/resilience.ts b/convex/ai/resilience.ts index 9cb45025..e763d63f 100644 --- a/convex/ai/resilience.ts +++ b/convex/ai/resilience.ts @@ -21,11 +21,6 @@ import { isTransientError, } from "./transientErrors"; -// Re-export for backwards compatibility with existing callers/tests. -export { buildByokErrorMessage, classifyByokError, withByokErrorSanitization } from "./byokErrors"; -export type { ByokErrorCode } from "./byokErrors"; -export { isTransientError } from "./transientErrors"; - const AI_ERROR_MESSAGE = "I'm having trouble right now. Please try again in a moment."; const BUDGET_CAP_MESSAGE = "This is getting expensive on your API key, so I'm simplifying here. Ask a narrower follow-up if you want me to keep going."; @@ -241,6 +236,11 @@ async function attemptStream({ stopWhen, experimental_telemetry: buildTelemetryConfig(telemetry), experimental_context: { runId: telemetry.runId }, + // @convex-dev/agent drops thought_signature from stored tool calls; disabling thinking prevents Gemini from requiring them on replay. + providerOptions: + telemetry.provider === "gemini" + ? { google: { thinkingConfig: { thinkingBudget: 0 } } } + : undefined, onChunk: (event: { chunk: { type: string } }) => { try { if (event.chunk.type === "text-delta") accumulator.markFirstChunk(); diff --git a/convex/ai/resilienceStreamFailure.test.ts b/convex/ai/resilienceStreamFailure.test.ts index 2dcc74bf..d8ed0a28 100644 --- a/convex/ai/resilienceStreamFailure.test.ts +++ b/convex/ai/resilienceStreamFailure.test.ts @@ -3,6 +3,7 @@ import { saveMessage } from "@convex-dev/agent"; import type { StepResult, ToolSet } from "ai"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ActionCtx } from "../_generated/server"; +import type { ProviderId } from "./providers"; import { streamWithRetry } from "./resilience"; const runWithPrimaryCircuitBreakerMock = vi.hoisted(() => vi.fn()); @@ -113,3 +114,82 @@ describe("streamWithRetry provider response failures", () => { expect(recordErrorMock).toHaveBeenCalledWith("byok_unknown_error"); }); }); + +function makeSuccessAgent(): { + agent: Agent; + captureStreamTextOptions: () => Record | undefined; +} { + let captured: Record | undefined; + const streamText = vi.fn(async (options: Record) => { + captured = options; + return { text: Promise.resolve("") }; + }); + const agent = { + continueThread: vi.fn(async () => ({ thread: { streamText } })), + } as unknown as Agent; + return { agent, captureStreamTextOptions: () => captured }; +} + +function baseStreamWithRetryArgs(provider: ProviderId) { + return { + primaryModelName: "test-model", + threadId: "thread-1", + userId: "user-1", + prompt: "hello", + isByok: false, + provider, + source: "chat" as const, + environment: "dev" as const, + }; +} + +describe("Gemini thinking disabled via providerOptions", () => { + beforeEach(() => { + vi.clearAllMocks(); + runWithPrimaryCircuitBreakerMock.mockImplementation( + async (options: { primaryAgent: Agent; runAttempt: unknown }) => { + const runAttempt = options.runAttempt as (agent: Agent) => Promise; + return await runAttempt(options.primaryAgent); + }, + ); + }); + + it("passes thinkingBudget: 0 providerOptions for Gemini to prevent thought_signature errors", async () => { + const { agent, captureStreamTextOptions } = makeSuccessAgent(); + const ctx = { + runQuery: vi.fn(async () => ({ page: [] })), + runMutation: vi.fn(async () => undefined), + runAction: vi.fn(async () => undefined), + } as unknown as ActionCtx; + + await streamWithRetry(ctx, { + primaryAgent: agent, + fallbackAgent: agent, + ...baseStreamWithRetryArgs("gemini"), + }); + + expect(captureStreamTextOptions()?.providerOptions).toEqual({ + google: { thinkingConfig: { thinkingBudget: 0 } }, + }); + }); + + it.each(["claude", "openai", "openrouter"])( + "does not add Google providerOptions for %s provider", + async (provider) => { + const { agent, captureStreamTextOptions } = makeSuccessAgent(); + const ctx = { + runQuery: vi.fn(async () => ({ page: [] })), + runMutation: vi.fn(async () => undefined), + runAction: vi.fn(async () => undefined), + } as unknown as ActionCtx; + + await streamWithRetry(ctx, { + primaryAgent: agent, + fallbackAgent: agent, + ...baseStreamWithRetryArgs(provider), + }); + + expect(captureStreamTextOptions()?.providerOptions).toBeUndefined(); + }, + ); +});