diff --git a/AGENTS.md b/AGENTS.md index d28c73b4e0..9692463816 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ This file provides guidance to agents when working with code in this repository. - Settings View Pattern: When working on `SettingsView`, inputs must bind to the local `cachedState`, NOT the live `useExtensionState()`. The `cachedState` acts as a buffer for user edits, isolating them from the `ContextProxy` source-of-truth until the user explicitly clicks "Save". Wiring inputs directly to the live state causes race conditions. - Changesets: Do NOT create `.changeset` files for each commit or code change. Changesets are managed separately by maintainers and should not be generated by agents during normal development. +- CHANGELOG: Do NOT update `CHANGELOG.md` or `src/CHANGELOG.md` in individual PRs. CHANGELOG entries are added in bulk during release preparation PRs. ## ESLint Suppressions diff --git a/apps/cli/src/commands/cli/__tests__/list.test.ts b/apps/cli/src/commands/cli/__tests__/list.test.ts index 71bdc4266b..78db9752d8 100644 --- a/apps/cli/src/commands/cli/__tests__/list.test.ts +++ b/apps/cli/src/commands/cli/__tests__/list.test.ts @@ -1,7 +1,45 @@ +import fs from "fs" +import os from "os" +import path from "path" +import { EventEmitter } from "events" + +import { openRouterDefaultModelId, providerIdentifiers } from "@roo-code/types" + import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js" -import { isRecord } from "@/lib/utils/guards.js" -import { listSessions, parseFormat } from "../list.js" +import { listModels, listSessions, parseFormat } from "../list.js" + +const extensionHostMock = vi.hoisted(() => ({ + activate: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + options: [] as unknown[], + responses: [] as unknown[], + sendToExtension: vi.fn(), +})) + +vi.mock("@/agent/index.js", () => ({ + ExtensionHost: class extends EventEmitter { + client = { + isInitialized: () => true, + on: vi.fn(() => () => undefined), + } + + constructor(options: unknown) { + super() + extensionHostMock.options.push(options) + } + + activate = extensionHostMock.activate + dispose = extensionHostMock.dispose + + sendToExtension(message: unknown): void { + extensionHostMock.sendToExtension(message) + for (const response of extensionHostMock.responses) { + this.emit("extensionWebviewMessage", response) + } + } + }, +})) vi.mock("@/lib/task-history/index.js", async (importOriginal) => { const actual = await importOriginal() @@ -39,30 +77,88 @@ describe("parseFormat", () => { }) }) -describe("router model extraction", () => { - // This mirrors the extraction logic in requestOpenRouterModels (list.ts:226-228) - const extractOpenRouterModels = (routerModelsRaw: unknown) => { - const routerModels = isRecord(routerModelsRaw) ? routerModelsRaw : {} - const openRouterModels = routerModels.openrouter - return isRecord(openRouterModels) ? openRouterModels : {} - } +describe("listModels", () => { + let tempDir: string + let workspacePath: string + let extensionPath: string - it("extracts openrouter models from valid routerModels", () => { - const models = { "openai/gpt-4.1": { contextWindow: 128000, supportsPromptCache: false } } - const result = extractOpenRouterModels({ openrouter: models }) - expect(result).toEqual(models) + beforeEach(() => { + vi.clearAllMocks() + extensionHostMock.options.length = 0 + extensionHostMock.responses.length = 0 + + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "roo-list-test-")) + workspacePath = path.join(tempDir, "workspace") + extensionPath = path.join(tempDir, "extension") + fs.mkdirSync(workspacePath) + fs.mkdirSync(extensionPath) + fs.writeFileSync(path.join(extensionPath, "extension.js"), "") }) - it("returns empty object when routerModels is null", () => { - expect(extractOpenRouterModels(null)).toEqual({}) + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }) + vi.restoreAllMocks() }) - it("returns empty object when openrouter key is missing", () => { - expect(extractOpenRouterModels({ requesty: {} })).toEqual({}) + const captureStdout = async (fn: () => Promise): Promise => { + const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true) + await fn() + return stdoutSpy.mock.calls.map(([chunk]) => String(chunk)).join("") + } + + it("creates a host with resolved paths and returns OpenRouter models", async () => { + const models = { "openai/gpt-4.1": { contextWindow: 128000, supportsPromptCache: false } } + extensionHostMock.responses.push( + { type: "unrelatedMessage" }, + { type: "routerModels", routerModels: { [providerIdentifiers.openrouter]: models } }, + ) + + const output = await captureStdout(() => + listModels({ + format: "json", + workspace: path.relative(process.cwd(), workspacePath), + extension: path.relative(process.cwd(), extensionPath), + apiKey: "test-api-key", + debug: true, + }), + ) + + expect(extensionHostMock.options).toEqual([ + expect.objectContaining({ + mode: "code", + provider: providerIdentifiers.openrouter, + model: openRouterDefaultModelId, + apiKey: "test-api-key", + workspacePath, + extensionPath, + nonInteractive: true, + ephemeral: true, + debug: true, + exitOnComplete: true, + exitOnError: false, + disableOutput: true, + }), + ]) + expect(extensionHostMock.activate).toHaveBeenCalledOnce() + expect(extensionHostMock.sendToExtension).toHaveBeenCalledWith({ + type: "requestRouterModels", + values: { provider: providerIdentifiers.openrouter }, + }) + expect(extensionHostMock.dispose).toHaveBeenCalledOnce() + expect(JSON.parse(output)).toEqual({ models }) }) - it("returns empty object when openrouter value is not a record", () => { - expect(extractOpenRouterModels({ openrouter: "invalid" })).toEqual({}) + it.each([ + ["a malformed routerModels value", null], + ["a malformed OpenRouter value", { [providerIdentifiers.openrouter]: "invalid" }], + ])("returns an empty model record for %s", async (_description, routerModels) => { + extensionHostMock.responses.push({ type: "routerModels", routerModels }) + + const output = await captureStdout(() => + listModels({ format: "json", workspace: workspacePath, extension: extensionPath }), + ) + + expect(JSON.parse(output)).toEqual({ models: {} }) }) }) diff --git a/apps/cli/src/commands/cli/__tests__/run.test.ts b/apps/cli/src/commands/cli/__tests__/run.test.ts index 7b7693a39c..e20d0672c3 100644 --- a/apps/cli/src/commands/cli/__tests__/run.test.ts +++ b/apps/cli/src/commands/cli/__tests__/run.test.ts @@ -2,6 +2,152 @@ import fs from "fs" import path from "path" import os from "os" +import { providerIdentifiers } from "@roo-code/types" +import { DEFAULT_FLAGS, FlagOptions } from "@/types/index.js" +import { + resolveLegacyRequireApproval, + resolveModel, + resolveProvider, + resolveReasoningEffort, + resolveWorkspacePath, + run, +} from "../run.js" + +const runCommandMocks = vi.hoisted(() => ({ + activate: vi.fn(async () => undefined), + dispose: vi.fn(async () => undefined), + loadSettings: vi.fn(), + options: [] as unknown[], + runTask: vi.fn(async () => undefined), +})) + +vi.mock("@/lib/storage/index.js", () => ({ + loadSettings: runCommandMocks.loadSettings, +})) + +vi.mock("@/agent/index.js", () => ({ + ExtensionHost: class { + client = {} + + constructor(options: unknown) { + runCommandMocks.options.push(options) + } + + activate = runCommandMocks.activate + dispose = runCommandMocks.dispose + runTask = runCommandMocks.runTask + }, +})) + +describe("resolveModel", () => { + it("uses the CLI flag before the settings model", () => { + expect(resolveModel("flag-model", "settings-model")).toBe("flag-model") + }) + + it("uses the settings model when the CLI flag is absent", () => { + expect(resolveModel(undefined, "settings-model")).toBe("settings-model") + }) + + it("uses the default model when neither the CLI flag nor settings provide one", () => { + expect(resolveModel()).toBe(DEFAULT_FLAGS.model) + }) +}) + +describe("resolveReasoningEffort", () => { + it("uses CLI, settings, and default values in priority order", () => { + expect(resolveReasoningEffort("high", "low")).toBe("high") + expect(resolveReasoningEffort(undefined, "low")).toBe("low") + expect(resolveReasoningEffort()).toBe(DEFAULT_FLAGS.reasoningEffort) + }) +}) + +describe("resolveProvider", () => { + it("uses CLI, settings, and openrouter values in priority order", () => { + expect(resolveProvider(providerIdentifiers.anthropic, providerIdentifiers.gemini)).toBe( + providerIdentifiers.anthropic, + ) + expect(resolveProvider(undefined, providerIdentifiers.gemini)).toBe(providerIdentifiers.gemini) + expect(resolveProvider()).toBe(providerIdentifiers.openrouter) + }) +}) + +describe("resolveWorkspacePath", () => { + it("resolves the provided workspace path", () => { + expect(resolveWorkspacePath("relative/workspace")).toBe(path.resolve("relative/workspace")) + }) + + it("uses the current working directory when workspace is absent", () => { + expect(resolveWorkspacePath()).toBe(process.cwd()) + }) +}) + +describe("resolveLegacyRequireApproval", () => { + it.each([ + { requireApproval: true, dangerouslySkipPermissions: true, expected: true }, + { requireApproval: false, dangerouslySkipPermissions: false, expected: false }, + { requireApproval: undefined, dangerouslySkipPermissions: false, expected: true }, + { requireApproval: undefined, dangerouslySkipPermissions: true, expected: false }, + { requireApproval: undefined, dangerouslySkipPermissions: undefined, expected: undefined }, + ])( + "resolves requireApproval=$requireApproval and dangerouslySkipPermissions=$dangerouslySkipPermissions", + ({ requireApproval, dangerouslySkipPermissions, expected }) => { + expect(resolveLegacyRequireApproval(requireApproval, dangerouslySkipPermissions)).toBe(expected) + }, + ) +}) + +describe("run command option resolution", () => { + let workspacePath: string + + beforeEach(() => { + vi.clearAllMocks() + runCommandMocks.options.length = 0 + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "roo-run-test-")) + }) + + afterEach(() => { + fs.rmSync(workspacePath, { recursive: true, force: true }) + vi.restoreAllMocks() + }) + + it("passes resolved settings and workspace values to the extension host", async () => { + runCommandMocks.loadSettings.mockResolvedValue({ + model: "settings-model", + reasoningEffort: "high", + provider: providerIdentifiers.anthropic, + dangerouslySkipPermissions: false, + }) + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never) + const flags: FlagOptions = { + continue: false, + workspace: path.relative(process.cwd(), workspacePath), + print: true, + stdinPromptStream: false, + signalOnlyExit: false, + debug: false, + requireApproval: false, + exitOnError: false, + apiKey: "test-api-key", + ephemeral: true, + oneshot: false, + } + + await run("test prompt", flags) + + expect(runCommandMocks.options).toEqual([ + expect.objectContaining({ + model: "settings-model", + reasoningEffort: "high", + provider: providerIdentifiers.anthropic, + workspacePath, + nonInteractive: false, + }), + ]) + expect(runCommandMocks.runTask).toHaveBeenCalledWith("test prompt", undefined) + expect(exitSpy).toHaveBeenCalledWith(0) + }) +}) + describe("run command --prompt-file option", () => { let tempDir: string let promptFilePath: string diff --git a/apps/cli/src/commands/cli/list.ts b/apps/cli/src/commands/cli/list.ts index fbd33da2cc..c5fbb4dba9 100644 --- a/apps/cli/src/commands/cli/list.ts +++ b/apps/cli/src/commands/cli/list.ts @@ -6,7 +6,7 @@ import pWaitFor from "p-wait-for" import type { TaskSessionEntry } from "@roo-code/core/cli" import type { Command, ModelRecord, WebviewMessage } from "@roo-code/types" -import { openRouterDefaultModelId } from "@roo-code/types" +import { openRouterDefaultModelId, providerIdentifiers } from "@roo-code/types" import { ExtensionHost, type ExtensionHostOptions } from "@/agent/index.js" import { readWorkspaceTaskSessions } from "@/lib/task-history/index.js" @@ -105,13 +105,13 @@ function outputSessionsText(sessions: SessionLike[]): void { async function createListHost(options: BaseListOptions, hostOptions: ListHostOptions): Promise { const workspacePath = resolveWorkspacePath(options.workspace) const extensionPath = resolveExtensionPath(options.extension) - const apiKey = options.apiKey || getApiKeyFromEnv("openrouter") + const apiKey = options.apiKey || getApiKeyFromEnv(providerIdentifiers.openrouter) const extensionHostOptions: ExtensionHostOptions = { mode: "code", reasoningEffort: undefined, user: null, - provider: "openrouter", + provider: providerIdentifiers.openrouter, model: openRouterDefaultModelId, apiKey, workspacePath, @@ -217,14 +217,14 @@ function requestModes(host: ExtensionHost): Promise { function requestOpenRouterModels(host: ExtensionHost): Promise { return requestFromExtension( host, - { type: "requestRouterModels", values: { provider: "openrouter" } }, + { type: "requestRouterModels", values: { provider: providerIdentifiers.openrouter } }, (message) => { if (message.type !== "routerModels") { return undefined } const routerModels = isRecord(message.routerModels) ? message.routerModels : {} - const openRouterModels = routerModels.openrouter + const openRouterModels = routerModels[providerIdentifiers.openrouter] return isRecord(openRouterModels) ? (openRouterModels as ModelRecord) : {} }, ) diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 908df9938b..bedb520ed4 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -5,10 +5,13 @@ import { fileURLToPath } from "url" import { createElement } from "react" import pWaitFor from "p-wait-for" +import { providerIdentifiers } from "@roo-code/types" import { setLogger } from "@roo-code/vscode-shim" import { FlagOptions, + ReasoningEffortFlagOptions, + SupportedProvider, isSupportedProvider, supportedProviders, DEFAULT_FLAGS, @@ -49,6 +52,35 @@ function normalizeError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)) } +export function resolveModel(flagModel?: string, settingsModel?: string): string { + return flagModel || settingsModel || DEFAULT_FLAGS.model +} + +export function resolveReasoningEffort( + flagReasoningEffort?: ReasoningEffortFlagOptions, + settingsReasoningEffort?: ReasoningEffortFlagOptions, +): ReasoningEffortFlagOptions { + return flagReasoningEffort || settingsReasoningEffort || DEFAULT_FLAGS.reasoningEffort +} + +export function resolveProvider( + flagProvider?: SupportedProvider, + settingsProvider?: SupportedProvider, +): SupportedProvider { + return flagProvider ?? settingsProvider ?? providerIdentifiers.openrouter +} + +export function resolveWorkspacePath(workspace?: string): string { + return workspace ? path.resolve(workspace) : process.cwd() +} + +export function resolveLegacyRequireApproval( + requireApproval?: boolean, + dangerouslySkipPermissions?: boolean, +): boolean | undefined { + return requireApproval ?? (dangerouslySkipPermissions === undefined ? undefined : !dangerouslySkipPermissions) +} + export async function run(promptArg: string | undefined, flagOptions: FlagOptions) { setLogger({ info: () => {}, @@ -119,14 +151,14 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption // Determine effective values: CLI flags > settings file > DEFAULT_FLAGS. const effectiveMode = flagOptions.mode || settings.mode || DEFAULT_FLAGS.mode - const effectiveModel = flagOptions.model || settings.model || DEFAULT_FLAGS.model - const effectiveReasoningEffort = - flagOptions.reasoningEffort || settings.reasoningEffort || DEFAULT_FLAGS.reasoningEffort - const effectiveProvider = flagOptions.provider ?? settings.provider ?? "openrouter" - const effectiveWorkspacePath = flagOptions.workspace ? path.resolve(flagOptions.workspace) : process.cwd() - const legacyRequireApprovalFromSettings = - settings.requireApproval ?? - (settings.dangerouslySkipPermissions === undefined ? undefined : !settings.dangerouslySkipPermissions) + const effectiveModel = resolveModel(flagOptions.model, settings.model) + const effectiveReasoningEffort = resolveReasoningEffort(flagOptions.reasoningEffort, settings.reasoningEffort) + const effectiveProvider = resolveProvider(flagOptions.provider, settings.provider) + const effectiveWorkspacePath = resolveWorkspacePath(flagOptions.workspace) + const legacyRequireApprovalFromSettings = resolveLegacyRequireApproval( + settings.requireApproval, + settings.dangerouslySkipPermissions, + ) const effectiveRequireApproval = flagOptions.requireApproval || legacyRequireApprovalFromSettings || false const effectiveExitOnComplete = flagOptions.print || flagOptions.oneshot || settings.oneshot || false const rawConsecutiveMistakeLimit = diff --git a/apps/cli/src/lib/utils/__tests__/context-window.test.ts b/apps/cli/src/lib/utils/__tests__/context-window.test.ts new file mode 100644 index 0000000000..8d33ef5e2b --- /dev/null +++ b/apps/cli/src/lib/utils/__tests__/context-window.test.ts @@ -0,0 +1,40 @@ +import { providerIdentifiers, type ProviderSettings } from "@roo-code/types" + +import { DEFAULT_CONTEXT_WINDOW, getContextWindow } from "../context-window.js" + +describe("getContextWindow", () => { + it.each([ + [providerIdentifiers.openrouter, "openRouterModelId"], + [providerIdentifiers.ollama, "ollamaModelId"], + [providerIdentifiers.lmstudio, "lmStudioModelId"], + [providerIdentifiers.openai, "openAiModelId"], + [providerIdentifiers.requesty, "requestyModelId"], + [providerIdentifiers.unbound, "unboundModelId"], + [providerIdentifiers.litellm, "litellmModelId"], + [providerIdentifiers.vercelAiGateway, "vercelAiGatewayModelId"], + [providerIdentifiers.opencodeGo, "opencodeGoModelId"], + [providerIdentifiers.kenari, "kenariModelId"], + [providerIdentifiers.zooGateway, "zooGatewayModelId"], + ] as const)("uses the provider-specific model field for %s", (provider, modelField) => { + const config = { apiProvider: provider, [modelField]: "selected-model" } as ProviderSettings + const routerModels = { [provider]: { "selected-model": { contextWindow: 123_456 } } } + + expect(getContextWindow(routerModels, config)).toBe(123_456) + }) + + it("uses apiModelId for providers without a specialized model field", () => { + const config: ProviderSettings = { + apiProvider: providerIdentifiers.anthropic, + apiModelId: "selected-model", + } + const routerModels = { + [providerIdentifiers.anthropic]: { "selected-model": { contextWindow: 64_000 } }, + } + + expect(getContextWindow(routerModels, config)).toBe(64_000) + }) + + it("returns the default when the selected model is unavailable", () => { + expect(getContextWindow({}, { apiProvider: providerIdentifiers.openrouter })).toBe(DEFAULT_CONTEXT_WINDOW) + }) +}) diff --git a/apps/cli/src/lib/utils/__tests__/provider.test.ts b/apps/cli/src/lib/utils/__tests__/provider.test.ts index 70d8a2a555..db44174f45 100644 --- a/apps/cli/src/lib/utils/__tests__/provider.test.ts +++ b/apps/cli/src/lib/utils/__tests__/provider.test.ts @@ -1,4 +1,47 @@ -import { getApiKeyFromEnv } from "../provider.js" +import { providerIdentifiers } from "@roo-code/types" + +import { getApiKeyFromEnv, getEnvVarName, getProviderSettings } from "../provider.js" + +describe("provider configuration", () => { + it.each([ + [providerIdentifiers.anthropic, "ANTHROPIC_API_KEY"], + [providerIdentifiers.openaiNative, "OPENAI_API_KEY"], + [providerIdentifiers.gemini, "GOOGLE_API_KEY"], + [providerIdentifiers.openrouter, "OPENROUTER_API_KEY"], + [providerIdentifiers.vercelAiGateway, "VERCEL_AI_GATEWAY_API_KEY"], + ] as const)("maps canonical provider %s to %s", (provider, envVarName) => { + expect(getEnvVarName(provider)).toBe(envVarName) + }) + + it.each([ + [ + providerIdentifiers.anthropic, + { apiProvider: providerIdentifiers.anthropic, apiKey: "key", apiModelId: "model" }, + ], + [ + providerIdentifiers.openaiNative, + { apiProvider: providerIdentifiers.openaiNative, openAiNativeApiKey: "key", apiModelId: "model" }, + ], + [ + providerIdentifiers.gemini, + { apiProvider: providerIdentifiers.gemini, geminiApiKey: "key", apiModelId: "model" }, + ], + [ + providerIdentifiers.openrouter, + { apiProvider: providerIdentifiers.openrouter, openRouterApiKey: "key", openRouterModelId: "model" }, + ], + [ + providerIdentifiers.vercelAiGateway, + { + apiProvider: providerIdentifiers.vercelAiGateway, + vercelAiGatewayApiKey: "key", + vercelAiGatewayModelId: "model", + }, + ], + ] as const)("builds settings for canonical provider %s", (provider, expected) => { + expect(getProviderSettings(provider, "key", "model")).toEqual(expected) + }) +}) describe("getApiKeyFromEnv", () => { const originalEnv = process.env diff --git a/apps/cli/src/lib/utils/context-window.ts b/apps/cli/src/lib/utils/context-window.ts index 5cd58b55a8..1d6402c525 100644 --- a/apps/cli/src/lib/utils/context-window.ts +++ b/apps/cli/src/lib/utils/context-window.ts @@ -1,4 +1,4 @@ -import type { ProviderSettings } from "@roo-code/types" +import { providerIdentifiers, retiredProviderIdentifiers, type ProviderSettings } from "@roo-code/types" import type { RouterModels } from "@/ui/store.js" @@ -36,24 +36,61 @@ export function getContextWindow(routerModels: RouterModels | null, apiConfigura */ function getModelIdForProvider(config: ProviderSettings): string | undefined { switch (config.apiProvider) { - case "openrouter": + case providerIdentifiers.openrouter: return config.openRouterModelId - case "ollama": + case providerIdentifiers.ollama: return config.ollamaModelId - case "lmstudio": + case providerIdentifiers.lmstudio: return config.lmStudioModelId - case "openai": + case providerIdentifiers.openai: return config.openAiModelId - case "requesty": + case providerIdentifiers.requesty: return config.requestyModelId - case "unbound": + case providerIdentifiers.unbound: return config.unboundModelId - case "litellm": + case providerIdentifiers.litellm: return config.litellmModelId - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: return config.vercelAiGatewayModelId - default: - // For anthropic, bedrock, vertex, gemini, xai, etc. + case providerIdentifiers.opencodeGo: + return config.opencodeGoModelId + case providerIdentifiers.kenari: + return config.kenariModelId + case providerIdentifiers.zooGateway: + return config.zooGatewayModelId + case providerIdentifiers.anthropic: + case providerIdentifiers.bedrock: + case providerIdentifiers.baseten: + case providerIdentifiers.deepseek: + case providerIdentifiers.fireworks: + case providerIdentifiers.friendli: + case providerIdentifiers.gemini: + case providerIdentifiers.geminiCli: + case providerIdentifiers.mistral: + case providerIdentifiers.moonshot: + case providerIdentifiers.kimiCode: + case providerIdentifiers.minimax: + case providerIdentifiers.mimo: + case providerIdentifiers.openaiCodex: + case providerIdentifiers.openaiNative: + case providerIdentifiers.poe: + case providerIdentifiers.qwenCode: + case providerIdentifiers.sambanova: + case providerIdentifiers.vertex: + case providerIdentifiers.xai: + case providerIdentifiers.zai: + case retiredProviderIdentifiers.cerebras: + case retiredProviderIdentifiers.chutes: + case retiredProviderIdentifiers.deepinfra: + case retiredProviderIdentifiers.doubao: + case retiredProviderIdentifiers.featherless: + case retiredProviderIdentifiers.groq: + case retiredProviderIdentifiers.huggingface: + case retiredProviderIdentifiers.ioIntelligence: + case retiredProviderIdentifiers.roo: + case providerIdentifiers.vscodeLm: + case providerIdentifiers.fakeAi: + case undefined: return config.apiModelId } } diff --git a/apps/cli/src/lib/utils/provider.ts b/apps/cli/src/lib/utils/provider.ts index 26beaf90c4..7cb7b30ffb 100644 --- a/apps/cli/src/lib/utils/provider.ts +++ b/apps/cli/src/lib/utils/provider.ts @@ -1,13 +1,13 @@ -import { RooCodeSettings } from "@roo-code/types" +import { providerIdentifiers, type RooCodeSettings } from "@roo-code/types" import type { SupportedProvider } from "@/types/index.js" const envVarMap: Record = { - anthropic: "ANTHROPIC_API_KEY", - "openai-native": "OPENAI_API_KEY", - gemini: "GOOGLE_API_KEY", - openrouter: "OPENROUTER_API_KEY", - "vercel-ai-gateway": "VERCEL_AI_GATEWAY_API_KEY", + [providerIdentifiers.anthropic]: "ANTHROPIC_API_KEY", + [providerIdentifiers.openaiNative]: "OPENAI_API_KEY", + [providerIdentifiers.gemini]: "GOOGLE_API_KEY", + [providerIdentifiers.openrouter]: "OPENROUTER_API_KEY", + [providerIdentifiers.vercelAiGateway]: "VERCEL_AI_GATEWAY_API_KEY", } export function getEnvVarName(provider: SupportedProvider): string { @@ -27,23 +27,23 @@ export function getProviderSettings( const config: RooCodeSettings = { apiProvider: provider } switch (provider) { - case "anthropic": + case providerIdentifiers.anthropic: if (apiKey) config.apiKey = apiKey if (model) config.apiModelId = model break - case "openai-native": + case providerIdentifiers.openaiNative: if (apiKey) config.openAiNativeApiKey = apiKey if (model) config.apiModelId = model break - case "gemini": + case providerIdentifiers.gemini: if (apiKey) config.geminiApiKey = apiKey if (model) config.apiModelId = model break - case "openrouter": + case providerIdentifiers.openrouter: if (apiKey) config.openRouterApiKey = apiKey if (model) config.openRouterModelId = model break - case "vercel-ai-gateway": + case providerIdentifiers.vercelAiGateway: if (apiKey) config.vercelAiGatewayApiKey = apiKey if (model) config.vercelAiGatewayModelId = model break diff --git a/apps/cli/src/types/__tests__/types.test.ts b/apps/cli/src/types/__tests__/types.test.ts index 1e54b5069e..5ed0c84016 100644 --- a/apps/cli/src/types/__tests__/types.test.ts +++ b/apps/cli/src/types/__tests__/types.test.ts @@ -1,5 +1,19 @@ +import { providerIdentifiers } from "@roo-code/types" + import { isSupportedProvider, supportedProviders } from "../types.js" +describe("supportedProviders", () => { + it("contains the canonical identifiers for the CLI provider subset", () => { + expect(supportedProviders).toEqual([ + providerIdentifiers.anthropic, + providerIdentifiers.openaiNative, + providerIdentifiers.gemini, + providerIdentifiers.openrouter, + providerIdentifiers.vercelAiGateway, + ]) + }) +}) + describe("isSupportedProvider", () => { it.each(supportedProviders)("returns true for supported provider '%s'", (provider) => { expect(isSupportedProvider(provider)).toBe(true) @@ -22,25 +36,25 @@ describe("provider resolution fallback", () => { it("defaults to openrouter when no flag or setting is provided", () => { const flagProvider = undefined const settingsProvider = undefined - const effectiveProvider = flagProvider ?? settingsProvider ?? "openrouter" + const effectiveProvider = flagProvider ?? settingsProvider ?? providerIdentifiers.openrouter - expect(effectiveProvider).toBe("openrouter") + expect(effectiveProvider).toBe(providerIdentifiers.openrouter) expect(isSupportedProvider(effectiveProvider)).toBe(true) }) it("uses flag provider over settings and default", () => { - const flagProvider = "anthropic" - const settingsProvider = "gemini" - const effectiveProvider = flagProvider ?? settingsProvider ?? "openrouter" + const flagProvider = providerIdentifiers.anthropic + const settingsProvider = providerIdentifiers.gemini + const effectiveProvider = flagProvider ?? settingsProvider ?? providerIdentifiers.openrouter - expect(effectiveProvider).toBe("anthropic") + expect(effectiveProvider).toBe(providerIdentifiers.anthropic) }) it("uses settings provider when flag is not provided", () => { const flagProvider = undefined - const settingsProvider = "gemini" - const effectiveProvider = flagProvider ?? settingsProvider ?? "openrouter" + const settingsProvider = providerIdentifiers.gemini + const effectiveProvider = flagProvider ?? settingsProvider ?? providerIdentifiers.openrouter - expect(effectiveProvider).toBe("gemini") + expect(effectiveProvider).toBe(providerIdentifiers.gemini) }) }) diff --git a/apps/cli/src/types/types.ts b/apps/cli/src/types/types.ts index 0a9f3d2259..999c7b655a 100644 --- a/apps/cli/src/types/types.ts +++ b/apps/cli/src/types/types.ts @@ -1,12 +1,12 @@ -import type { ProviderName, ReasoningEffortExtended } from "@roo-code/types" +import { providerIdentifiers, type ProviderName, type ReasoningEffortExtended } from "@roo-code/types" import type { OutputFormat } from "./json-events.js" export const supportedProviders = [ - "anthropic", - "openai-native", - "gemini", - "openrouter", - "vercel-ai-gateway", + providerIdentifiers.anthropic, + providerIdentifiers.openaiNative, + providerIdentifiers.gemini, + providerIdentifiers.openrouter, + providerIdentifiers.vercelAiGateway, ] as const satisfies ProviderName[] export type SupportedProvider = (typeof supportedProviders)[number] diff --git a/apps/vscode-e2e/fixtures/provider-cost.json b/apps/vscode-e2e/fixtures/provider-cost.json new file mode 100644 index 0000000000..87d3fde002 --- /dev/null +++ b/apps/vscode-e2e/fixtures/provider-cost.json @@ -0,0 +1,18 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "provider-cost-e2e" + }, + "response": { + "toolCalls": [ + { + "name": "attempt_completion", + "arguments": "{\"result\":\"4\"}", + "id": "call_provider_cost_e2e_001" + } + ] + } + } + ] +} diff --git a/apps/vscode-e2e/fixtures/resume-eviction-race.json b/apps/vscode-e2e/fixtures/resume-eviction-race.json new file mode 100644 index 0000000000..18851a44c9 --- /dev/null +++ b/apps/vscode-e2e/fixtures/resume-eviction-race.json @@ -0,0 +1,18 @@ +{ + "fixtures": [ + { + "match": { + "userMessage": "RESUME_EVICTION_RACE_SMOKE" + }, + "response": { + "toolCalls": [ + { + "name": "attempt_completion", + "arguments": "{\"result\":\"Resume eviction smoke completed.\"}", + "id": "call_resume_eviction_001" + } + ] + } + } + ] +} diff --git a/apps/vscode-e2e/src/suite/provider-cost.test.ts b/apps/vscode-e2e/src/suite/provider-cost.test.ts new file mode 100644 index 0000000000..c3727b8cae --- /dev/null +++ b/apps/vscode-e2e/src/suite/provider-cost.test.ts @@ -0,0 +1,265 @@ +import * as assert from "assert" +import { createServer, type IncomingMessage, type ServerResponse } from "http" + +import { RooCodeEventName, mimoModels, type ClineMessage } from "@roo-code/types" + +import { setDefaultSuiteTimeout } from "./test-utils" + +/** + * E2E coverage for the B17 provider cost metric calculation. + * + * The MiMo provider (src/api/providers/mimo.ts) computes `totalCost` from + * streamed `usage` chunks via `calculateApiCostOpenAI` and yields it as a + * `usage` stream item. Task.ts then persists it on the `api_req_started` + * cline message (`cost` field of ClineApiReqInfo) and forwards it to + * `TelemetryService.captureLlmCompletion`. This suite drives the built + * extension against a local OpenAI-compatible stub that returns a fixed + * usage payload and asserts the persisted cost matches the model's + * published pricing (inputPrice/outputPrice of mimo-v2.5-pro). + */ + +type CapturedMimoRequest = { + model?: string + stream?: boolean + includeUsage?: boolean +} + +const MIMO_MODEL_ID = "mimo-v2.5-pro" +// Deterministic usage payload served by the stub. Cost expectation: +// input: 1000 / 1e6 * $1.00 = $0.001 +// output: 500 / 1e6 * $3.00 = $0.0015 +// total = $0.0025 +const STUB_INPUT_TOKENS = 1000 +const STUB_OUTPUT_TOKENS = 500 +const EXPECTED_TOTAL_COST = 0.0025 + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = [] + req.on("data", (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))) + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))) + req.on("error", reject) + }) +} + +function buildSsePayload(modelId: string): string { + const toolChunk = { + id: "chatcmpl-stub", + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model: modelId, + choices: [ + { + index: 0, + delta: { + role: "assistant", + tool_calls: [ + { + index: 0, + id: "call_stub_001", + type: "function", + function: { + name: "attempt_completion", + arguments: JSON.stringify({ result: "4" }), + }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + usage: { + prompt_tokens: STUB_INPUT_TOKENS, + completion_tokens: STUB_OUTPUT_TOKENS, + total_tokens: STUB_INPUT_TOKENS + STUB_OUTPUT_TOKENS, + }, + } + + return `data: ${JSON.stringify(toolChunk)}\n\ndata: [DONE]\n\n` +} + +function isChatCompletionsUrl(rawUrl: string): boolean { + try { + return new URL(rawUrl, "http://127.0.0.1").pathname.endsWith("/chat/completions") + } catch { + return false + } +} + +async function withMimoStub( + run: (args: { baseUrl: string; requests: CapturedMimoRequest[] }) => Promise, +): Promise { + const requests: CapturedMimoRequest[] = [] + let serverError: Error | undefined + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + try { + const requestUrl = req.url ?? "/" + + if (!isChatCompletionsUrl(requestUrl)) { + res.writeHead(404) + res.end("Not found") + return + } + + const bodyText = await readRequestBody(req) + const body = JSON.parse(bodyText) as { + model?: string + stream?: boolean + stream_options?: { include_usage?: boolean } + } + + requests.push({ + model: body.model, + stream: body.stream, + includeUsage: body.stream_options?.include_usage, + }) + + res.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }) + res.end(buildSsePayload(body.model ?? MIMO_MODEL_ID)) + } catch (error) { + serverError = error instanceof Error ? error : new Error(String(error)) + res.writeHead(500) + res.end("Stub failure") + } + }) + + await new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve())) + const address = server.address() + if (!address || typeof address === "string") { + server.close() + throw new Error("Failed to start MiMo stub server") + } + + const baseUrl = `http://127.0.0.1:${address.port}/v1` + + try { + const result = await run({ baseUrl, requests }) + if (serverError) { + throw serverError + } + return result + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + } +} + +function extractCost(message: ClineMessage): number | undefined { + if (message.type !== "say" || message.say !== "api_req_started" || !message.text) { + return undefined + } + try { + const info = JSON.parse(message.text) as { cost?: number } + return typeof info.cost === "number" ? info.cost : undefined + } catch { + return undefined + } +} + +suite("Provider Cost Metrics (B17)", function () { + setDefaultSuiteTimeout(this) + + // Restore the default OpenRouter config so subsequent suites are unaffected. + suiteTeardown(async () => { + const aimockUrl = process.env.AIMOCK_URL + const isRecord = process.env.AIMOCK_RECORD === "true" + await globalThis.api.setConfiguration({ + apiProvider: "openrouter" as const, + openRouterApiKey: aimockUrl && !isRecord ? "mock-key" : process.env.OPENROUTER_API_KEY!, + openRouterModelId: "openai/gpt-4.1", + ...(aimockUrl && { openRouterBaseUrl: `${aimockUrl}/v1` }), + }) + }) + + test("MiMo provider streams usage, calculates cost, and persists it on api_req_started", async function () { + const api = globalThis.api + + await withMimoStub(async ({ baseUrl, requests }) => { + await api.setConfiguration({ + apiProvider: "mimo" as const, + mimoApiKey: "stub-key", + mimoBaseUrl: baseUrl, + apiModelId: MIMO_MODEL_ID, + }) + + const apiReqMessages: ClineMessage[] = [] + const onMessage = ({ message }: { message: ClineMessage }) => { + if (message.type === "say" && message.say === "api_req_started" && message.partial !== true) { + apiReqMessages.push(message) + } + } + api.on(RooCodeEventName.Message, onMessage) + + let taskId: string + try { + taskId = await api.startNewTask({ + configuration: { mode: "ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }, + text: "provider-cost-e2e: what is 2+2? Reply with only the number.", + }) + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + cleanup() + reject(new Error("Timeout after 60s")) + }, 60_000) + + const cleanup = () => { + clearTimeout(timer) + api.off(RooCodeEventName.TaskCompleted, onCompleted) + api.off(RooCodeEventName.TaskAborted, onAborted) + } + + const onCompleted = (completedId: string) => { + if (completedId === taskId) { + cleanup() + resolve() + } + } + + const onAborted = (abortedId: string) => { + if (abortedId === taskId) { + cleanup() + reject(new Error("Task was aborted - MiMo stub request failed")) + } + } + + api.on(RooCodeEventName.TaskCompleted, onCompleted) + api.on(RooCodeEventName.TaskAborted, onAborted) + }) + } finally { + api.off(RooCodeEventName.Message, onMessage) + } + + // The provider must have issued at least one streaming request asking for usage. + const firstRequest = requests[0] + assert.ok(firstRequest, "MiMo provider should issue at least one /chat/completions request") + assert.strictEqual(firstRequest.model, MIMO_MODEL_ID) + assert.strictEqual(firstRequest.stream, true) + assert.strictEqual( + firstRequest.includeUsage, + true, + "MiMo provider must request usage via stream_options.include_usage", + ) + + // Cost data flows into usage stats: the final api_req_started message + // must carry the cost computed by calculateApiCostOpenAI for the stubbed + // token counts and mimo-v2.5-pro pricing. + const costs = apiReqMessages.map(extractCost).filter((c): c is number => typeof c === "number") + assert.ok(costs.length > 0, "At least one api_req_started message should contain a cost value") + + const finalCost = costs[costs.length - 1] + assert.ok( + finalCost !== undefined && Math.abs(finalCost - EXPECTED_TOTAL_COST) < 1e-9, + `Expected total cost ${EXPECTED_TOTAL_COST} but got ${finalCost}`, + ) + + // Sanity: the pricing inputs come from the mimoModels registry. + assert.strictEqual(mimoModels[MIMO_MODEL_ID].inputPrice, 1.0) + assert.strictEqual(mimoModels[MIMO_MODEL_ID].outputPrice, 3.0) + }) + }) +}) diff --git a/apps/vscode-e2e/src/suite/resume-eviction-race.test.ts b/apps/vscode-e2e/src/suite/resume-eviction-race.test.ts new file mode 100644 index 0000000000..53f33cb4e5 --- /dev/null +++ b/apps/vscode-e2e/src/suite/resume-eviction-race.test.ts @@ -0,0 +1,96 @@ +import * as assert from "assert" + +import { setDefaultSuiteTimeout } from "./test-utils" +import { waitUntilCompleted, waitFor } from "./utils" + +// Regression test for the "Work #1 (no message)" title-clobber bug reported +// against Zoo Code v3.76.0 (Discord, 2026-08-06). +// +// Root cause: Task#resumeTaskFromHistory() is started fire-and-forget by +// scheduleTask() after createTaskWithHistoryItem() adds the task to the +// registry, so `clineMessages` is [] until the first disk read resolves. +// ClineProvider#evictCurrentTask() (called by clearCurrentTask / the +// Back-to-parent / Go-to-subtask buttons) calls abortTask(), which calls +// saveClineMessages() → taskMetadata() while the array is still empty. +// taskMetadata() then persists the "no_messages" placeholder title, +// permanently clobbering the real title in the history store. +// +// The test exercises the race by: +// 1. Running a task to completion so a real title is persisted. +// 2. Starting resumeTask() (same path as showTaskWithId) without awaiting it. +// 3. Polling until the task appears on the stack, then immediately evicting — +// the task is on the stack but its message load is still in flight. +// 4. Asserting the stored title still matches the original. +// +// NOTE: Because the extension host reads task messages from disk in the same +// process as this test, the I/O window is very tight (< 1ms on local disk). +// The race is not reliably triggerable from the e2e layer; the canonical +// regression anchor is the unit test in +// src/core/task/__tests__/Task.resume-eviction-race.spec.ts, which controls +// the timing via a deferred promise. This e2e test serves as a smoke test that +// the resume-then-evict flow does not blow up and that the stored title is +// correct after a round-trip. +suite("Resume eviction race (title clobber regression)", function () { + setDefaultSuiteTimeout(this) + + test("evicting a mid-resume task does not overwrite its stored title", async () => { + const api = globalThis.api + + const ORIGINAL_TITLE = + "RESUME_EVICTION_RACE_SMOKE: complete immediately with 'Resume eviction smoke completed.'" + + // Step 1 — run a task to completion so a real title is persisted. + const taskId = await waitUntilCompleted({ + api, + start: () => + api.startNewTask({ + configuration: { + mode: "ask", + autoApprovalEnabled: true, + enableCheckpoints: false, + }, + text: ORIGINAL_TITLE, + }), + }) + + const beforeResume = await api.getTaskHistoryItem(taskId) + assert.ok(beforeResume, "Task should be in history after completion") + assert.ok( + beforeResume.task?.includes("RESUME_EVICTION_RACE_SMOKE"), + `Persisted title before resume should contain the prompt marker (got "${beforeResume.task}")`, + ) + + // Drain the stack so we start clean. + while (api.getCurrentTaskStack().length > 0) { + await api.clearCurrentTask() + } + + // Step 2 — fire resumeTask() without awaiting it. resumeTask() calls + // createTaskWithHistoryItem() which adds the task to the registry and + // calls scheduleTask() (fire-and-forget). The task's run() and + // resumeTaskFromHistory() start in the background. + const resumePromise = api.resumeTask(taskId) + + // Step 3 — wait only until the task appears on the stack (i.e. + // createTaskWithHistoryItem has returned and addClineToStack has run), + // then immediately evict. This minimises the gap between the eviction + // and the in-flight message load, giving the best chance of hitting the + // race window before readTaskMessages() resolves. + await waitFor(() => api.getCurrentTaskStack().includes(taskId)) + await api.clearCurrentTask() + + // Let the resume settle. + await resumePromise.catch(() => {}) + + // Step 4 — the stored title must still be the real one. + const afterEviction = await api.getTaskHistoryItem(taskId) + assert.ok(afterEviction, "Task should still be in history after eviction") + + // Before the fix this would be "Task #N (No messages)" / "工作 #N (無訊息)". + assert.strictEqual( + afterEviction.task, + beforeResume.task, + `Title must not change during resume eviction. Got: "${afterEviction.task}"`, + ) + }) +}) diff --git a/codecov.yml b/codecov.yml index 7dd22dfdc2..b4783e6dee 100644 --- a/codecov.yml +++ b/codecov.yml @@ -16,6 +16,7 @@ coverage: default: target: 80% # new lines must be 80% covered threshold: 0% + informational: true # non-blocking for PRs with large new code webview-patch: target: 70% # new lines in webview must be 70% covered threshold: 0% diff --git a/package.json b/package.json index b2ab5b7173..4ce00f4d1e 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "esbuild": "0.28.1", "rollup": "4.60.4", "vite": "8.1.0", - "undici": "6.27.0", + "undici": "6.28.0", "form-data": ">=4.0.4", "bluebird": ">=3.7.2", "glob": "11.1.0", diff --git a/packages/telemetry/src/TelemetryService.ts b/packages/telemetry/src/TelemetryService.ts index fdf0942bdb..30db60353c 100644 --- a/packages/telemetry/src/TelemetryService.ts +++ b/packages/telemetry/src/TelemetryService.ts @@ -370,6 +370,71 @@ export class TelemetryService { }) } + /** + * Captures a tool-call policy resolution event. + * + * Emitted after the tool-call policy is resolved for an API request, + * recording only metadata about the decision (provider, model, policy + * source, enforcement mode, and what was requested/sent to the provider). + * + * **Privacy:** NEVER includes raw commands, file paths, file contents, + * tool arguments, or API keys. Only policy metadata and boolean flags. + * + * @param taskId The task identifier + * @param properties Policy resolution metadata (no raw user data) + */ + public captureToolCallPolicyResolution( + taskId: string, + properties: { + provider: string + model: string + policySource: string + maxCallsPerTurn: number | "unbounded" + enforcement: string + parallelToolCallsRequested: boolean + parallelToolCallsSent?: boolean + }, + ): void { + this.captureEvent(TelemetryEventName.TOOL_CALL_POLICY_RESOLUTION, { + taskId, + ...properties, + }) + } + + /** + * Captures a tool-call enforcement event. + * + * Emitted when local enforcement acts on tool calls in a turn — either + * ghost quarantine drops or max-one enforcement rejections. Records only + * counts and metadata, never raw call content. + * + * **Privacy:** NEVER includes raw commands, file paths, file contents, + * tool arguments, or API keys. Only counts and policy metadata. + * + * @param taskId The task identifier + * @param properties Enforcement metadata with counts (no raw user data) + */ + public captureToolCallEnforcement( + taskId: string, + properties: { + provider: string + model: string + policySource: string + maxCallsPerTurn: number | "unbounded" + enforcement: string + callCount: number + ghostDroppedCount: number + errorResultCount: number + parallelToolCallsRequested: boolean + parallelToolCallsSent?: boolean + }, + ): void { + this.captureEvent(TelemetryEventName.TOOL_CALL_ENFORCEMENT, { + taskId, + ...properties, + }) + } + /** * Checks if telemetry is currently enabled * @returns Whether telemetry is enabled diff --git a/packages/types/src/__tests__/provider-identifiers.test.ts b/packages/types/src/__tests__/provider-identifiers.test.ts index b3640a8f5d..870ce77d78 100644 --- a/packages/types/src/__tests__/provider-identifiers.test.ts +++ b/packages/types/src/__tests__/provider-identifiers.test.ts @@ -11,6 +11,7 @@ import { isProviderName, isRetiredProvider, localProviders, + MODELS_BY_PROVIDER, providerIdentifiers, providerNames, providerNamesSchema, @@ -113,6 +114,12 @@ describe("provider identifiers", () => { expect(fauxProviders).toEqual([providerIdentifiers.fakeAi]) }) + it("keeps model provider ids aligned with their keys", () => { + for (const [identifier, providerModels] of Object.entries(MODELS_BY_PROVIDER)) { + expect(providerModels.id).toBe(identifier) + } + }) + it("preserves provider category type guards", () => { for (const identifier of dynamicProviders) { expect(isDynamicProvider(identifier)).toBe(true) diff --git a/packages/types/src/model.ts b/packages/types/src/model.ts index 9fbf9e358b..3c4f1a5981 100644 --- a/packages/types/src/model.ts +++ b/packages/types/src/model.ts @@ -95,6 +95,34 @@ export type ModelParameter = z.infer export const isModelParameter = (value: string): value is ModelParameter => modelParameters.includes(value as ModelParameter) +/** + * ModelToolCallCapabilities + */ + +export const modelToolCallCapabilitiesSchema = z.object({ + supportsParallelToolCalls: z.union([z.boolean(), z.literal("unknown")]), + parallelToolCallsRequestControl: z.enum(["openai", "anthropic", "none", "unknown"]), +}) + +export type ModelToolCallCapabilities = z.infer + +/** + * ToolCallGenerationPolicy + */ + +export type ToolCallGenerationPolicy = "parallel" | "single" | "provider-default" + +/** + * ResolvedToolCallPolicy + */ + +export type ResolvedToolCallPolicy = { + generation: ToolCallGenerationPolicy + maxCallsPerTurn: 1 | "unbounded" + enforcement: "provider" | "local" | "provider-and-local" + source: "model-capability" | "provider-default" | "user-setting" | "adaptive-circuit" +} + /** * ModelInfo */ @@ -162,6 +190,9 @@ export const modelInfoSchema = z.object({ // These tools will be added if they belong to an allowed group in the current mode // Cannot force-add tools from groups the mode doesn't allow includedTools: z.array(z.string()).optional(), + // Tool-call capability metadata for parallel/single-call policy resolution. + // When absent, the resolver treats the model as "unknown" and applies a conservative default. + toolCallCapabilities: modelToolCallCapabilitiesSchema.optional(), /** * Service tiers with pricing information. * Each tier can have a name (for OpenAI service tiers) and pricing overrides. diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index e17cd5ddbc..40894aec81 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -336,14 +336,7 @@ const minimaxSchema = apiModelIdProviderModelSchema.extend({ }) const mimoSchema = apiModelIdProviderModelSchema.extend({ - mimoBaseUrl: z - .union([ - z.literal("https://api.xiaomimimo.com/v1"), - z.literal("https://token-plan-cn.xiaomimimo.com/v1"), - z.literal("https://token-plan-sgp.xiaomimimo.com/v1"), - z.literal("https://token-plan-ams.xiaomimimo.com/v1"), - ]) - .optional(), + mimoBaseUrl: z.string().url().optional(), mimoApiKey: z.string().optional(), }) @@ -428,40 +421,40 @@ const defaultSchema = z.object({ }) export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [ - anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })), - openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })), - bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })), - vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })), - openAiSchema.merge(z.object({ apiProvider: z.literal("openai") })), - ollamaSchema.merge(z.object({ apiProvider: z.literal("ollama") })), - vsCodeLmSchema.merge(z.object({ apiProvider: z.literal("vscode-lm") })), - lmStudioSchema.merge(z.object({ apiProvider: z.literal("lmstudio") })), - geminiSchema.merge(z.object({ apiProvider: z.literal("gemini") })), - geminiCliSchema.merge(z.object({ apiProvider: z.literal("gemini-cli") })), - openAiCodexSchema.merge(z.object({ apiProvider: z.literal("openai-codex") })), - openAiNativeSchema.merge(z.object({ apiProvider: z.literal("openai-native") })), - mistralSchema.merge(z.object({ apiProvider: z.literal("mistral") })), - deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })), - poeSchema.merge(z.object({ apiProvider: z.literal("poe") })), - moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })), - kimiCodeSchema.merge(z.object({ apiProvider: z.literal("kimi-code") })), - minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })), - mimoSchema.merge(z.object({ apiProvider: z.literal("mimo") })), - requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })), - unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })), - fakeAiSchema.merge(z.object({ apiProvider: z.literal("fake-ai") })), - xaiSchema.merge(z.object({ apiProvider: z.literal("xai") })), - basetenSchema.merge(z.object({ apiProvider: z.literal("baseten") })), - litellmSchema.merge(z.object({ apiProvider: z.literal("litellm") })), - sambaNovaSchema.merge(z.object({ apiProvider: z.literal("sambanova") })), - zaiSchema.merge(z.object({ apiProvider: z.literal("zai") })), - fireworksSchema.merge(z.object({ apiProvider: z.literal("fireworks") })), - friendliSchema.merge(z.object({ apiProvider: z.literal("friendli") })), - qwenCodeSchema.merge(z.object({ apiProvider: z.literal("qwen-code") })), - vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal("vercel-ai-gateway") })), - opencodeGoSchema.merge(z.object({ apiProvider: z.literal("opencode-go") })), - kenariSchema.merge(z.object({ apiProvider: z.literal("kenari") })), - zooGatewaySchema.merge(z.object({ apiProvider: z.literal("zoo-gateway") })), + anthropicSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.anthropic) })), + openRouterSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.openrouter) })), + bedrockSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.bedrock) })), + vertexSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.vertex) })), + openAiSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.openai) })), + ollamaSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.ollama) })), + vsCodeLmSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.vscodeLm) })), + lmStudioSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.lmstudio) })), + geminiSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.gemini) })), + geminiCliSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.geminiCli) })), + openAiCodexSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.openaiCodex) })), + openAiNativeSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.openaiNative) })), + mistralSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.mistral) })), + deepSeekSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.deepseek) })), + poeSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.poe) })), + moonshotSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.moonshot) })), + kimiCodeSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.kimiCode) })), + minimaxSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.minimax) })), + mimoSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.mimo) })), + requestySchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.requesty) })), + unboundSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.unbound) })), + fakeAiSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.fakeAi) })), + xaiSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.xai) })), + basetenSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.baseten) })), + litellmSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.litellm) })), + sambaNovaSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.sambanova) })), + zaiSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.zai) })), + fireworksSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.fireworks) })), + friendliSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.friendli) })), + qwenCodeSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.qwenCode) })), + vercelAiGatewaySchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.vercelAiGateway) })), + opencodeGoSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.opencodeGo) })), + kenariSchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.kenari) })), + zooGatewaySchema.merge(z.object({ apiProvider: z.literal(providerIdentifiers.zooGateway) })), defaultSchema, ]) @@ -553,37 +546,37 @@ export const isTypicalProvider = (key: unknown): key is TypicalProvider => isProviderName(key) && !isInternalProvider(key) && !isCustomProvider(key) && !isFauxProvider(key) export const modelIdKeysByProvider: Record = { - anthropic: "apiModelId", - openrouter: "openRouterModelId", - bedrock: "apiModelId", - vertex: "apiModelId", - "openai-codex": "apiModelId", - "openai-native": "openAiModelId", - ollama: "ollamaModelId", - lmstudio: "lmStudioModelId", - gemini: "apiModelId", - "gemini-cli": "apiModelId", - mistral: "apiModelId", - moonshot: "apiModelId", - "kimi-code": "apiModelId", - minimax: "apiModelId", - mimo: "apiModelId", - deepseek: "apiModelId", - poe: "apiModelId", - "qwen-code": "apiModelId", - requesty: "requestyModelId", - unbound: "unboundModelId", - xai: "apiModelId", - baseten: "apiModelId", - litellm: "litellmModelId", - sambanova: "apiModelId", - zai: "apiModelId", - fireworks: "apiModelId", - friendli: "apiModelId", - "vercel-ai-gateway": "vercelAiGatewayModelId", - "opencode-go": "opencodeGoModelId", - kenari: "kenariModelId", - "zoo-gateway": "zooGatewayModelId", + [providerIdentifiers.anthropic]: "apiModelId", + [providerIdentifiers.openrouter]: "openRouterModelId", + [providerIdentifiers.bedrock]: "apiModelId", + [providerIdentifiers.vertex]: "apiModelId", + [providerIdentifiers.openaiCodex]: "apiModelId", + [providerIdentifiers.openaiNative]: "openAiModelId", + [providerIdentifiers.ollama]: "ollamaModelId", + [providerIdentifiers.lmstudio]: "lmStudioModelId", + [providerIdentifiers.gemini]: "apiModelId", + [providerIdentifiers.geminiCli]: "apiModelId", + [providerIdentifiers.mistral]: "apiModelId", + [providerIdentifiers.moonshot]: "apiModelId", + [providerIdentifiers.kimiCode]: "apiModelId", + [providerIdentifiers.minimax]: "apiModelId", + [providerIdentifiers.mimo]: "apiModelId", + [providerIdentifiers.deepseek]: "apiModelId", + [providerIdentifiers.poe]: "apiModelId", + [providerIdentifiers.qwenCode]: "apiModelId", + [providerIdentifiers.requesty]: "requestyModelId", + [providerIdentifiers.unbound]: "unboundModelId", + [providerIdentifiers.xai]: "apiModelId", + [providerIdentifiers.baseten]: "apiModelId", + [providerIdentifiers.litellm]: "litellmModelId", + [providerIdentifiers.sambanova]: "apiModelId", + [providerIdentifiers.zai]: "apiModelId", + [providerIdentifiers.fireworks]: "apiModelId", + [providerIdentifiers.friendli]: "apiModelId", + [providerIdentifiers.vercelAiGateway]: "vercelAiGatewayModelId", + [providerIdentifiers.opencodeGo]: "opencodeGoModelId", + [providerIdentifiers.kenari]: "kenariModelId", + [providerIdentifiers.zooGateway]: "zooGatewayModelId", } /** @@ -653,106 +646,125 @@ export const getApiProtocol = (provider: ProviderName | undefined, modelId?: str */ export const MODELS_BY_PROVIDER: Record< - Exclude, + Exclude< + ProviderName, + typeof providerIdentifiers.fakeAi | typeof providerIdentifiers.geminiCli | typeof providerIdentifiers.openai + >, { id: ProviderName; label: string; models: string[] } > = { - anthropic: { - id: "anthropic", + [providerIdentifiers.anthropic]: { + id: providerIdentifiers.anthropic, label: "Anthropic", models: Object.keys(anthropicModels), }, - bedrock: { - id: "bedrock", + [providerIdentifiers.bedrock]: { + id: providerIdentifiers.bedrock, label: "Amazon Bedrock", models: Object.keys(bedrockModels), }, - deepseek: { - id: "deepseek", + [providerIdentifiers.deepseek]: { + id: providerIdentifiers.deepseek, label: "DeepSeek", models: Object.keys(deepSeekModels), }, - fireworks: { - id: "fireworks", + [providerIdentifiers.fireworks]: { + id: providerIdentifiers.fireworks, label: "Fireworks", models: Object.keys(fireworksModels), }, - friendli: { - id: "friendli", + [providerIdentifiers.friendli]: { + id: providerIdentifiers.friendli, label: "Friendli", models: Object.keys(friendliModels), }, - gemini: { - id: "gemini", + [providerIdentifiers.gemini]: { + id: providerIdentifiers.gemini, label: "Google Gemini", models: Object.keys(geminiModels), }, - mistral: { - id: "mistral", + [providerIdentifiers.mistral]: { + id: providerIdentifiers.mistral, label: "Mistral", models: Object.keys(mistralModels), }, - moonshot: { - id: "moonshot", + [providerIdentifiers.moonshot]: { + id: providerIdentifiers.moonshot, label: "Moonshot", models: Object.keys(moonshotModels), }, - "kimi-code": { - id: "kimi-code", + [providerIdentifiers.kimiCode]: { + id: providerIdentifiers.kimiCode, label: "Kimi Code", models: [], }, - minimax: { - id: "minimax", + [providerIdentifiers.minimax]: { + id: providerIdentifiers.minimax, label: "MiniMax", models: Object.keys(minimaxModels), }, - mimo: { - id: "mimo", + [providerIdentifiers.mimo]: { + id: providerIdentifiers.mimo, label: "Xiaomi MiMo", models: Object.keys(mimoModels), }, - "openai-codex": { - id: "openai-codex", + [providerIdentifiers.openaiCodex]: { + id: providerIdentifiers.openaiCodex, label: "OpenAI - ChatGPT Plus/Pro", models: Object.keys(openAiCodexModels), }, - "openai-native": { - id: "openai-native", + [providerIdentifiers.openaiNative]: { + id: providerIdentifiers.openaiNative, label: "OpenAI", models: Object.keys(openAiNativeModels), }, - "qwen-code": { id: "qwen-code", label: "Qwen Code", models: Object.keys(qwenCodeModels) }, - sambanova: { - id: "sambanova", + [providerIdentifiers.qwenCode]: { + id: providerIdentifiers.qwenCode, + label: "Qwen Code", + models: Object.keys(qwenCodeModels), + }, + [providerIdentifiers.sambanova]: { + id: providerIdentifiers.sambanova, label: "SambaNova", models: Object.keys(sambaNovaModels), }, - vertex: { - id: "vertex", + [providerIdentifiers.vertex]: { + id: providerIdentifiers.vertex, label: "GCP Vertex AI", models: Object.keys(vertexModels), }, - "vscode-lm": { - id: "vscode-lm", + [providerIdentifiers.vscodeLm]: { + id: providerIdentifiers.vscodeLm, label: "VS Code LM API", models: Object.keys(vscodeLlmModels), }, - xai: { id: "xai", label: "xAI (Grok)", models: Object.keys(xaiModels) }, - zai: { id: "zai", label: "Z.ai", models: Object.keys(internationalZAiModels) }, - baseten: { id: "baseten", label: "Baseten", models: Object.keys(basetenModels) }, + [providerIdentifiers.xai]: { id: providerIdentifiers.xai, label: "xAI (Grok)", models: Object.keys(xaiModels) }, + [providerIdentifiers.zai]: { + id: providerIdentifiers.zai, + label: "Z.ai", + models: Object.keys(internationalZAiModels), + }, + [providerIdentifiers.baseten]: { + id: providerIdentifiers.baseten, + label: "Baseten", + models: Object.keys(basetenModels), + }, // Dynamic providers; models pulled from remote APIs. - poe: { id: "poe", label: "Poe", models: [] }, - litellm: { id: "litellm", label: "LiteLLM", models: [] }, - openrouter: { id: "openrouter", label: "OpenRouter", models: [] }, - requesty: { id: "requesty", label: "Requesty", models: [] }, - unbound: { id: "unbound", label: "Unbound", models: [] }, - "vercel-ai-gateway": { id: "vercel-ai-gateway", label: "Vercel AI Gateway", models: [] }, - "opencode-go": { id: "opencode-go", label: "Opencode Go", models: [] }, - kenari: { id: "kenari", label: "Kenari", models: [] }, - "zoo-gateway": { id: "zoo-gateway", label: "Zoo Gateway", models: [] }, + [providerIdentifiers.poe]: { id: providerIdentifiers.poe, label: "Poe", models: [] }, + [providerIdentifiers.litellm]: { id: providerIdentifiers.litellm, label: "LiteLLM", models: [] }, + [providerIdentifiers.openrouter]: { id: providerIdentifiers.openrouter, label: "OpenRouter", models: [] }, + [providerIdentifiers.requesty]: { id: providerIdentifiers.requesty, label: "Requesty", models: [] }, + [providerIdentifiers.unbound]: { id: providerIdentifiers.unbound, label: "Unbound", models: [] }, + [providerIdentifiers.vercelAiGateway]: { + id: providerIdentifiers.vercelAiGateway, + label: "Vercel AI Gateway", + models: [], + }, + [providerIdentifiers.opencodeGo]: { id: providerIdentifiers.opencodeGo, label: "Opencode Go", models: [] }, + [providerIdentifiers.kenari]: { id: providerIdentifiers.kenari, label: "Kenari", models: [] }, + [providerIdentifiers.zooGateway]: { id: providerIdentifiers.zooGateway, label: "Zoo Gateway", models: [] }, // Local providers; models discovered from localhost endpoints. - lmstudio: { id: "lmstudio", label: "LM Studio", models: [] }, - ollama: { id: "ollama", label: "Ollama", models: [] }, + [providerIdentifiers.lmstudio]: { id: providerIdentifiers.lmstudio, label: "LM Studio", models: [] }, + [providerIdentifiers.ollama]: { id: providerIdentifiers.ollama, label: "Ollama", models: [] }, } diff --git a/packages/types/src/providers/mimo.ts b/packages/types/src/providers/mimo.ts index debd0cbefc..ed660f078a 100644 --- a/packages/types/src/providers/mimo.ts +++ b/packages/types/src/providers/mimo.ts @@ -32,6 +32,15 @@ export const mimoModels = { outputPriceMultiplier: 2, cacheReadsPriceMultiplier: 2, }, + // MiMo v2.5 Pro produces malformed parallel tool calls (nested cwd objects, + // empty-argument ghost calls). Xiaomi's own Zed integration declares + // parallel_tool_calls: false for this model. Treat as non-parallel-capable. + // parallelToolCallsRequestControl will be updated to "openai" in Sub-task 2 + // after a provider canary confirms server-side enforcement. + toolCallCapabilities: { + supportsParallelToolCalls: false, + parallelToolCallsRequestControl: "none", + }, description: "MiMo V2.5 Pro - Xiaomi's flagship reasoning model with 1M context, deep thinking, tool calling, and structured output.", }, @@ -52,6 +61,11 @@ export const mimoModels = { outputPriceMultiplier: 2, cacheReadsPriceMultiplier: 2, }, + // Same parallel tool-call limitation as v2.5-pro. + toolCallCapabilities: { + supportsParallelToolCalls: false, + parallelToolCallsRequestControl: "none", + }, description: "MiMo V2.5 - Full-modal understanding model (text, image, audio, video) with 1M context, deep thinking, tool calling, and structured output.", }, diff --git a/packages/types/src/telemetry.ts b/packages/types/src/telemetry.ts index 402cd571c8..2e823f2afa 100644 --- a/packages/types/src/telemetry.ts +++ b/packages/types/src/telemetry.ts @@ -74,6 +74,8 @@ export enum TelemetryEventName { TELEMETRY_SETTINGS_CHANGED = "Telemetry Settings Changed", MODEL_CACHE_EMPTY_RESPONSE = "Model Cache Empty Response", READ_FILE_LEGACY_FORMAT_USED = "Read File Legacy Format Used", + TOOL_CALL_POLICY_RESOLUTION = "Tool Call Policy Resolution", + TOOL_CALL_ENFORCEMENT = "Tool Call Enforcement", } /** @@ -217,6 +219,35 @@ export const rooCodeTelemetryEventSchema = z.discriminatedUnion("type", [ newSetting: telemetrySettingsSchema, }), }), + z.object({ + type: z.literal(TelemetryEventName.TOOL_CALL_POLICY_RESOLUTION), + properties: z.object({ + ...telemetryPropertiesSchema.shape, + provider: z.string(), + model: z.string(), + policySource: z.string(), + maxCallsPerTurn: z.union([z.literal(1), z.literal("unbounded")]), + enforcement: z.string(), + parallelToolCallsRequested: z.boolean(), + parallelToolCallsSent: z.boolean().optional(), + }), + }), + z.object({ + type: z.literal(TelemetryEventName.TOOL_CALL_ENFORCEMENT), + properties: z.object({ + ...telemetryPropertiesSchema.shape, + provider: z.string(), + model: z.string(), + policySource: z.string(), + maxCallsPerTurn: z.union([z.literal(1), z.literal("unbounded")]), + enforcement: z.string(), + callCount: z.number(), + ghostDroppedCount: z.number(), + errorResultCount: z.number(), + parallelToolCallsRequested: z.boolean(), + parallelToolCallsSent: z.boolean().optional(), + }), + }), z.object({ type: z.literal(TelemetryEventName.TASK_MESSAGE), properties: z.object({ diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index 4f90b63e9f..d89a8107c1 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -46,6 +46,7 @@ export const toolNames = [ "skill", "generate_image", "custom_tool", + "invalid_tool_call", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c3dd070ac..393c6ac143 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,7 +9,7 @@ overrides: esbuild: 0.28.1 rollup: 4.60.4 vite: 8.1.0 - undici: 6.27.0 + undici: 6.28.0 form-data: '>=4.0.4' bluebird: '>=3.7.2' glob: 11.1.0 @@ -608,8 +608,8 @@ importers: specifier: ^0.1.13 version: 0.1.13 undici: - specifier: 6.27.0 - version: 6.27.0 + specifier: 6.28.0 + version: 6.28.0 uuid: specifier: ^11.1.0 version: 11.1.1 @@ -808,7 +808,7 @@ importers: version: 1.21.0(react@18.3.1) mermaid: specifier: ^11.4.1 - version: 11.16.0 + version: 11.16.1 posthog-js: specifier: ^1.227.2 version: 1.393.4 @@ -1647,8 +1647,8 @@ packages: '@iconify/types@2.0.0': resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} - '@iconify/utils@3.1.3': - resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} '@inkjs/ui@2.0.0': resolution: {integrity: sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==} @@ -3058,8 +3058,8 @@ packages: '@types/d3-format@3.0.4': resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} - '@types/d3-geo@3.1.0': - resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + '@types/d3-geo@3.1.1': + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} '@types/d3-hierarchy@3.1.7': resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} @@ -3076,8 +3076,8 @@ packages: '@types/d3-quadtree@3.0.6': resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} - '@types/d3-random@3.0.3': - resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} '@types/d3-scale-chromatic@3.1.0': resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} @@ -3519,11 +3519,6 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - acorn@8.17.0: resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} engines: {node: '>=0.4.0'} @@ -4054,10 +4049,6 @@ packages: console-grid@2.2.4: resolution: {integrity: sha512-OLjCRTiHhOpTRo9lQp/2FgJDyq5uQHwkEmVJulEnQ6JVf27oKKzXHZnNOv/e72V4++UdMZCrDWtvXW5sx4lyQg==} - content-disposition@1.0.0: - resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} - engines: {node: '>= 0.6'} - content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -4481,8 +4472,8 @@ packages: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} - dompurify@3.4.11: - resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + dompurify@3.4.13: + resolution: {integrity: sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==} domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -4623,8 +4614,8 @@ packages: resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} engines: {node: '>= 0.4'} - es-toolkit@1.49.0: - resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + es-toolkit@1.50.0: + resolution: {integrity: sha512-OyZKhUVvEep9ITEiwHn8GKnMRQIVqoSIX7WnRbkWgJkllCujilqP2rD0u979tkl8wqyc8ICwlc1UBVv/Sl1G6w==} es6-error@4.1.1: resolution: {integrity: sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg==} @@ -6200,8 +6191,8 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - mermaid@11.16.0: - resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + mermaid@11.16.1: + resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==} micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -6673,8 +6664,8 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} - package-manager-detector@1.6.0: - resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -7816,8 +7807,8 @@ packages: tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} tinyglobby@0.2.17: @@ -8011,8 +8002,8 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} - undici@6.27.0: - resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} engines: {node: '>=18.17'} unicorn-magic@0.1.0: @@ -8597,8 +8588,8 @@ snapshots: '@antfu/install-pkg@1.1.0': dependencies: - package-manager-detector: 1.6.0 - tinyexec: 1.2.4 + package-manager-detector: 1.8.0 + tinyexec: 1.3.0 '@anthropic-ai/sdk@0.109.1(zod@3.25.76)': dependencies: @@ -9445,7 +9436,7 @@ snapshots: '@iconify/types@2.0.0': {} - '@iconify/utils@3.1.3': + '@iconify/utils@3.1.4': dependencies: '@antfu/install-pkg': 1.1.0 '@iconify/types': 2.0.0 @@ -9867,7 +9858,7 @@ snapshots: dependencies: '@qdrant/openapi-typescript-fetch': 1.2.6 typescript: 5.9.3 - undici: 6.27.0 + undici: 6.28.0 '@qdrant/openapi-typescript-fetch@1.2.6': {} @@ -10852,7 +10843,7 @@ snapshots: '@types/d3-format@3.0.4': {} - '@types/d3-geo@3.1.0': + '@types/d3-geo@3.1.1': dependencies: '@types/geojson': 7946.0.16 @@ -10868,7 +10859,7 @@ snapshots: '@types/d3-quadtree@3.0.6': {} - '@types/d3-random@3.0.3': {} + '@types/d3-random@3.0.4': {} '@types/d3-scale-chromatic@3.1.0': {} @@ -10913,13 +10904,13 @@ snapshots: '@types/d3-fetch': 3.0.7 '@types/d3-force': 3.0.10 '@types/d3-format': 3.0.4 - '@types/d3-geo': 3.1.0 + '@types/d3-geo': 3.1.1 '@types/d3-hierarchy': 3.1.7 '@types/d3-interpolate': 3.0.4 '@types/d3-path': 3.1.1 '@types/d3-polygon': 3.0.2 '@types/d3-quadtree': 3.0.6 - '@types/d3-random': 3.0.3 + '@types/d3-random': 3.0.4 '@types/d3-scale': 4.0.9 '@types/d3-scale-chromatic': 3.1.0 '@types/d3-selection': 3.0.11 @@ -11400,9 +11391,9 @@ snapshots: mime-types: 3.0.1 negotiator: 1.0.0 - acorn-jsx@5.3.2(acorn@8.15.0): + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: - acorn: 8.15.0 + acorn: 8.17.0 acorn-loose@8.5.2: dependencies: @@ -11412,8 +11403,6 @@ snapshots: dependencies: acorn: 8.17.0 - acorn@8.15.0: {} - acorn@8.17.0: {} agent-base@6.0.2: @@ -11853,7 +11842,7 @@ snapshots: parse5: 7.3.0 parse5-htmlparser2-tree-adapter: 7.1.0 parse5-parser-stream: 7.1.2 - undici: 6.27.0 + undici: 6.28.0 whatwg-mimetype: 4.0.0 chokidar@4.0.3: @@ -11970,10 +11959,6 @@ snapshots: console-grid@2.2.4: {} - content-disposition@1.0.0: - dependencies: - safe-buffer: 5.2.1 - content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -12401,7 +12386,7 @@ snapshots: dependencies: domelementtype: 2.3.0 - dompurify@3.4.11: + dompurify@3.4.13: optionalDependencies: '@types/trusted-types': 2.0.7 @@ -12601,7 +12586,7 @@ snapshots: is-date-object: 1.1.0 is-symbol: 1.1.1 - es-toolkit@1.49.0: {} + es-toolkit@1.50.0: {} es6-error@4.1.1: {} @@ -12734,8 +12719,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -12846,7 +12831,7 @@ snapshots: dependencies: accepts: 2.0.0 body-parser: 2.2.2 - content-disposition: 1.0.0 + content-disposition: 1.0.1 content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 @@ -13494,7 +13479,7 @@ snapshots: cli-cursor: 4.0.0 cli-truncate: 5.2.0 code-excerpt: 4.0.0 - es-toolkit: 1.49.0 + es-toolkit: 1.50.0 indent-string: 5.0.0 is-in-ci: 2.0.0 patch-console: 2.0.0 @@ -14107,7 +14092,7 @@ snapshots: listr2: 9.0.5 picomatch: 4.0.4 string-argv: 0.3.2 - tinyexec: 1.2.4 + tinyexec: 1.3.0 yaml: 2.9.0 listenercount@1.0.1: {} @@ -14487,10 +14472,10 @@ snapshots: merge2@1.4.1: {} - mermaid@11.16.0: + mermaid@11.16.1: dependencies: '@braintree/sanitize-url': 7.1.2 - '@iconify/utils': 3.1.3 + '@iconify/utils': 3.1.4 '@mermaid-js/parser': 1.2.0 '@types/d3': 7.4.3 '@upsetjs/venn.js': 2.0.0 @@ -14501,8 +14486,8 @@ snapshots: d3-sankey: 0.12.3 dagre-d3-es: 7.0.14 dayjs: 1.11.21 - dompurify: 3.4.11 - es-toolkit: 1.49.0 + dompurify: 3.4.13 + es-toolkit: 1.50.0 katex: 0.16.47 khroma: 2.1.0 marked: 16.4.2 @@ -14782,7 +14767,7 @@ snapshots: mlly@1.7.4: dependencies: - acorn: 8.15.0 + acorn: 8.17.0 pathe: 2.0.3 pkg-types: 1.3.1 ufo: 1.6.1 @@ -15157,7 +15142,7 @@ snapshots: dependencies: quansync: 0.2.11 - package-manager-detector@1.6.0: {} + package-manager-detector@1.8.0: {} pako@1.0.11: {} @@ -15312,7 +15297,7 @@ snapshots: '@posthog/core': 1.38.0 '@posthog/types': 1.391.1 core-js: 3.49.0 - dompurify: 3.4.11 + dompurify: 3.4.13 fflate: 0.4.8 preact: 10.29.3 query-selector-shadow-dom: 1.0.1 @@ -16491,7 +16476,7 @@ snapshots: tinyexec@0.3.2: {} - tinyexec@1.2.4: {} + tinyexec@1.3.0: {} tinyglobby@0.2.17: dependencies: @@ -16690,7 +16675,7 @@ snapshots: undici-types@6.21.0: {} - undici@6.27.0: {} + undici@6.28.0: {} unicorn-magic@0.1.0: {} @@ -16930,7 +16915,7 @@ snapshots: picomatch: 4.0.4 std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.0 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 vite: 8.1.0(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0) diff --git a/progress.txt b/progress.txt deleted file mode 100644 index b3983826b3..0000000000 --- a/progress.txt +++ /dev/null @@ -1,59 +0,0 @@ -# Reapplication Progress — rc6 branch cleanup -# Updated: 2026-02-15 - -## Completed Batches - -### Batch 1 — Clean cherry-picks (PR #11473) -- 22 PRs merged cleanly -- Status: MERGED to main - -### Batch 2 — Minor conflicts (PR #11474) -- 9 PRs with minor conflicts resolved -- Status: MERGED to main - -### Batch 3 — Skills Infrastructure & Browser Use Removal (4 PRs) -- PR #11102: skill mode dropdown (44 conflicts resolved) -- PR #11157: improve Skills/Slash Commands UI (6 conflicts resolved) -- PR #11414: remove built-in skills mechanism (4 conflicts resolved) -- PR #11392: remove browser use entirely (5 conflicts resolved) -- Status: ON BRANCH reapply/batch-3-4-5-major-conflicts - -### Batch 4 — Provider Removals (2 PRs) -- PR #11253: remove URL context/Grounding checkboxes (4 conflicts resolved) -- PR #11297: remove 9 low-usage providers + retired UX (14 conflicts resolved) -- Status: ON BRANCH reapply/batch-3-4-5-major-conflicts - -### Batch 5 — Azure Foundry -- PR #11315 and #11374: EXCLUDED — depends on AI-SDK (@ai-sdk/azure, from "ai") -- These PRs are AI-SDK-entangled and cannot be cherry-picked to the pre-AI-SDK codebase -- Status: DEFERRED (AI-SDK dependent) - -## Post-cherry-pick Fixes Applied -1. Restored gemini.ts + vertex.ts to pre-AI-SDK state (cherry-picks brought AI-SDK versions) -2. Restored ai-sdk.spec.ts, gemini-handler.spec.ts, vertex.spec.ts to pre-AI-SDK versions -3. Fixed processUserContentMentions.ts ghost import (rooMessage.ts doesn't exist) -4. Added missing skills type exports to @roo-code/types (SkillMetadata, validateSkillName, etc.) -5. Added SkillsSettings import to SettingsView.tsx -6. Added Dialog/Select/Collapsible mocks to SettingsView test files -7. Fixed Task.ts type mismatches (replaced local types with Anthropic SDK types) -8. Added skills state to ExtensionStateContext - -## Deferred PRs (AI-SDK Entangled) -- #11379: delegation (AI-SDK) -- #11418: delegation (AI-SDK) -- #11422: delegation (AI-SDK) -- #11315: Azure Foundry provider (AI-SDK) -- #11374: Azure Foundry fix (AI-SDK) - -## Validation Results -- Backend tests: ALL PASSED (5224 tests) -- UI tests: ALL PASSED (1267 tests) -- Type checks: ALL PASSED (14/14 packages) -- AI-SDK contamination: CLEAN (0 matches) - -## Notes -- Pre-push hook fails on `roo-cline:bundle` because `generate-built-in-skills.ts` was removed - by PR #11414 but `package.json` still references it in `prebundle`. This is expected and - will be resolved when the PR is merged to main and the script reference is cleaned up. -- Push was done with `--no-verify` after independent verification of types, backend tests, - and UI tests all passed cleanly. diff --git a/src/api/index.ts b/src/api/index.ts index f48ab50c0e..13e45ff629 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -7,6 +7,8 @@ import { retiredProviderIdentifiers, type ProviderSettings, type ModelInfo, + type ResolvedToolCallPolicy, + type ModelToolCallCapabilities, } from "@roo-code/types" import { getRouterRemovalMessage } from "../core/config/routerRemoval" @@ -150,6 +152,132 @@ export interface ApiHandler { countTokens(content: Array): Promise } +/** + * Providers that use the OpenAI-compatible API format and natively support + * parallel tool calls via the `parallel_tool_calls` request field. + * When a model from one of these providers has no explicit + * `toolCallCapabilities`, we preserve the pre-existing parallel behavior. + */ +const OPENAI_COMPATIBLE_PARALLEL_PROVIDERS = new Set([ + "openai", + "openai-native", + "openai-codex", + "openrouter", + "deepseek", + "qwen-code", + "moonshot", + "kimi-code", + "mistral", + "requesty", + "unbound", + "xai", + "litellm", + "sambanova", + "zai", + "fireworks", + "friendli", + "vercel-ai-gateway", + "opencode-go", + "kenari", + "zoo-gateway", + "minimax", + "baseten", + "poe", +]) + +/** + * Providers that use the Anthropic API format and natively support + * parallel tool calls via `disable_parallel_tool_use`. + * When a model from one of these providers has no explicit + * `toolCallCapabilities`, we preserve the pre-existing parallel behavior. + */ +const ANTHROPIC_PARALLEL_PROVIDERS = new Set(["anthropic", "bedrock", "vertex"]) + +/** + * Resolve the tool-call policy for a given model and provider. + * + * This is a pure function: given the model info and provider name, it returns + * a {@link ResolvedToolCallPolicy} that describes whether parallel tool calls + * should be enabled, the max calls per turn, and how enforcement is applied. + * + * Resolution logic: + * 1. If the model declares `toolCallCapabilities` with `supportsParallelToolCalls: false`, + * the policy is "single" with local enforcement (and provider enforcement when + * the request control is not "none"). + * 2. If the model declares `supportsParallelToolCalls: true` with a known request + * control ("openai" or "anthropic"), the policy is "parallel" with provider enforcement. + * 3. If capabilities are unknown or absent: + * a. If the provider is known to be OpenAI-compatible or Anthropic, preserve + * the pre-existing parallel behavior (parallel, unbounded, provider enforcement). + * b. Otherwise (e.g. mimo, unknown providers), apply a conservative "single" + * default with local enforcement to prevent malformed parallel calls. + * + * @param modelInfo - The ModelInfo for the active model. + * @param providerName - The provider identifier string (e.g. "mimo", "anthropic", "openai"). + * @returns A resolved tool-call policy. + */ +export function resolveToolCallPolicy(modelInfo: ModelInfo, providerName?: string): ResolvedToolCallPolicy { + const capabilities: ModelToolCallCapabilities | undefined = modelInfo.toolCallCapabilities + + // Case 1: Model explicitly declares it does NOT support parallel tool calls. + if (capabilities && capabilities.supportsParallelToolCalls === false) { + const enforcement = capabilities.parallelToolCallsRequestControl === "none" ? "local" : "provider-and-local" + return { + generation: "single", + maxCallsPerTurn: 1, + enforcement, + source: "model-capability", + } + } + + // Case 2: Model explicitly declares it DOES support parallel tool calls + // and has a known request control mechanism. + if ( + capabilities && + capabilities.supportsParallelToolCalls === true && + (capabilities.parallelToolCallsRequestControl === "openai" || + capabilities.parallelToolCallsRequestControl === "anthropic") + ) { + return { + generation: "parallel", + maxCallsPerTurn: "unbounded", + enforcement: "provider", + source: "model-capability", + } + } + + // Case 3: Unknown or absent capabilities — use provider-based fallback. + // Known-parallel providers (OpenAI-compatible and Anthropic) preserve their + // pre-existing parallel behavior. Unknown or explicitly non-parallel providers + // (e.g. mimo) get a conservative single-call default. + if (providerName && OPENAI_COMPATIBLE_PARALLEL_PROVIDERS.has(providerName)) { + return { + generation: "parallel", + maxCallsPerTurn: "unbounded", + enforcement: "provider", + source: "provider-default", + } + } + + if (providerName && ANTHROPIC_PARALLEL_PROVIDERS.has(providerName)) { + return { + generation: "parallel", + maxCallsPerTurn: "unbounded", + enforcement: "provider", + source: "provider-default", + } + } + + // Conservative default for unknown providers (e.g. mimo, ollama, lmstudio, + // vscode-lm, gemini, fake-ai) or when providerName is absent. + return { + generation: "single", + maxCallsPerTurn: 1, + enforcement: "local", + source: "provider-default", + } +} + export function buildApiHandler(configuration: ProviderSettings): ApiHandler { const { apiProvider, ...options } = configuration diff --git a/src/api/providers/__tests__/anthropic.spec.ts b/src/api/providers/__tests__/anthropic.spec.ts index c88f5aab71..21d2816ec7 100644 --- a/src/api/providers/__tests__/anthropic.spec.ts +++ b/src/api/providers/__tests__/anthropic.spec.ts @@ -3,6 +3,7 @@ import { AnthropicHandler } from "../anthropic" import { ApiHandlerOptions } from "../../../shared/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" // Mock TelemetryService vitest.mock("@roo-code/telemetry", () => ({ @@ -85,7 +86,7 @@ describe("AnthropicHandler", () => { apiModelId: "claude-3-5-sonnet-20241022", } handler = new AnthropicHandler(mockOptions) - vitest.clearAllMocks() + clearAllMocks() }) describe("constructor", () => { diff --git a/src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts index 6b0c0dca31..d4a4605b2f 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider-timeout.spec.ts @@ -11,6 +11,8 @@ vitest.mock("../utils/timeout-config", () => ({ import { getApiRequestTimeout } from "../utils/timeout-config" +import { clearAllMocks } from "../../../test-utils/reset" + // Mock OpenAI and capture constructor calls const mockOpenAIConstructor = vitest.fn() @@ -56,7 +58,7 @@ class TestOpenAiCompatibleProvider extends BaseOpenAiCompatibleProvider<"test-mo describe("BaseOpenAiCompatibleProvider Timeout Configuration", () => { beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() }) it("should call getApiRequestTimeout when creating the provider", () => { diff --git a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts index ec87e29ab6..fa7c19c5ed 100644 --- a/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts +++ b/src/api/providers/__tests__/base-openai-compatible-provider.spec.ts @@ -7,6 +7,7 @@ import type { ModelInfo } from "@roo-code/types" import { BaseOpenAiCompatibleProvider } from "../base-openai-compatible-provider" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" // Create mock functions const mockCreate = vi.fn() @@ -52,7 +53,7 @@ describe("BaseOpenAiCompatibleProvider", () => { let handler: TestOpenAiCompatibleProvider beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() handler = new TestOpenAiCompatibleProvider("test-api-key") }) diff --git a/src/api/providers/__tests__/bedrock-error-handling.spec.ts b/src/api/providers/__tests__/bedrock-error-handling.spec.ts index 708f8275ad..36893de3eb 100644 --- a/src/api/providers/__tests__/bedrock-error-handling.spec.ts +++ b/src/api/providers/__tests__/bedrock-error-handling.spec.ts @@ -34,12 +34,13 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => ({ import { AwsBedrockHandler } from "../bedrock" import { Anthropic } from "@anthropic-ai/sdk" import { collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" describe("AwsBedrockHandler Error Handling", () => { let handler: AwsBedrockHandler beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() mockCaptureException.mockClear() handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", diff --git a/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts b/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts index ab760cc38a..bcca570297 100644 --- a/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts +++ b/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts @@ -2,6 +2,7 @@ import { ApiHandlerOptions } from "../../../shared/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" import { AwsBedrockHandler, StreamEvent } from "../bedrock" @@ -76,7 +77,7 @@ vitest.mock("@aws-sdk/client-bedrock-runtime", () => { describe("AwsBedrockHandler with invokedModelId", () => { beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() }) // Helper function to create a mock async iterable stream diff --git a/src/api/providers/__tests__/bedrock-native-tools.spec.ts b/src/api/providers/__tests__/bedrock-native-tools.spec.ts index 9cb93aac7e..52a28c59ff 100644 --- a/src/api/providers/__tests__/bedrock-native-tools.spec.ts +++ b/src/api/providers/__tests__/bedrock-native-tools.spec.ts @@ -32,6 +32,8 @@ import { AwsBedrockHandler } from "../bedrock" import { ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime" import type { ApiHandlerCreateMessageMetadata } from "../../index" +import { clearAllMocks } from "../../../test-utils/reset" + const mockConverseStreamCommand = vi.mocked(ConverseStreamCommand) // Test tool definitions in OpenAI format @@ -71,7 +73,7 @@ describe("AwsBedrockHandler Native Tool Calling", () => { let handler: AwsBedrockHandler beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() // Create handler with a model that supports native tools handler = new AwsBedrockHandler({ diff --git a/src/api/providers/__tests__/bedrock-reasoning.spec.ts b/src/api/providers/__tests__/bedrock-reasoning.spec.ts index fcbbeb1049..1577d51f93 100644 --- a/src/api/providers/__tests__/bedrock-reasoning.spec.ts +++ b/src/api/providers/__tests__/bedrock-reasoning.spec.ts @@ -4,6 +4,8 @@ import { AwsBedrockHandler } from "../bedrock" import { BedrockRuntimeClient, ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime" import { logger } from "../../../utils/logging" +import { clearAllMocks } from "../../../test-utils/reset" + // Mock the AWS SDK vi.mock("@aws-sdk/client-bedrock-runtime") vi.mock("../../../utils/logging") @@ -37,7 +39,7 @@ describe("AwsBedrockHandler - Extended Thinking", () => { }) afterEach(() => { - vi.clearAllMocks() + clearAllMocks() }) describe("Extended Thinking Support", () => { diff --git a/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts b/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts index 0c5d653e1b..bb2f71ddd7 100644 --- a/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts +++ b/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts @@ -28,13 +28,15 @@ vi.mock("@aws-sdk/client-bedrock-runtime", () => { import { AwsBedrockHandler } from "../bedrock" import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime" +import { clearAllMocks } from "../../../test-utils/reset" + // Get access to the mocked functions const mockBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient) describe("Amazon Bedrock VPC Endpoint Functionality", () => { beforeEach(() => { // Clear all mocks before each test - vi.clearAllMocks() + clearAllMocks() }) // Test Scenario 1: Input Validation Test diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index b025f33f02..fd9c92a438 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -68,6 +68,8 @@ import { NodeHttpHandler } from "@smithy/node-http-handler" import { HttpProxyAgent } from "http-proxy-agent" import { HttpsProxyAgent } from "https-proxy-agent" +import { clearAllMocks } from "../../../test-utils/reset" + // Get access to the mocked functions const mockConverseStreamCommand = vi.mocked(ConverseStreamCommand) const mockBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient) @@ -81,7 +83,7 @@ describe("AwsBedrockHandler", () => { beforeEach(() => { // Clear all mocks before each test - vi.clearAllMocks() + clearAllMocks() handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index f1268ed6ed..4f3cccdc08 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -1,5 +1,6 @@ // Mocks must come first, before imports import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" const mockCreate = vi.fn() vi.mock("openai", () => { @@ -144,7 +145,7 @@ describe("DeepSeekHandler", () => { deepSeekBaseUrl: "https://api.deepseek.com", } handler = new DeepSeekHandler(mockOptions) - vi.clearAllMocks() + clearAllMocks() }) describe("constructor", () => { diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index a066b139cf..353ee31552 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -7,6 +7,7 @@ import { type FireworksModelId, fireworksDefaultModelId, fireworksModels } from import { FireworksHandler } from "../fireworks" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" // Create mock functions const mockCreate = vi.fn() @@ -28,7 +29,7 @@ describe("FireworksHandler", () => { let handler: FireworksHandler beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() // Set up default mock implementation mockCreate.mockImplementation(async () => asyncStreamFrom([ diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 7c31c754e7..0e6b21c5e5 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -9,6 +9,7 @@ import { buildApiHandler } from "../../index" import { getModelMaxOutputTokens } from "../../../shared/api" import { FriendliHandler } from "../friendli" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" // Create mock functions const mockCreate = vi.fn() @@ -30,7 +31,7 @@ describe("FriendliHandler", () => { let handler: FriendliHandler beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() // Set up default mock implementation mockCreate.mockImplementation(async () => asyncStreamFrom([ @@ -368,7 +369,7 @@ describe("Friendli model max output tokens (clamping behavior)", () => { describe("FriendliHandler — Friendli-specific reasoning params", () => { beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() }) it("should include reasoning_effort, chat_template_kwargs, parse_reasoning for GLM-5.2 with reasoning enabled", async () => { diff --git a/src/api/providers/__tests__/kenari.spec.ts b/src/api/providers/__tests__/kenari.spec.ts index d6b95ce0b1..f9d07873c6 100644 --- a/src/api/providers/__tests__/kenari.spec.ts +++ b/src/api/providers/__tests__/kenari.spec.ts @@ -18,6 +18,7 @@ import { KenariHandler } from "../kenari" import { getModels } from "../fetchers/modelCache" import { ApiHandlerOptions } from "../../../shared/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" vitest.mock("openai") vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) @@ -51,7 +52,7 @@ describe("KenariHandler", () => { } beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() mockCreate.mockClear() }) diff --git a/src/api/providers/__tests__/kimi-code.spec.ts b/src/api/providers/__tests__/kimi-code.spec.ts index d78c252304..df909d57d4 100644 --- a/src/api/providers/__tests__/kimi-code.spec.ts +++ b/src/api/providers/__tests__/kimi-code.spec.ts @@ -1,6 +1,8 @@ import { buildApiHandler } from "../../index" import { KimiCodeHandler } from "../kimi-code" +import { clearAllMocks } from "../../../test-utils/reset" + const { mockGetAccessToken, mockForceRefreshAccessToken, mockGetModels } = vi.hoisted(() => ({ mockGetAccessToken: vi.fn(), mockForceRefreshAccessToken: vi.fn(), @@ -18,7 +20,7 @@ vi.mock("../fetchers/modelCache", () => ({ getModels: mockGetModels })) describe("KimiCodeHandler", () => { beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() mockGetAccessToken.mockResolvedValue("oauth-token") mockForceRefreshAccessToken.mockResolvedValue("refreshed-token") mockGetModels.mockRejectedValue(new Error("offline")) diff --git a/src/api/providers/__tests__/lite-llm.spec.ts b/src/api/providers/__tests__/lite-llm.spec.ts index 03399b0256..eee5cf52bb 100644 --- a/src/api/providers/__tests__/lite-llm.spec.ts +++ b/src/api/providers/__tests__/lite-llm.spec.ts @@ -5,6 +5,7 @@ import { LiteLLMHandler } from "../lite-llm" import { ApiHandlerOptions } from "../../../shared/api" import { litellmDefaultModelId, litellmDefaultModelInfo } from "@roo-code/types" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" // Mock vscode first to avoid import errors vi.mock("vscode", () => ({ @@ -65,7 +66,7 @@ describe("LiteLLMHandler", () => { let mockOptions: ApiHandlerOptions beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() mockOptions = { litellmApiKey: "test-key", litellmBaseUrl: "http://localhost:4000", @@ -220,7 +221,7 @@ describe("LiteLLMHandler", () => { ] for (const modelId of gpt5Variations) { - vi.clearAllMocks() + clearAllMocks() const optionsWithGPT5: ApiHandlerOptions = { ...mockOptions, @@ -261,7 +262,7 @@ describe("LiteLLMHandler", () => { const nonGPT5Models = ["gpt-4", "claude-3-opus", "llama-3", "gpt-4-turbo"] for (const modelId of nonGPT5Models) { - vi.clearAllMocks() + clearAllMocks() const options: ApiHandlerOptions = { ...mockOptions, diff --git a/src/api/providers/__tests__/lm-studio-timeout.spec.ts b/src/api/providers/__tests__/lm-studio-timeout.spec.ts index d443514223..f661d9092e 100644 --- a/src/api/providers/__tests__/lm-studio-timeout.spec.ts +++ b/src/api/providers/__tests__/lm-studio-timeout.spec.ts @@ -10,6 +10,8 @@ vitest.mock("../utils/timeout-config", () => ({ import { getApiRequestTimeout } from "../utils/timeout-config" +import { clearAllMocks } from "../../../test-utils/reset" + // Mock OpenAI const mockOpenAIConstructor = vitest.fn() vitest.mock("openai", () => { @@ -30,7 +32,7 @@ vitest.mock("openai", () => { describe("LmStudioHandler timeout configuration", () => { beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() }) it("should use default timeout of 600 seconds when no configuration is set", () => { diff --git a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts index 2e04399f98..c6a63902a1 100644 --- a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts +++ b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts @@ -3,6 +3,7 @@ // Mock OpenAI client - must come before other imports const mockCreate = vi.fn() import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" vi.mock("openai", () => { return { __esModule: true, @@ -44,7 +45,7 @@ describe("LmStudioHandler Native Tools", () => { ] beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() mockOptions = { apiModelId: "local-model", diff --git a/src/api/providers/__tests__/mimo.spec.ts b/src/api/providers/__tests__/mimo.spec.ts index 357bbf6861..65e5b99ad8 100644 --- a/src/api/providers/__tests__/mimo.spec.ts +++ b/src/api/providers/__tests__/mimo.spec.ts @@ -1,942 +1,1971 @@ -const mockCreate = vi.fn() -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" -vi.mock("openai", () => { - return { - __esModule: true, - default: vi.fn().mockImplementation(function () { - return { - chat: { - completions: { - create: mockCreate.mockImplementation(async (options) => - asyncStreamFrom([ - { - choices: [{ delta: { content: "Test response" }, index: 0 }], - usage: null, - }, - { - choices: [{ delta: {}, index: 0, finish_reason: "stop" }], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - prompt_tokens_details: { cached_tokens: 2 }, - }, - }, - ]), - ), - }, - }, - } - }), - } -}) - -import type { Anthropic } from "@anthropic-ai/sdk" -import { mimoDefaultModelId, mimoModels } from "@roo-code/types" -import type { ApiHandlerOptions } from "../../../shared/api" -import { MimoHandler } from "../mimo" -import { convertToR1Format } from "../../transform/r1-format" -import { sanitizeOpenAiCallId } from "../../../utils/tool-id" - -describe("MimoHandler", () => { - let handler: MimoHandler - let mockOptions: ApiHandlerOptions - - beforeEach(() => { - mockOptions = { - mimoApiKey: "test-api-key", - apiModelId: "mimo-v2.5-pro", - mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", - } - handler = new MimoHandler(mockOptions) - vi.clearAllMocks() - }) - - describe("constructor", () => { - it("should initialize with provided options", () => { - expect(handler).toBeInstanceOf(MimoHandler) - expect(handler.getModel().id).toBe("mimo-v2.5-pro") - }) - - it("should use default model ID if not provided", () => { - const handlerWithoutModel = new MimoHandler({ - ...mockOptions, - apiModelId: undefined, - }) - expect(handlerWithoutModel.getModel().id).toBe(mimoDefaultModelId) - }) - - it("should use Singapore base URL if not provided", () => { - const h = new MimoHandler({ ...mockOptions, mimoBaseUrl: undefined }) - expect((h as any).options.openAiBaseUrl).toBe("https://token-plan-sgp.xiaomimimo.com/v1") - }) - - it("should use custom base URL when provided", () => { - const customUrl = "https://api.xiaomimimo.com/v1" - const h = new MimoHandler({ ...mockOptions, mimoBaseUrl: customUrl }) - expect((h as any).options.openAiBaseUrl).toBe(customUrl) - }) - }) - - describe("getModel", () => { - it("should return correct model info for mimo-v2.5-pro", () => { - const model = handler.getModel() - expect(model.id).toBe("mimo-v2.5-pro") - expect(model.info.contextWindow).toBe(1_048_576) - expect(model.info.maxTokens).toBe(131_072) - expect(model.info.inputPrice).toBe(1.0) - expect(model.info.outputPrice).toBe(3.0) - }) - - it("should return correct model info for mimo-v2.5", () => { - const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.5" }) - const model = h.getModel() - expect(model.id).toBe("mimo-v2.5") - expect(model.info.inputPrice).toBe(0.4) - expect(model.info.outputPrice).toBe(2.0) - }) - - it("should fallback to default model for unknown model ID", () => { - const h = new MimoHandler({ ...mockOptions, apiModelId: "unknown-model" }) - const model = h.getModel() - expect(model.id).toBe("unknown-model") - expect(model.info).toBe(mimoModels["mimo-v2.5-pro"]) - }) - }) - - describe("convertMessagesForMiMo (via convertToR1Format)", () => { - const convert = (messages: Anthropic.Messages.MessageParam[]) => - convertToR1Format(messages, { - mergeToolResultText: true, - normalizeToolCallId: sanitizeOpenAiCallId, - }) - - it("should convert assistant message with reasoning and text", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "reasoning" as const, text: "Let me think..." } as any, - { type: "text" as const, text: "Here is the answer" }, - ], - }, - ] - const result = convert(messages) - expect(result).toHaveLength(1) - expect(result[0].role).toBe("assistant") - expect(result[0].content).toBe("Here is the answer") - expect((result[0] as any).reasoning_content).toBe("Let me think...") - }) - - it("should convert assistant message with tool_use blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text" as const, text: "I'll read the file" }, - { - type: "tool_use" as const, - id: "call_123", - name: "read_file", - input: { path: "README.md" }, - }, - ], - }, - ] - const result = convert(messages) - expect(result).toHaveLength(1) - const msg = result[0] as any - expect(msg.tool_calls).toHaveLength(1) - expect(msg.tool_calls[0].id).toBe("call_123") - expect(msg.tool_calls[0].function.name).toBe("read_file") - expect(msg.tool_calls[0].function.arguments).toBe('{"path":"README.md"}') - }) - - it("should handle string-input tool_use (JSON string)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use" as const, - id: "call_456", - name: "read_file", - input: '{"path":"test.ts"}', - }, - ], - }, - ] - const result = convert(messages) - const msg = result[0] as any - expect(msg.tool_calls).toHaveLength(1) - expect(msg.tool_calls[0].function.name).toBe("read_file") - expect(msg.tool_calls[0].function.arguments).toContain("test.ts") - }) - - it("should handle assistant message with string content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: "Simple text response", - }, - ] - const result = convert(messages) - expect(result).toHaveLength(1) - expect(result[0].role).toBe("assistant") - expect(result[0].content).toBe("Simple text response") - }) - - it("should handle assistant string content with reasoning_content", () => { - const messages = [ - { - role: "assistant" as const, - content: "Response after thinking", - reasoning_content: "My reasoning", - }, - ] as any[] - const result = convert(messages) - expect(result).toHaveLength(1) - expect((result[0] as any).reasoning_content).toBe("My reasoning") - }) - - it("should not add reasoning_content if empty string", () => { - const messages = [ - { - role: "assistant" as const, - content: "Response", - reasoning_content: "", - }, - ] as any[] - const result = convert(messages) - expect((result[0] as any).reasoning_content).toBeUndefined() - }) - - it("should convert user messages with tool_result blocks", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result" as const, - tool_use_id: "call_123", - content: "File contents here", - }, - ], - }, - ] - const result = convert(messages) - const msg = result[0] as any - expect(msg.role).toBe("tool") - expect(msg.tool_call_id).toBe("call_123") - expect(msg.content).toBe("File contents here") - }) - - it("should handle tool_result with array content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result" as const, - tool_use_id: "call_789", - content: [ - { type: "text" as const, text: "Part 1" }, - { type: "text" as const, text: "Part 2" }, - ], - }, - ], - }, - ] - const result = convert(messages) - expect(result[0].content).toBe("Part 1\nPart 2") - }) - - it("should handle empty tool_result content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result" as const, - tool_use_id: "call_empty", - content: "", - }, - ], - }, - ] - const result = convert(messages) - expect(result[0].content).toBe("") - }) - - it("should merge text into last tool message when both exist in same turn", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result" as const, - tool_use_id: "call_1", - content: "result", - }, - { type: "text" as const, text: "..." }, - ], - }, - ] - const result = convert(messages) - expect(result).toHaveLength(1) - expect(result[0].role).toBe("tool") - expect(result[0].content).toContain("result") - expect(result[0].content).toContain("...") - }) - - it("should keep text as separate user message when no tool_results present", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text" as const, text: "Hello" }], - }, - ] - const result = convert(messages) - expect(result).toHaveLength(1) - expect(result[0].role).toBe("user") - expect(result[0].content).toBe("Hello") - }) - - it("should handle user message with string content", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello world", - }, - ] - const result = convert(messages) - expect(result).toHaveLength(1) - expect(result[0].role).toBe("user") - expect(result[0].content).toBe("Hello world") - }) - - it("should handle full multi-turn conversation with reasoning", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text" as const, text: "Read README.md" }], - }, - { - role: "assistant", - content: [ - { type: "reasoning" as const, text: "User wants to read a file" } as any, - { type: "text" as const, text: "I'll read it" }, - { - type: "tool_use" as const, - id: "call_1", - name: "read_file", - input: { path: "README.md" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result" as const, - tool_use_id: "call_1", - content: "# README\nHello world", - }, - ], - }, - ] - const result = convert(messages) - - // user message - expect(result[0].role).toBe("user") - // assistant with reasoning + tool_calls - expect(result[1].role).toBe("assistant") - expect((result[1] as any).reasoning_content).toBe("User wants to read a file") - expect((result[1] as any).tool_calls).toHaveLength(1) - // tool result - expect(result[2].role).toBe("tool") - expect((result[2] as any).tool_call_id).toBe("call_1") - }) - }) - - describe("createMessage", () => { - it("should send request with thinking enabled in extra_body", async () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const stream = handler.createMessage("System prompt", messages) - // Consume the stream - await collectStream(stream) - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - extra_body: { thinking: { type: "enabled" } }, - }), - ) - }) - - it("should not send parallel_tool_calls or tool_choice", async () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const stream = handler.createMessage("System prompt", messages) - await collectStream(stream) - - const params = mockCreate.mock.calls[0][0] - expect(params.parallel_tool_calls).toBeUndefined() - expect(params.tool_choice).toBeUndefined() - }) - - it("should send stream_options with include_usage", async () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const stream = handler.createMessage("System prompt", messages) - await collectStream(stream) - - const params = mockCreate.mock.calls[0][0] - expect(params.stream_options).toEqual({ include_usage: true }) - }) - - it("should include tools when provided", async () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - const tools = [ - { - type: "function" as const, - function: { - name: "read_file", - description: "Read a file", - parameters: { - type: "object", - properties: { path: { type: "string" } }, - required: ["path"], - }, - }, - }, - ] - - const stream = handler.createMessage("System prompt", messages, { tools } as any) - await collectStream(stream) - - const params = mockCreate.mock.calls[0][0] - expect(params.tools).toHaveLength(1) - expect(params.tools[0].function.name).toBe("read_file") - }) - - it("should yield text chunks from stream", async () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System prompt", messages)) - - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks.length).toBeGreaterThan(0) - expect(textChunks[0].text).toBe("Test response") - }) - - it("should yield usage chunk at the end", async () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System prompt", messages)) - - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].inputTokens).toBe(10) - expect(usageChunks[0].outputTokens).toBe(5) - }) - - it("streams reasoning chunks from delta.reasoning_content", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] }, - { choices: [{ delta: { content: "answer" }, index: 0 }] }, - { - choices: [{ delta: {}, index: 0 }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]), - ) - - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System prompt", messages)) - - expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." }) - }) - - it("falls back to delta.reasoning when reasoning_content is absent", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }, - { - choices: [{ delta: {}, index: 0 }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]), - ) - - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System prompt", messages)) - - expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" }) - }) - - it("prefers delta.reasoning_content over delta.reasoning when both are present", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { - choices: [ - { - delta: { - reasoning_content: "primary thought", - reasoning: "fallback thought", - }, - index: 0, - }, - ], - }, - { - choices: [{ delta: {}, index: 0 }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]), - ) - - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System prompt", messages)) - - const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") - expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }]) - }) - - it("should yield tool_call_partial chunks from stream", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_abc", - function: { name: "read_file", arguments: '{"path' }, - }, - ], - }, - index: 0, - }, - ], - usage: null, - }, - { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - function: { arguments: '":"test.ts"}' }, - }, - ], - }, - index: 0, - }, - ], - usage: null, - }, - { - choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], - usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }, - ]), - ) - - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Read test.ts" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System prompt", messages)) - - const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") - expect(toolChunks).toHaveLength(2) - expect(toolChunks[0].id).toBe("call_abc") - expect(toolChunks[0].name).toBe("read_file") - expect(toolChunks[0].arguments).toBe('{"path') - expect(toolChunks[1].arguments).toBe('":"test.ts"}') - }) - - it("should yield usage with cache tokens", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { - choices: [{ delta: { content: "Hi" }, index: 0 }], - usage: null, - }, - { - choices: [{ delta: {}, index: 0, finish_reason: "stop" }], - usage: { - prompt_tokens: 100, - completion_tokens: 20, - total_tokens: 120, - prompt_tokens_details: { - cache_write_tokens: 50, - cached_tokens: 30, - }, - }, - }, - ]), - ) - - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System prompt", messages)) - - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(1) - expect(usageChunks[0].inputTokens).toBe(100) - expect(usageChunks[0].outputTokens).toBe(20) - expect(usageChunks[0].cacheWriteTokens).toBe(50) - expect(usageChunks[0].cacheReadTokens).toBe(30) - expect(usageChunks[0].totalCost).toBeGreaterThan(0) - }) - - it("should handle API errors gracefully", async () => { - mockCreate.mockRejectedValueOnce(new Error("400 Param Incorrect")) - - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - await expect(async () => { - await collectStream(handler.createMessage("System prompt", messages)) - }).rejects.toThrow() - }) - - it("should send converted Anthropic messages to API", async () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [{ type: "text", text: "Read the file" }], - }, - { - role: "assistant", - content: [ - { type: "text" as const, text: "I'll read it" }, - { - type: "tool_use" as const, - id: "call_1", - name: "read_file", - input: { path: "README.md" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result" as const, - tool_use_id: "call_1", - content: "# Hello", - }, - ], - }, - ] - - const stream = handler.createMessage("System prompt", messages) - await collectStream(stream) - - const params = mockCreate.mock.calls[0][0] - expect(params.messages).toHaveLength(4) // system + user + assistant + tool - expect(params.messages[0].role).toBe("system") - expect(params.messages[0].content).toBe("System prompt") - expect(params.messages[1].role).toBe("user") - expect(params.messages[2].role).toBe("assistant") - expect(params.messages[2].reasoning_content).toBeUndefined() - expect(params.messages[2].tool_calls).toHaveLength(1) - expect(params.messages[3].role).toBe("tool") - expect(params.messages[3].tool_call_id).toBe("call_1") - }) - - it("should not include tools param when no tools provided", async () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const stream = handler.createMessage("System prompt", messages) - await collectStream(stream) - - const params = mockCreate.mock.calls[0][0] - expect(params.tools).toBeUndefined() - }) - - it("should handle empty delta chunks without errors", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { choices: [{}], usage: null }, - { choices: [{ delta: {} }], usage: null }, - { - choices: [{ delta: {}, index: 0, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]), - ) - - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System prompt", messages)) - - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(0) - }) - - it("should handle multiple tool calls in single response", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_1", - function: { name: "read_file", arguments: '{"path":' }, - }, - { - index: 1, - id: "call_2", - function: { name: "list_files", arguments: '{"path":' }, - }, - ], - }, - index: 0, - }, - ], - usage: null, - }, - { - choices: [ - { - delta: { - tool_calls: [ - { index: 0, function: { arguments: '"a.txt"}' } }, - { index: 1, function: { arguments: '"./"}' } }, - ], - }, - index: 0, - }, - ], - usage: null, - }, - { - choices: [{ delta: {}, index: 0, finish_reason: "stop" }], - usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, - }, - ]), - ) - - const tools: any[] = [ - { - type: "function", - function: { name: "read_file", description: "Read", parameters: {} }, - }, - { - type: "function", - function: { name: "list_files", description: "List", parameters: {} }, - }, - ] - - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System", messages, { taskId: "test", tools })) - - const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") - const readChunks = toolChunks.filter((c) => c.name === "read_file") - const listChunks = toolChunks.filter((c) => c.name === "list_files") - expect(readChunks.length).toBeGreaterThan(0) - expect(listChunks.length).toBeGreaterThan(0) - }) - - it("should handle stream interruption gracefully", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { - choices: [{ delta: { content: "Partial " }, index: 0 }], - usage: null, - }, - ]), - ) - - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System", messages)) - - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("Partial ") - - const usageChunks = chunks.filter((c) => c.type === "usage") - expect(usageChunks).toHaveLength(0) - }) - - it("should sanitize tool call IDs with invalid characters", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_with-special.chars@123", - function: { name: "test_tool", arguments: "{}" }, - }, - ], - }, - index: 0, - }, - ], - usage: null, - }, - { - choices: [{ delta: {}, index: 0, finish_reason: "stop" }], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]), - ) - - const tools: any[] = [ - { - type: "function", - function: { name: "test_tool", description: "Test", parameters: {} }, - }, - ] - - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const chunks = await collectStream(handler.createMessage("System", messages, { taskId: "test", tools })) - - const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") - expect(toolChunks.length).toBeGreaterThan(0) - expect(toolChunks[0].id).toBe(sanitizeOpenAiCallId("call_with-special.chars@123")) - expect(toolChunks[0].id).not.toMatch(/[^a-zA-Z0-9_-]/) - }) - - it("should convert system prompt to system message for MiMo", async () => { - const userMessages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: [{ type: "text", text: "Hello" }] }, - ] - - const stream = handler.createMessage("You are a helpful assistant", userMessages) - await collectStream(stream) - - const params = mockCreate.mock.calls[0][0] - expect(params.messages[0].role).toBe("system") - expect(params.messages[0].content).toBe("You are a helpful assistant") - expect(params.messages[1].role).toBe("user") - }) - }) - - describe("completePrompt", () => { - it("should complete prompt successfully", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: "Test response" } }], - }) - - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("Test response") - }) - - it("should send correct parameters to the API", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: "Response" } }], - }) - - await handler.completePrompt("What is 2+2?") - - const params = mockCreate.mock.calls[0][0] - expect(params.model).toBe("mimo-v2.5-pro") - expect(params.messages).toHaveLength(1) - expect(params.messages[0].role).toBe("user") - expect(params.messages[0].content).toBe("What is 2+2?") - }) - - it("should handle API errors with provider prefix", async () => { - mockCreate.mockRejectedValueOnce(new Error("401 Unauthorized")) - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error:") - }) - - it("should return empty string when choices array is empty", async () => { - mockCreate.mockResolvedValueOnce({ choices: [] }) - - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") - }) - - it("should return empty string when message content is null", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: null } }], - }) - - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") - }) - - it("should propagate network errors with provider prefix", async () => { - mockCreate.mockRejectedValueOnce(new Error("ECONNREFUSED")) - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error:") - }) - - it("should propagate rate limit errors with provider prefix", async () => { - mockCreate.mockRejectedValueOnce(new Error("429 Too Many Requests")) - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error:") - }) - - it("should use correct model ID for mimo-v2.5 variant", async () => { - const v25Handler = new MimoHandler({ - ...mockOptions, - apiModelId: "mimo-v2.5", - }) - - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: "Response" } }], - }) - - await v25Handler.completePrompt("Test") - - const params = mockCreate.mock.calls[0][0] - expect(params.model).toBe("mimo-v2.5") - }) - }) -}) +import type { ApiStreamChunk } from "../../transform/stream" +import type { DeepSeekAssistantMessage } from "../../transform/r1-format" +import type OpenAI from "openai" + +const mockCreate = vi.fn() +vi.mock("openai", () => { + return { + __esModule: true, + default: vi.fn().mockImplementation(function () { + return { + chat: { + completions: { + create: mockCreate.mockImplementation(async (_options: unknown) => { + return { + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "stop" }], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + prompt_tokens_details: { cached_tokens: 2 }, + }, + } + }, + } + }), + }, + }, + } + }), + } +}) + +import type { Anthropic } from "@anthropic-ai/sdk" +import { mimoDefaultModelId, mimoModels } from "@roo-code/types" +import type { ApiHandlerOptions } from "../../../shared/api" +import { MimoHandler } from "../mimo" +import { convertToR1Format } from "../../transform/r1-format" +import { sanitizeOpenAiCallId } from "../../../utils/tool-id" +import type { ApiHandlerCreateMessageMetadata } from "../../index" + +describe("MimoHandler", () => { + let handler: MimoHandler + let mockOptions: ApiHandlerOptions + + beforeEach(() => { + mockOptions = { + mimoApiKey: "test-api-key", + apiModelId: "mimo-v2.5-pro", + mimoBaseUrl: "https://token-plan-sgp.xiaomimimo.com/v1", + } + handler = new MimoHandler(mockOptions) + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(MimoHandler) + expect(handler.getModel().id).toBe("mimo-v2.5-pro") + }) + + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new MimoHandler({ + ...mockOptions, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(mimoDefaultModelId) + }) + + it("should use Singapore base URL if not provided", () => { + const h = new MimoHandler({ ...mockOptions, mimoBaseUrl: undefined }) + expect((h as unknown as { options: { openAiBaseUrl: string } }).options.openAiBaseUrl).toBe( + "https://token-plan-sgp.xiaomimimo.com/v1", + ) + }) + + it("should use custom base URL when provided", () => { + const customUrl = "https://api.xiaomimimo.com/v1" + const h = new MimoHandler({ ...mockOptions, mimoBaseUrl: customUrl }) + expect((h as unknown as { options: { openAiBaseUrl: string } }).options.openAiBaseUrl).toBe(customUrl) + }) + }) + + describe("getModel", () => { + it("should return correct model info for mimo-v2.5-pro", () => { + const model = handler.getModel() + expect(model.id).toBe("mimo-v2.5-pro") + expect(model.info.contextWindow).toBe(1_048_576) + expect(model.info.maxTokens).toBe(131_072) + expect(model.info.inputPrice).toBe(1.0) + expect(model.info.outputPrice).toBe(3.0) + }) + + it("should return correct model info for mimo-v2.5", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: "mimo-v2.5" }) + const model = h.getModel() + expect(model.id).toBe("mimo-v2.5") + expect(model.info.inputPrice).toBe(0.4) + expect(model.info.outputPrice).toBe(2.0) + }) + + it("should fallback to default model for unknown model ID", () => { + const h = new MimoHandler({ ...mockOptions, apiModelId: "unknown-model" }) + const model = h.getModel() + expect(model.id).toBe("unknown-model") + expect(model.info).toBe(mimoModels["mimo-v2.5-pro"]) + }) + }) + + describe("convertMessagesForMiMo (via convertToR1Format)", () => { + const convert = (messages: Anthropic.Messages.MessageParam[]) => + convertToR1Format(messages, { + mergeToolResultText: true, + normalizeToolCallId: sanitizeOpenAiCallId, + }) + + it("should convert assistant message with reasoning and text", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "reasoning" as const, + text: "Let me think...", + } as unknown as Anthropic.Messages.MessageParam["content"][number], + { type: "text" as const, text: "Here is the answer" }, + ] as unknown as Anthropic.Messages.MessageParam["content"], + }, + ] + const result = convert(messages) + expect(result).toHaveLength(1) + expect(result[0].role).toBe("assistant") + expect(result[0].content).toBe("Here is the answer") + expect((result[0] as DeepSeekAssistantMessage).reasoning_content).toBe("Let me think...") + }) + + it("should convert assistant message with tool_use blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { type: "text" as const, text: "I'll read the file" }, + { + type: "tool_use" as const, + id: "call_123", + name: "read_file", + input: { path: "README.md" }, + }, + ], + }, + ] + const result = convert(messages) + expect(result).toHaveLength(1) + const msg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(msg.tool_calls).toHaveLength(1) + expect(msg.tool_calls![0].id).toBe("call_123") + expect((msg.tool_calls![0] as OpenAI.Chat.ChatCompletionMessageFunctionToolCall).function.name).toBe( + "read_file", + ) + expect((msg.tool_calls![0] as OpenAI.Chat.ChatCompletionMessageFunctionToolCall).function.arguments).toBe( + '{"path":"README.md"}', + ) + }) + + it("should handle string-input tool_use (JSON string)", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: [ + { + type: "tool_use" as const, + id: "call_456", + name: "read_file", + input: '{"path":"test.ts"}', + }, + ], + }, + ] + const result = convert(messages) + const msg = result[0] as OpenAI.Chat.ChatCompletionAssistantMessageParam + expect(msg.tool_calls).toHaveLength(1) + expect((msg.tool_calls![0] as OpenAI.Chat.ChatCompletionMessageFunctionToolCall).function.name).toBe( + "read_file", + ) + expect( + (msg.tool_calls![0] as OpenAI.Chat.ChatCompletionMessageFunctionToolCall).function.arguments, + ).toContain("test.ts") + }) + + it("should handle assistant message with string content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "assistant", + content: "Simple text response", + }, + ] + const result = convert(messages) + expect(result).toHaveLength(1) + expect(result[0].role).toBe("assistant") + expect(result[0].content).toBe("Simple text response") + }) + + it("should handle assistant string content with reasoning_content", () => { + const messages = [ + { + role: "assistant" as const, + content: "Response after thinking", + reasoning_content: "My reasoning", + }, + ] as unknown as Anthropic.Messages.MessageParam[] + const result = convert(messages) + expect(result).toHaveLength(1) + expect((result[0] as DeepSeekAssistantMessage).reasoning_content).toBe("My reasoning") + }) + + it("should not add reasoning_content if empty string", () => { + const messages = [ + { + role: "assistant" as const, + content: "Response", + reasoning_content: "", + }, + ] as unknown as Anthropic.Messages.MessageParam[] + const result = convert(messages) + expect((result[0] as DeepSeekAssistantMessage).reasoning_content).toBeUndefined() + }) + + it("should convert user messages with tool_result blocks", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "call_123", + content: "File contents here", + }, + ], + }, + ] + const result = convert(messages) + const msg = result[0] as OpenAI.Chat.ChatCompletionToolMessageParam + expect(msg.role).toBe("tool") + expect(msg.tool_call_id).toBe("call_123") + expect(msg.content).toBe("File contents here") + }) + + it("should handle tool_result with array content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "call_789", + content: [ + { type: "text" as const, text: "Part 1" }, + { type: "text" as const, text: "Part 2" }, + ], + }, + ], + }, + ] + const result = convert(messages) + expect(result[0].content).toBe("Part 1\nPart 2") + }) + + it("should handle empty tool_result content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "call_empty", + content: "", + }, + ], + }, + ] + const result = convert(messages) + expect(result[0].content).toBe("") + }) + + it("should merge text into last tool message when both exist in same turn", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "call_1", + content: "result", + }, + { type: "text" as const, text: "..." }, + ], + }, + ] + const result = convert(messages) + expect(result).toHaveLength(1) + expect(result[0].role).toBe("tool") + expect(result[0].content).toContain("result") + expect(result[0].content).toContain("...") + }) + + it("should keep text as separate user message when no tool_results present", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello" }], + }, + ] + const result = convert(messages) + expect(result).toHaveLength(1) + expect(result[0].role).toBe("user") + expect(result[0].content).toBe("Hello") + }) + + it("should handle user message with string content", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: "Hello world", + }, + ] + const result = convert(messages) + expect(result).toHaveLength(1) + expect(result[0].role).toBe("user") + expect(result[0].content).toBe("Hello world") + }) + + it("should handle full multi-turn conversation with reasoning", () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Read README.md" }], + }, + { + role: "assistant", + content: [ + { + type: "reasoning" as const, + text: "User wants to read a file", + } as unknown as Anthropic.Messages.MessageParam["content"][number], + { type: "text" as const, text: "I'll read it" }, + { + type: "tool_use" as const, + id: "call_1", + name: "read_file", + input: { path: "README.md" }, + }, + ] as unknown as Anthropic.Messages.MessageParam["content"], + }, + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "call_1", + content: "# README\nHello world", + }, + ], + }, + ] + const result = convert(messages) + + // user message + expect(result[0].role).toBe("user") + // assistant with reasoning + tool_calls + expect(result[1].role).toBe("assistant") + expect((result[1] as DeepSeekAssistantMessage).reasoning_content).toBe("User wants to read a file") + expect((result[1] as OpenAI.Chat.ChatCompletionAssistantMessageParam).tool_calls).toHaveLength(1) + // tool result + expect(result[2].role).toBe("tool") + expect((result[2] as OpenAI.Chat.ChatCompletionToolMessageParam).tool_call_id).toBe("call_1") + }) + }) + + describe("createMessage", () => { + it("should send request with thinking enabled in extra_body", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("System prompt", messages) + // Consume the stream + for await (const _chunk of stream) { + // drain + } + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + extra_body: { thinking: { type: "enabled" } }, + }), + ) + }) + + it("should omit parallel_tool_calls when metadata.parallelToolCalls is undefined", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("System prompt", messages) + for await (const _chunk of stream) { + // drain + } + + const params = mockCreate.mock.calls[0][0] + expect(params.parallel_tool_calls).toBeUndefined() + expect(params.tool_choice).toBeUndefined() + }) + + it("should send parallel_tool_calls: false when metadata.parallelToolCalls is false", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("System prompt", messages, { + taskId: "test-task", + parallelToolCalls: false, + }) + for await (const _chunk of stream) { + // drain + } + + const params = mockCreate.mock.calls[0][0] + expect(params.parallel_tool_calls).toBe(false) + }) + + it("should send parallel_tool_calls: true when metadata.parallelToolCalls is true", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("System prompt", messages, { + taskId: "test-task", + parallelToolCalls: true, + }) + for await (const _chunk of stream) { + // drain + } + + const params = mockCreate.mock.calls[0][0] + expect(params.parallel_tool_calls).toBe(true) + }) + + it("should pass through tool_choice when provided in metadata", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("System prompt", messages, { + taskId: "test-task", + tool_choice: "auto", + }) + for await (const _chunk of stream) { + // drain + } + + const params = mockCreate.mock.calls[0][0] + expect(params.tool_choice).toBe("auto") + }) + + it("should retry without parallel_tool_calls when endpoint rejects the field", async () => { + // First call rejects with a 400 error mentioning parallel_tool_calls + const rejectionError = Object.assign( + new Error("400 - Unrecognized request parameter: parallel_tool_calls"), + { + status: 400, + }, + ) + mockCreate.mockRejectedValueOnce(rejectionError) + + // Second call (retry) succeeds + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Retried" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("System prompt", messages, { + taskId: "test-task", + parallelToolCalls: false, + }) + + const chunks: ApiStreamChunk[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // First call should have had parallel_tool_calls + const firstCallParams = mockCreate.mock.calls[0][0] + expect(firstCallParams.parallel_tool_calls).toBe(false) + + // Second call (retry) should NOT have parallel_tool_calls + const retryCallParams = mockCreate.mock.calls[1][0] + expect(retryCallParams.parallel_tool_calls).toBeUndefined() + + // Stream should have produced text from the retry + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.length).toBeGreaterThan(0) + expect(textChunks[0].text).toBe("Retried") + }) + + it("should retry without the strict flag when the endpoint rejects strict tool schemas", async () => { + // First call rejects with a 400 error naming the strict field + const rejectionError = Object.assign(new Error("400 - Unknown parameter: tools[0].function.strict"), { + status: 400, + }) + mockCreate.mockRejectedValueOnce(rejectionError) + + // Second call (retry) succeeds + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Retried" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + }, + })) + + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ] + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System prompt", messages, { taskId: "test-task", tools }) + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(mockCreate).toHaveBeenCalledTimes(2) + + // First call sent tools with the strict flag applied + const firstCallParams = mockCreate.mock.calls[0][0] + expect(firstCallParams.tools[0].function).toHaveProperty("strict") + + // Retry stripped the strict flag but kept the original schema + const retryCallParams = mockCreate.mock.calls[1][0] + expect(retryCallParams.tools).toHaveLength(1) + expect(retryCallParams.tools[0].function.name).toBe("read_file") + expect(retryCallParams.tools[0].function).not.toHaveProperty("strict") + expect(retryCallParams.tools[0].function.parameters).toEqual({ + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }) + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks[0].text).toBe("Retried") + }) + + it("should retry without the strict flag when the endpoint rejects hardened schema fields", async () => { + // 400 naming additionalProperties in a tools context + const rejectionError = Object.assign( + new Error("400 - Invalid tools: additionalProperties is not a supported field"), + { status: 400 }, + ) + mockCreate.mockRejectedValueOnce(rejectionError) + + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Retried" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + }, + })) + + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { name: "read_file", description: "Read", parameters: {} }, + }, + ] + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("System prompt", messages, { taskId: "test-task", tools }) + for await (const _chunk of stream) { + // drain + } + + expect(mockCreate).toHaveBeenCalledTimes(2) + const retryCallParams = mockCreate.mock.calls[1][0] + expect(retryCallParams.tools[0].function).not.toHaveProperty("strict") + }) + + it("should not retry schema-unrelated 400 errors", async () => { + // A 400 about reasoning_content (not tool schemas) must NOT trigger + // the strict-schema fallback. + const rejectionError = Object.assign( + new Error("400 - reasoning_content is required in multi-turn tool call conversations"), + { status: 400 }, + ) + mockCreate.mockRejectedValueOnce(rejectionError) + + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { name: "read_file", description: "Read", parameters: {} }, + }, + ] + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + await expect(async () => { + const stream = handler.createMessage("System prompt", messages, { taskId: "test-task", tools }) + for await (const _chunk of stream) { + // drain + } + }).rejects.toThrow() + + expect(mockCreate).toHaveBeenCalledTimes(1) + }) + + it("should not retry when rejection is a non-Error value (parallel_tool_calls path)", async () => { + // A non-Error rejection (e.g. a string) must not trigger the + // parallel_tool_calls fallback. isParallelToolCallsRejected + // returns false for non-Error values. + mockCreate.mockRejectedValueOnce("network failure") + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + await expect(async () => { + const stream = handler.createMessage("System prompt", messages, { + taskId: "test-task", + parallelToolCalls: false, + }) + for await (const _chunk of stream) { + // drain + } + }).rejects.toThrow() + + expect(mockCreate).toHaveBeenCalledTimes(1) + }) + + it("should not retry strict-schema fallback for non-400 errors", async () => { + // A 500 error must NOT trigger the strict-schema fallback. + // isStrictToolSchemaRejected returns false when status !== 400. + const rejectionError = Object.assign( + new Error("500 - Internal server error"), + { status: 500 }, + ) + mockCreate.mockRejectedValueOnce(rejectionError) + + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { name: "read_file", description: "Read", parameters: {} }, + }, + ] + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + await expect(async () => { + const stream = handler.createMessage("System prompt", messages, { taskId: "test-task", tools }) + for await (const _chunk of stream) { + // drain + } + }).rejects.toThrow() + + expect(mockCreate).toHaveBeenCalledTimes(1) + }) + + it("should not retry strict-schema fallback for non-Error rejections", async () => { + // A non-Error rejection (e.g. a string) must not trigger the + // strict-schema fallback. isStrictToolSchemaRejected returns + // false for non-Error values. + mockCreate.mockRejectedValueOnce("bad gateway") + + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { name: "read_file", description: "Read", parameters: {} }, + }, + ] + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + await expect(async () => { + const stream = handler.createMessage("System prompt", messages, { taskId: "test-task", tools }) + for await (const _chunk of stream) { + // drain + } + }).rejects.toThrow() + + expect(mockCreate).toHaveBeenCalledTimes(1) + }) + + it("should pass non-function tools through unchanged during strict-schema retry", async () => { + // When the endpoint rejects strict tool schemas, the retry + // strips strict from function tools but passes non-function + // tools (e.g. type "code_interpreter") through unchanged. + const rejectionError = Object.assign(new Error("400 - Unknown parameter: tools[0].function.strict"), { + status: 400, + }) + mockCreate.mockRejectedValueOnce(rejectionError) + + // Second call (retry) succeeds + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Retried" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + }, + })) + + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { + name: "read_file", + description: "Read", + parameters: { type: "object", properties: {} }, + strict: true, + }, + }, + // Non-function tool — should pass through stripStrictFromTools unchanged + { + type: "code_interpreter" as OpenAI.Chat.ChatCompletionTool["type"], + code_interpreter: { name: "code_interpreter" }, + } as unknown as OpenAI.Chat.ChatCompletionTool, + ] + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("System prompt", messages, { taskId: "test-task", tools }) + for await (const _chunk of stream) { + // drain + } + + // The retry call should have been made + expect(mockCreate).toHaveBeenCalledTimes(2) + + // The retry call's tools should have the function tool with strict removed + // and the non-function tool preserved unchanged + const retryCallParams = mockCreate.mock.calls[1][0] + expect(retryCallParams.tools).toBeDefined() + expect(retryCallParams.tools).toHaveLength(2) + // Function tool should have strict removed + expect(retryCallParams.tools[0].function).not.toHaveProperty("strict") + // Non-function tool should be preserved + expect(retryCallParams.tools[1].type).toBe("code_interpreter") + }) + + it("should send stream_options with include_usage", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("System prompt", messages) + for await (const _chunk of stream) { + // drain + } + + const params = mockCreate.mock.calls[0][0] + expect(params.stream_options).toEqual({ include_usage: true }) + }) + + it("should include tools when provided", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + const tools = [ + { + type: "function" as const, + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ] + + const stream = handler.createMessage("System prompt", messages, { + tools, + } as unknown as ApiHandlerCreateMessageMetadata) + for await (const _chunk of stream) { + // drain + } + + const params = mockCreate.mock.calls[0][0] + expect(params.tools).toHaveLength(1) + expect(params.tools[0].function.name).toBe("read_file") + }) + + it("should yield text chunks from stream", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System prompt", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks.length).toBeGreaterThan(0) + expect(textChunks[0].text).toBe("Test response") + }) + + it("should yield usage chunk at the end", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System prompt", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(5) + }) + + it("streams reasoning chunks from delta.reasoning_content", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] } + yield { choices: [{ delta: { content: "answer" }, index: 0 }] } + yield { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + for await (const chunk of handler.createMessage("System prompt", messages)) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." }) + }) + + it("falls back to delta.reasoning when reasoning_content is absent", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] } + yield { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + for await (const chunk of handler.createMessage("System prompt", messages)) { + chunks.push(chunk) + } + + expect(chunks).toContainEqual({ type: "reasoning", text: "router-style thought" }) + }) + + it("prefers delta.reasoning_content over delta.reasoning when both are present", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + reasoning_content: "primary thought", + reasoning: "fallback thought", + }, + index: 0, + }, + ], + } + yield { + choices: [{ delta: {}, index: 0 }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + for await (const chunk of handler.createMessage("System prompt", messages)) { + chunks.push(chunk) + } + + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }]) + }) + + it("should yield tool_call_partial chunks from stream", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_abc", + function: { name: "read_file", arguments: '{"path' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: '":"test.ts"}' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Read test.ts" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System prompt", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_partial", + ) + expect(toolChunks).toHaveLength(2) + expect(toolChunks[0].id).toBe("call_abc") + expect(toolChunks[0].name).toBe("read_file") + expect(toolChunks[0].arguments).toBe('{"path') + expect(toolChunks[1].arguments).toBe('":"test.ts"}') + }) + + it("should yield usage with cache tokens", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Hi" }, index: 0 }], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "stop" }], + usage: { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + prompt_tokens_details: { + cache_write_tokens: 50, + cached_tokens: 30, + }, + }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System prompt", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter( + (c): c is Extract => c.type === "usage", + ) + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(20) + expect(usageChunks[0].cacheWriteTokens).toBe(50) + expect(usageChunks[0].cacheReadTokens).toBe(30) + expect(usageChunks[0].totalCost).toBeGreaterThan(0) + }) + + it("should handle API errors gracefully", async () => { + mockCreate.mockRejectedValueOnce(new Error("400 Param Incorrect")) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + await expect(async () => { + const stream = handler.createMessage("System prompt", messages) + for await (const _chunk of stream) { + // drain + } + }).rejects.toThrow() + }) + + it("should send converted Anthropic messages to API", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text", text: "Read the file" }], + }, + { + role: "assistant", + content: [ + { type: "text" as const, text: "I'll read it" }, + { + type: "tool_use" as const, + id: "call_1", + name: "read_file", + input: { path: "README.md" }, + }, + ], + }, + { + role: "user", + content: [ + { + type: "tool_result" as const, + tool_use_id: "call_1", + content: "# Hello", + }, + ], + }, + ] + + const stream = handler.createMessage("System prompt", messages) + for await (const _chunk of stream) { + // drain + } + + const params = mockCreate.mock.calls[0][0] + expect(params.messages).toHaveLength(4) // system + user + assistant + tool + expect(params.messages[0].role).toBe("system") + expect(params.messages[0].content).toBe("System prompt") + expect(params.messages[1].role).toBe("user") + expect(params.messages[2].role).toBe("assistant") + expect(params.messages[2].reasoning_content).toBeUndefined() + expect(params.messages[2].tool_calls).toHaveLength(1) + expect(params.messages[3].role).toBe("tool") + expect(params.messages[3].tool_call_id).toBe("call_1") + }) + + it("should not include tools param when no tools provided", async () => { + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("System prompt", messages) + for await (const _chunk of stream) { + // drain + } + + const params = mockCreate.mock.calls[0][0] + expect(params.tools).toBeUndefined() + }) + + it("should handle empty delta chunks without errors", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{}], usage: null } + yield { choices: [{ delta: {} }], usage: null } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System prompt", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(0) + }) + + it("should suppress parallel tool calls, keeping only the first", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + function: { name: "read_file", arguments: '{"path":' }, + }, + { + index: 1, + id: "call_2", + function: { name: "list_files", arguments: '{"path":' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, function: { arguments: '"a.txt"}' } }, + { index: 1, function: { arguments: '"./"}' } }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "stop" }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { name: "read_file", description: "Read", parameters: {} }, + }, + { + type: "function", + function: { name: "list_files", description: "List", parameters: {} }, + }, + ] + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System", messages, { taskId: "test", tools }) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_partial", + ) + const readChunks = toolChunks.filter((c) => c.name === "read_file") + const listChunks = toolChunks.filter((c) => c.name === "list_files") + expect(readChunks.length).toBeGreaterThan(0) + expect(listChunks.length).toBe(0) + }) + + describe("parallel tool call suppression", () => { + it("drops the second parallel tool call and keeps the first", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_1", + function: { name: "read_file", arguments: '{"path":' }, + }, + { + index: 1, + id: "call_2", + function: { name: "list_files", arguments: '{"path":' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { index: 0, function: { arguments: '"a.txt"}' } }, + { index: 1, function: { arguments: '"./"}' } }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_partial", + ) + const listChunks = toolChunks.filter((c) => c.name === "list_files") + expect(toolChunks.length).toBe(2) + expect(listChunks.length).toBe(0) + expect(toolChunks[0].id).toBe("call_1") + expect(toolChunks[0].name).toBe("read_file") + }) + + it("drops parallel calls arriving in later chunks", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_a", + function: { name: "read_file", arguments: '{"path":' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 1, + id: "call_b", + function: { name: "list_files", arguments: '{"path":' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '"a.txt"}' } }], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_partial", + ) + const listChunks = toolChunks.filter((c) => c.name === "list_files") + expect(toolChunks.length).toBe(2) + expect(listChunks.length).toBe(0) + expect(toolChunks[0].name).toBe("read_file") + }) + + it("passes a single tool call through unchanged", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_abc", + function: { name: "read_file", arguments: '{"path' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '":"test.ts"}' } }], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Read test.ts" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System prompt", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_partial", + ) + expect(toolChunks).toHaveLength(2) + expect(toolChunks[0].id).toBe("call_abc") + expect(toolChunks[0].name).toBe("read_file") + expect(toolChunks[0].arguments).toBe('{"path') + expect(toolChunks[1].arguments).toBe('":"test.ts"}') + }) + + it("emits exactly one tool_call_end for the surviving call", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_x", + function: { name: "read_file", arguments: "{}" }, + }, + { + index: 1, + id: "call_y", + function: { name: "list_files", arguments: "{}" }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const endChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_end", + ) + expect(endChunks).toHaveLength(1) + expect(endChunks[0].id).toBe("call_x") + }) + + it("drops a disguised parallel call (second id at index 0)", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_a", + function: { name: "read_file", arguments: "{}" }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_b", + function: { name: "list_files", arguments: "{}" }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_partial", + ) + const readChunks = toolChunks.filter((c) => c.name === "read_file") + const listChunks = toolChunks.filter((c) => c.name === "list_files") + expect(readChunks.length).toBe(1) + expect(listChunks.length).toBe(0) + + const endChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_end", + ) + expect(endChunks).toHaveLength(1) + expect(endChunks[0].id).toBe("call_a") + }) + + it("keeps all argument-continuation fragments of a compliant single call", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_a", + function: { name: "read_file", arguments: '{"path' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '":"a' } }], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '.txt"}' } }], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_partial", + ) + expect(toolChunks).toHaveLength(3) + const accumulated = toolChunks.map((c) => c.arguments ?? "").join("") + expect(accumulated).toBe('{"path":"a.txt"}') + }) + + it("keeps fragments after the provider re-sends the kept call's id", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_a", + function: { name: "read_file", arguments: '{"path' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + // Provider re-sends the same id at index 0 (compliant duplicate). + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, id: "call_a", function: { arguments: '":"a.txt"}' } }], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: "" } }], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_partial", + ) + const accumulated = toolChunks.map((c) => c.arguments ?? "").join("") + expect(accumulated).toBe('{"path":"a.txt"}') + // Every emitted chunk belongs to the kept call. + expect(toolChunks.every((c) => c.id === undefined || c.id === "call_a")).toBe(true) + }) + + it("drops a disguised parallel call's argument fragments so they don't pollute the first call", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_a", + function: { name: "read_file", arguments: '{"path' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + // Compliant continuation of the first call. + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '":"a.txt"}' } }], + }, + index: 0, + }, + ], + usage: null, + } + // Disguised second call: index 0 reused with a NEW id. + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_b", + function: { name: "list_files", arguments: '{"path"' }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + // Id-less fragments of the disguised call — these previously + // concatenated into the FIRST call's accumulator, corrupting + // its JSON. + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '":"./"}' } }], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "tool_calls" }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + } + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_partial", + ) + // Only the first call's id chunk + compliant continuation survive. + expect(toolChunks).toHaveLength(2) + const accumulated = toolChunks.map((c) => c.arguments ?? "").join("") + // The disguised call's fragments must NOT pollute the first call — + // the accumulated arguments stay valid JSON. + expect(accumulated).toBe('{"path":"a.txt"}') + expect(() => JSON.parse(accumulated)).not.toThrow() + + const endChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_end", + ) + expect(endChunks).toHaveLength(1) + expect(endChunks[0].id).toBe("call_a") + }) + }) + + it("should handle stream interruption gracefully", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Partial " }, index: 0 }], + usage: null, + } + // Stream ends without finish_reason (connection dropped) + }, + })) + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c): c is Extract => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Partial ") + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(0) + }) + + it("should sanitize tool call IDs with invalid characters", async () => { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: "call_with-special.chars@123", + function: { name: "test_tool", arguments: "{}" }, + }, + ], + }, + index: 0, + }, + ], + usage: null, + } + yield { + choices: [{ delta: {}, index: 0, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + } + }, + })) + + const tools: OpenAI.Chat.ChatCompletionTool[] = [ + { + type: "function", + function: { name: "test_tool", description: "Test", parameters: {} }, + }, + ] + + const messages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const chunks: ApiStreamChunk[] = [] + const stream = handler.createMessage("System", messages, { taskId: "test", tools }) + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolChunks = chunks.filter( + (c): c is Extract => c.type === "tool_call_partial", + ) + expect(toolChunks.length).toBeGreaterThan(0) + expect(toolChunks[0].id).toBe(sanitizeOpenAiCallId("call_with-special.chars@123")) + expect(toolChunks[0].id).not.toMatch(/[^a-zA-Z0-9_-]/) + }) + + it("should convert system prompt to system message for MiMo", async () => { + const userMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + ] + + const stream = handler.createMessage("You are a helpful assistant", userMessages) + for await (const _chunk of stream) { + // drain + } + + const params = mockCreate.mock.calls[0][0] + expect(params.messages[0].role).toBe("system") + expect(params.messages[0].content).toBe("You are a helpful assistant") + expect(params.messages[1].role).toBe("user") + }) + }) + + describe("completePrompt", () => { + it("should complete prompt successfully", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "Test response" } }], + }) + + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("Test response") + }) + + it("should send correct parameters to the API", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "Response" } }], + }) + + await handler.completePrompt("What is 2+2?") + + const params = mockCreate.mock.calls[0][0] + expect(params.model).toBe("mimo-v2.5-pro") + expect(params.messages).toHaveLength(1) + expect(params.messages[0].role).toBe("user") + expect(params.messages[0].content).toBe("What is 2+2?") + }) + + it("should handle API errors with provider prefix", async () => { + mockCreate.mockRejectedValueOnce(new Error("401 Unauthorized")) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error:") + }) + + it("should return empty string when choices array is empty", async () => { + mockCreate.mockResolvedValueOnce({ choices: [] }) + + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("") + }) + + it("should return empty string when message content is null", async () => { + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: null } }], + }) + + const result = await handler.completePrompt("Test prompt") + expect(result).toBe("") + }) + + it("should propagate network errors with provider prefix", async () => { + mockCreate.mockRejectedValueOnce(new Error("ECONNREFUSED")) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error:") + }) + + it("should propagate rate limit errors with provider prefix", async () => { + mockCreate.mockRejectedValueOnce(new Error("429 Too Many Requests")) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("OpenAI completion error:") + }) + + it("should use correct model ID for mimo-v2.5 variant", async () => { + const v25Handler = new MimoHandler({ + ...mockOptions, + apiModelId: "mimo-v2.5", + }) + + mockCreate.mockResolvedValueOnce({ + choices: [{ message: { content: "Response" } }], + }) + + await v25Handler.completePrompt("Test") + + const params = mockCreate.mock.calls[0][0] + expect(params.model).toBe("mimo-v2.5") + }) + }) +}) diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 53dbd8740f..01102b0457 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -14,6 +14,7 @@ import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo- import { MiniMaxHandler } from "../minimax" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" vitest.mock("@anthropic-ai/sdk", () => { const mockCreate = vitest.fn() @@ -33,7 +34,7 @@ describe("MiniMaxHandler", () => { let mockCreate: any beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() const anthropicInstance = (Anthropic as unknown as any)() mockCreate = anthropicInstance.messages.create }) diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index f2a7591bd8..91fdaa06fe 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -53,7 +53,14 @@ import type OpenAI from "openai" import { MistralHandler } from "../mistral" import type { ApiHandlerOptions } from "../../../shared/api" import type { ApiHandlerCreateMessageMetadata } from "../../index" -import type { ApiStreamTextChunk, ApiStreamReasoningChunk, ApiStreamToolCallPartialChunk } from "../../transform/stream" +import type { + ApiStreamTextChunk, + ApiStreamReasoningChunk, + ApiStreamToolCallPartialChunk, + ApiStreamUsageChunk, +} from "../../transform/stream" +import { calculateApiCostOpenAI } from "../../../shared/cost" +import { mistralModels, type ModelInfo } from "@roo-code/types" describe("MistralHandler", () => { let handler: MistralHandler @@ -233,6 +240,128 @@ describe("MistralHandler", () => { expect(results[1]).toEqual({ type: "reasoning", text: "Some reasoning" }) expect(results[2]).toEqual({ type: "text", text: "Second text" }) }) + + it("should yield usage event with totalCost when stream contains usage data", async () => { + // Mock stream with usage data in Mistral SSE format + mockCreate.mockImplementationOnce(async (_options) => + asyncStreamFrom([ + { + data: { + choices: [ + { + delta: { content: "Hello" }, + index: 0, + }, + ], + usage: { + promptTokens: 100, + completionTokens: 50, + }, + }, + }, + ]), + ) + + const iterator = handler.createMessage(systemPrompt, messages) + const results: ApiStreamUsageChunk[] = [] + + for await (const chunk of iterator) { + if (chunk.type === "usage") { + results.push(chunk as ApiStreamUsageChunk) + } + } + + expect(results).toHaveLength(1) + + const modelInfo = mistralModels["codestral-latest"] + const expectedCost = calculateApiCostOpenAI(modelInfo, 100, 50, 0, 0).totalCost + + expect(results[0]).toEqual({ + type: "usage", + inputTokens: 100, + outputTokens: 50, + totalCost: expectedCost, + }) + }) + + it("should yield totalCost: 0 when modelInfo is not available", async () => { + // Mock stream with usage data but no model info available + mockCreate.mockImplementationOnce(async (_options) => + asyncStreamFrom([ + { + data: { + choices: [ + { + delta: { content: "Hello" }, + index: 0, + }, + ], + usage: { + promptTokens: 100, + completionTokens: 50, + }, + }, + }, + ]), + ) + + // Spy on getModel to return undefined info. + // maxTokens must be provided so that line 94 (`maxTokens ?? info.maxTokens`) + // short-circuits before accessing info.maxTokens (which would crash). + vi.spyOn(handler, "getModel").mockReturnValueOnce({ + id: "codestral-latest", + // Intentionally undefined to test error handling when model info is missing + info: undefined as unknown as ModelInfo, + maxTokens: 8192, + temperature: 0, + } as ReturnType) + + const iterator = handler.createMessage(systemPrompt, messages) + const results: ApiStreamUsageChunk[] = [] + + for await (const chunk of iterator) { + if (chunk.type === "usage") { + results.push(chunk as ApiStreamUsageChunk) + } + } + + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: "usage", + inputTokens: 100, + outputTokens: 50, + totalCost: 0, + }) + }) + + it("should not yield usage event when stream has no usage data", async () => { + // Mock stream without usage field + mockCreate.mockImplementationOnce(async (_options) => + asyncStreamFrom([ + { + data: { + choices: [ + { + delta: { content: "Hello" }, + index: 0, + }, + ], + }, + }, + ]), + ) + + const iterator = handler.createMessage(systemPrompt, messages) + const results: ApiStreamUsageChunk[] = [] + + for await (const chunk of iterator) { + if (chunk.type === "usage") { + results.push(chunk as ApiStreamUsageChunk) + } + } + + expect(results).toHaveLength(0) + }) }) describe("native tool calling", () => { diff --git a/src/api/providers/__tests__/moonshot.spec.ts b/src/api/providers/__tests__/moonshot.spec.ts index ab8f818697..af82c86c8e 100644 --- a/src/api/providers/__tests__/moonshot.spec.ts +++ b/src/api/providers/__tests__/moonshot.spec.ts @@ -6,6 +6,8 @@ import type { ApiHandlerOptions } from "../../../shared/api" import { MoonshotHandler } from "../moonshot" +import { clearAllMocks } from "../../../test-utils/reset" + describe("MoonshotHandler", () => { let handler: MoonshotHandler let mockOptions: ApiHandlerOptions @@ -17,7 +19,7 @@ describe("MoonshotHandler", () => { moonshotBaseUrl: "https://api.moonshot.ai/v1", } handler = new MoonshotHandler(mockOptions) - vi.clearAllMocks() + clearAllMocks() }) describe("constructor", () => { diff --git a/src/api/providers/__tests__/native-ollama.spec.ts b/src/api/providers/__tests__/native-ollama.spec.ts index f3f312d296..8fcbf4a0a1 100644 --- a/src/api/providers/__tests__/native-ollama.spec.ts +++ b/src/api/providers/__tests__/native-ollama.spec.ts @@ -6,6 +6,8 @@ import { NativeOllamaHandler } from "../native-ollama" import { ApiHandlerOptions } from "../../../shared/api" import { getOllamaModels } from "../fetchers/ollama" +import { clearAllMocks } from "../../../test-utils/reset" + // Mock the ollama package const mockChat = vitest.fn() vitest.mock("ollama", () => { @@ -30,7 +32,7 @@ describe("NativeOllamaHandler", () => { let handler: NativeOllamaHandler beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() // Default mock for getOllamaModels mockGetOllamaModels.mockResolvedValue({ @@ -330,7 +332,7 @@ describe("NativeOllamaHandler", () => { ] for (const [effort, expected] of cases) { - vitest.clearAllMocks() + clearAllMocks() mockGetOllamaModels.mockResolvedValue({ qwen3: { contextWindow: 4096, maxTokens: 4096, supportsImages: false, supportsPromptCache: false }, }) diff --git a/src/api/providers/__tests__/openai-timeout.spec.ts b/src/api/providers/__tests__/openai-timeout.spec.ts index 96a68c1314..16c2b3f710 100644 --- a/src/api/providers/__tests__/openai-timeout.spec.ts +++ b/src/api/providers/__tests__/openai-timeout.spec.ts @@ -10,6 +10,8 @@ vitest.mock("../utils/timeout-config", () => ({ import { getApiRequestTimeout } from "../utils/timeout-config" +import { clearAllMocks } from "../../../test-utils/reset" + // Mock OpenAI and AzureOpenAI const mockOpenAIConstructor = vitest.fn() const mockAzureOpenAIConstructor = vitest.fn() @@ -42,7 +44,7 @@ vitest.mock("openai", () => { describe("OpenAiHandler timeout configuration", () => { beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() }) it("should use default timeout for standard OpenAI", () => { diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index 0c81cbc75c..721e795eb4 100644 --- a/src/api/providers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -18,6 +18,7 @@ import { OpencodeGoHandler } from "../opencode-go" import { getModels } from "../fetchers/modelCache" import { ApiHandlerOptions } from "../../../shared/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" vitest.mock("openai") vitest.mock("delay", () => ({ @@ -64,7 +65,7 @@ describe("OpencodeGoHandler", () => { } beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() mockCreate.mockClear() mockAnthropicCreate.mockClear() }) diff --git a/src/api/providers/__tests__/openrouter.spec.ts b/src/api/providers/__tests__/openrouter.spec.ts index 254cd1dad4..f0000918d8 100644 --- a/src/api/providers/__tests__/openrouter.spec.ts +++ b/src/api/providers/__tests__/openrouter.spec.ts @@ -18,9 +18,10 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { OpenRouterHandler } from "../openrouter" -import { ApiHandlerOptions } from "../../../shared/api" import { Package } from "../../../shared/package" +import { makeApiHandlerOptions } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" vitest.mock("openai") vitest.mock("delay", () => ({ @@ -102,12 +103,12 @@ vitest.mock("../fetchers/modelCache", () => ({ })) describe("OpenRouterHandler", () => { - const mockOptions: ApiHandlerOptions = { + const mockOptions = makeApiHandlerOptions({ openRouterApiKey: "test-key", openRouterModelId: "anthropic/claude-sonnet-4", - } + }) - beforeEach(() => vitest.clearAllMocks()) + beforeEach(() => clearAllMocks()) it("initializes with correct options", () => { const handler = new OpenRouterHandler(mockOptions) @@ -147,12 +148,14 @@ describe("OpenRouterHandler", () => { }) it("honors custom maxTokens for thinking models", async () => { - const handler = new OpenRouterHandler({ - openRouterApiKey: "test-key", - openRouterModelId: "anthropic/claude-3.7-sonnet:thinking", - modelMaxTokens: 32_768, - modelMaxThinkingTokens: 16_384, - }) + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + openRouterApiKey: "test-key", + openRouterModelId: "anthropic/claude-3.7-sonnet:thinking", + modelMaxTokens: 32_768, + modelMaxThinkingTokens: 16_384, + }), + ) const result = await handler.fetchModel() // With the new clamping logic, 128000 tokens (64% of 200000 context window) @@ -163,11 +166,13 @@ describe("OpenRouterHandler", () => { }) it("does not honor custom maxTokens for non-thinking models", async () => { - const handler = new OpenRouterHandler({ - ...mockOptions, - modelMaxTokens: 32_768, - modelMaxThinkingTokens: 16_384, - }) + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + modelMaxTokens: 32_768, + modelMaxThinkingTokens: 16_384, + }), + ) const result = await handler.fetchModel() expect(result.maxTokens).toBe(8192) @@ -176,10 +181,12 @@ describe("OpenRouterHandler", () => { }) it("adds excludedTools and includedTools for OpenAI models", async () => { - const handler = new OpenRouterHandler({ - openRouterApiKey: "test-key", - openRouterModelId: "openai/gpt-4o", - }) + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + openRouterApiKey: "test-key", + openRouterModelId: "openai/gpt-4o", + }), + ) const result = await handler.fetchModel() expect(result.id).toBe("openai/gpt-4o") @@ -189,10 +196,12 @@ describe("OpenRouterHandler", () => { }) it("merges excludedTools and includedTools with existing values for OpenAI models", async () => { - const handler = new OpenRouterHandler({ - openRouterApiKey: "test-key", - openRouterModelId: "openai/o1", - }) + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + openRouterApiKey: "test-key", + openRouterModelId: "openai/o1", + }), + ) const result = await handler.fetchModel() expect(result.id).toBe("openai/o1") @@ -208,10 +217,12 @@ describe("OpenRouterHandler", () => { }) it("does not add excludedTools or includedTools for non-OpenAI models", async () => { - const handler = new OpenRouterHandler({ - openRouterApiKey: "test-key", - openRouterModelId: "anthropic/claude-sonnet-4", - }) + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + openRouterApiKey: "test-key", + openRouterModelId: "anthropic/claude-sonnet-4", + }), + ) const result = await handler.fetchModel() expect(result.id).toBe("anthropic/claude-sonnet-4") @@ -281,10 +292,12 @@ describe("OpenRouterHandler", () => { }) it("adds cache control for supported models", async () => { - const handler = new OpenRouterHandler({ - ...mockOptions, - openRouterModelId: "anthropic/claude-3.5-sonnet", - }) + const handler = new OpenRouterHandler( + makeApiHandlerOptions({ + ...mockOptions, + openRouterModelId: "anthropic/claude-3.5-sonnet", + }), + ) const mockStream = asyncStreamFrom([ { diff --git a/src/api/providers/__tests__/poe.spec.ts b/src/api/providers/__tests__/poe.spec.ts index b22d42179c..627d203994 100644 --- a/src/api/providers/__tests__/poe.spec.ts +++ b/src/api/providers/__tests__/poe.spec.ts @@ -74,12 +74,14 @@ vitest.mock("../fetchers/modelCache", () => ({ import { poeDefaultModelId } from "@roo-code/types" import { PoeHandler } from "../poe" +import { clearAllMocks } from "../../../test-utils/reset" + describe("PoeHandler", () => { const mockLanguageModel = { modelId: "test-model" } const mockPoeProvider = vitest.fn().mockReturnValue(mockLanguageModel) beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() mockCreatePoe.mockReturnValue(mockPoeProvider) }) diff --git a/src/api/providers/__tests__/qwen-code-native-tools.spec.ts b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts index 6c7caba260..54df551d4e 100644 --- a/src/api/providers/__tests__/qwen-code-native-tools.spec.ts +++ b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts @@ -10,6 +10,7 @@ vi.mock("node:fs", () => ({ const mockCreate = vi.fn() import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" vi.mock("openai", () => { return { __esModule: true, @@ -54,7 +55,7 @@ describe("QwenCodeHandler Native Tools", () => { ] beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() // Mock credentials file const mockCredentials = { diff --git a/src/api/providers/__tests__/requesty.spec.ts b/src/api/providers/__tests__/requesty.spec.ts index 77adb8724f..c685da0ed2 100644 --- a/src/api/providers/__tests__/requesty.spec.ts +++ b/src/api/providers/__tests__/requesty.spec.ts @@ -10,10 +10,11 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { RequestyHandler } from "../requesty" -import { ApiHandlerOptions } from "../../../shared/api" import { Package } from "../../../shared/package" import { ApiHandlerCreateMessageMetadata } from "../../index" +import { makeApiHandlerOptions } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" const mockCreate = vitest.fn() @@ -98,12 +99,12 @@ vitest.mock("../fetchers/modelCache", () => ({ })) describe("RequestyHandler", () => { - const mockOptions: ApiHandlerOptions = { + const mockOptions = makeApiHandlerOptions({ requestyApiKey: "test-key", requestyModelId: "coding/claude-4-sonnet", - } + }) - beforeEach(() => vitest.clearAllMocks()) + beforeEach(() => clearAllMocks()) it("initializes with correct options", () => { const handler = new RequestyHandler(mockOptions) @@ -244,12 +245,14 @@ describe("RequestyHandler", () => { }) it("uses adaptive thinking for Claude Fable 5 when reasoning is enabled", async () => { - const handler = new RequestyHandler({ - requestyApiKey: "test-key", - requestyModelId: "anthropic/claude-fable-5", - enableReasoningEffort: true, - modelMaxTokens: 32768, - }) + const handler = new RequestyHandler( + makeApiHandlerOptions({ + requestyApiKey: "test-key", + requestyModelId: "anthropic/claude-fable-5", + enableReasoningEffort: true, + modelMaxTokens: 32768, + }), + ) const mockStream = asyncStreamFrom([ { @@ -275,12 +278,14 @@ describe("RequestyHandler", () => { }) it("uses adaptive thinking for Claude Sonnet 5 when reasoning is enabled", async () => { - const handler = new RequestyHandler({ - requestyApiKey: "test-key", - requestyModelId: "anthropic/claude-sonnet-5", - enableReasoningEffort: true, - modelMaxTokens: 32768, - }) + const handler = new RequestyHandler( + makeApiHandlerOptions({ + requestyApiKey: "test-key", + requestyModelId: "anthropic/claude-sonnet-5", + enableReasoningEffort: true, + modelMaxTokens: 32768, + }), + ) const mockStream = asyncStreamFrom([ { @@ -306,12 +311,14 @@ describe("RequestyHandler", () => { }) it("uses adaptive thinking for Claude Opus 5 when reasoning is enabled", async () => { - const handler = new RequestyHandler({ - requestyApiKey: "test-key", - requestyModelId: "anthropic/claude-opus-5", - enableReasoningEffort: true, - modelMaxTokens: 32768, - }) + const handler = new RequestyHandler( + makeApiHandlerOptions({ + requestyApiKey: "test-key", + requestyModelId: "anthropic/claude-opus-5", + enableReasoningEffort: true, + modelMaxTokens: 32768, + }), + ) const mockStream = asyncStreamFrom([ { @@ -574,10 +581,12 @@ describe("RequestyHandler", () => { }) it("omits temperature for Claude Fable 5 in completePrompt", async () => { - const handler = new RequestyHandler({ - requestyApiKey: "test-key", - requestyModelId: "anthropic/claude-fable-5", - }) + const handler = new RequestyHandler( + makeApiHandlerOptions({ + requestyApiKey: "test-key", + requestyModelId: "anthropic/claude-fable-5", + }), + ) mockCreate.mockResolvedValue({ choices: [{ message: { content: "test completion" } }] }) await handler.completePrompt("test prompt") @@ -591,10 +600,12 @@ describe("RequestyHandler", () => { }) it("omits temperature for Claude Sonnet 5 in completePrompt", async () => { - const handler = new RequestyHandler({ - requestyApiKey: "test-key", - requestyModelId: "anthropic/claude-sonnet-5", - }) + const handler = new RequestyHandler( + makeApiHandlerOptions({ + requestyApiKey: "test-key", + requestyModelId: "anthropic/claude-sonnet-5", + }), + ) mockCreate.mockResolvedValue({ choices: [{ message: { content: "test completion" } }] }) await handler.completePrompt("test prompt") @@ -608,10 +619,12 @@ describe("RequestyHandler", () => { }) it("omits temperature for Claude Opus 5 in completePrompt", async () => { - const handler = new RequestyHandler({ - requestyApiKey: "test-key", - requestyModelId: "anthropic/claude-opus-5", - }) + const handler = new RequestyHandler( + makeApiHandlerOptions({ + requestyApiKey: "test-key", + requestyModelId: "anthropic/claude-opus-5", + }), + ) mockCreate.mockResolvedValue({ choices: [{ message: { content: "test completion" } }] }) await handler.completePrompt("test prompt") diff --git a/src/api/providers/__tests__/sambanova.spec.ts b/src/api/providers/__tests__/sambanova.spec.ts index 916def271a..2a19d5659c 100644 --- a/src/api/providers/__tests__/sambanova.spec.ts +++ b/src/api/providers/__tests__/sambanova.spec.ts @@ -7,6 +7,7 @@ import { type SambaNovaModelId, sambaNovaDefaultModelId, sambaNovaModels } from import { SambaNovaHandler } from "../sambanova" import { asyncStreamFrom } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" vitest.mock("openai", () => { const createMock = vitest.fn() @@ -22,7 +23,7 @@ describe("SambaNovaHandler", () => { let mockCreate: any beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() mockCreate = (OpenAI as unknown as any)().chat.completions.create handler = new SambaNovaHandler({ sambaNovaApiKey: "test-sambanova-api-key" }) }) diff --git a/src/api/providers/__tests__/unbound.spec.ts b/src/api/providers/__tests__/unbound.spec.ts index 0e18c4b175..9b45713386 100644 --- a/src/api/providers/__tests__/unbound.spec.ts +++ b/src/api/providers/__tests__/unbound.spec.ts @@ -3,6 +3,7 @@ import OpenAI from "openai" import { UnboundHandler } from "../unbound" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" vi.mock("openai", () => { const createMock = vi.fn() @@ -35,7 +36,7 @@ vi.mock("../fetchers/modelCache", () => ({ describe("UnboundHandler", () => { beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() }) it("identifies itself as Zoo Code in the Unbound request headers", () => { diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 92cc785951..ffad3fa0d1 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -13,8 +13,9 @@ import { Anthropic } from "@anthropic-ai/sdk" import OpenAI from "openai" import { VercelAiGatewayHandler } from "../vercel-ai-gateway" -import { ApiHandlerOptions } from "../../../shared/api" +import { makeApiHandlerOptions } from "../../../test-utils/api" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" import { vercelAiGatewayDefaultModelId, VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types" // Mock dependencies @@ -126,13 +127,13 @@ const mockConstructor = vitest.fn() }) describe("VercelAiGatewayHandler", () => { - const mockOptions: ApiHandlerOptions = { + const mockOptions = makeApiHandlerOptions({ vercelAiGatewayApiKey: "test-key", vercelAiGatewayModelId: "anthropic/claude-sonnet-4", - } + }) beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() mockCreate.mockClear() mockConstructor.mockClear() }) @@ -270,10 +271,12 @@ describe("VercelAiGatewayHandler", () => { it("uses correct temperature from options", async () => { const customTemp = 0.5 - const handler = new VercelAiGatewayHandler({ - ...mockOptions, - modelTemperature: customTemp, - }) + const handler = new VercelAiGatewayHandler( + makeApiHandlerOptions({ + ...mockOptions, + modelTemperature: customTemp, + }), + ) const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] @@ -303,10 +306,12 @@ describe("VercelAiGatewayHandler", () => { }) it("omits temperature for Claude Fable 5", async () => { - const handler = new VercelAiGatewayHandler({ - ...mockOptions, - vercelAiGatewayModelId: "anthropic/claude-fable-5", - }) + const handler = new VercelAiGatewayHandler( + makeApiHandlerOptions({ + ...mockOptions, + vercelAiGatewayModelId: "anthropic/claude-fable-5", + }), + ) await handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello" }]).next() @@ -320,10 +325,12 @@ describe("VercelAiGatewayHandler", () => { }) it("omits temperature for Claude Sonnet 5", async () => { - const handler = new VercelAiGatewayHandler({ - ...mockOptions, - vercelAiGatewayModelId: "anthropic/claude-sonnet-5", - }) + const handler = new VercelAiGatewayHandler( + makeApiHandlerOptions({ + ...mockOptions, + vercelAiGatewayModelId: "anthropic/claude-sonnet-5", + }), + ) await handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello" }]).next() @@ -338,10 +345,12 @@ describe("VercelAiGatewayHandler", () => { }) it("omits temperature for Claude Opus 5", async () => { - const handler = new VercelAiGatewayHandler({ - ...mockOptions, - vercelAiGatewayModelId: "anthropic/claude-opus-5", - }) + const handler = new VercelAiGatewayHandler( + makeApiHandlerOptions({ + ...mockOptions, + vercelAiGatewayModelId: "anthropic/claude-opus-5", + }), + ) await handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello" }]).next() @@ -357,10 +366,12 @@ describe("VercelAiGatewayHandler", () => { it("adds cache breakpoints for supported models", async () => { const { addCacheBreakpoints } = await import("../../transform/caching/vercel-ai-gateway") - const handler = new VercelAiGatewayHandler({ - ...mockOptions, - vercelAiGatewayModelId: "anthropic/claude-3.5-haiku", - }) + const handler = new VercelAiGatewayHandler( + makeApiHandlerOptions({ + ...mockOptions, + vercelAiGatewayModelId: "anthropic/claude-3.5-haiku", + }), + ) const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] @@ -647,10 +658,12 @@ describe("VercelAiGatewayHandler", () => { it("uses custom temperature for completion", async () => { const customTemp = 0.8 - const handler = new VercelAiGatewayHandler({ - ...mockOptions, - modelTemperature: customTemp, - }) + const handler = new VercelAiGatewayHandler( + makeApiHandlerOptions({ + ...mockOptions, + modelTemperature: customTemp, + }), + ) await handler.completePrompt("Test prompt") @@ -694,11 +707,13 @@ describe("VercelAiGatewayHandler", () => { describe("temperature support", () => { it("applies temperature for supported models", async () => { - const handler = new VercelAiGatewayHandler({ - ...mockOptions, - vercelAiGatewayModelId: "anthropic/claude-sonnet-4", - modelTemperature: 0.9, - }) + const handler = new VercelAiGatewayHandler( + makeApiHandlerOptions({ + ...mockOptions, + vercelAiGatewayModelId: "anthropic/claude-sonnet-4", + modelTemperature: 0.9, + }), + ) await handler.completePrompt("Test") diff --git a/src/api/providers/__tests__/vscode-lm.spec.ts b/src/api/providers/__tests__/vscode-lm.spec.ts index 37fb851720..423f119f14 100644 --- a/src/api/providers/__tests__/vscode-lm.spec.ts +++ b/src/api/providers/__tests__/vscode-lm.spec.ts @@ -65,6 +65,8 @@ import type { ApiHandlerOptions } from "../../../shared/api" import type { Anthropic } from "@anthropic-ai/sdk" import { openAiModelInfoSaneDefaults, vscodeLlmDefaultModelId, vscodeLlmModels } from "@roo-code/types" +import { clearAllMocks } from "../../../test-utils/reset" + const mockLanguageModelChat = { id: "test-model", name: "Test Model", @@ -86,7 +88,7 @@ describe("VsCodeLmHandler", () => { } beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() // Set up a default successful mock for selectChatModels before creating the handler const mockModels = [{ ...mockLanguageModelChat }] ;(vscode.lm.selectChatModels as Mock).mockResolvedValue(mockModels) diff --git a/src/api/providers/__tests__/xai.spec.ts b/src/api/providers/__tests__/xai.spec.ts index 44c3f26f6c..a0427b6fe0 100644 --- a/src/api/providers/__tests__/xai.spec.ts +++ b/src/api/providers/__tests__/xai.spec.ts @@ -10,19 +10,11 @@ vitest.mock("@roo-code/telemetry", () => ({ }, })) -const mockResponsesCreate = vitest.fn() - -vitest.mock("openai", () => { - const mockConstructor = vitest.fn() - - return { - __esModule: true, - default: mockConstructor.mockImplementation(function () { - return { - responses: { create: mockResponsesCreate }, - } - }), - } +const mockResponsesCreate = vitest.hoisted(() => vitest.fn()) + +vitest.mock("openai", async () => { + const { mockOpenAiResponsesClient } = await import("../../../test-utils/api") + return mockOpenAiResponsesClient(mockResponsesCreate) }) import OpenAI from "openai" @@ -32,12 +24,13 @@ import { xaiDefaultModelId, xaiModels } from "@roo-code/types" import { XAIHandler } from "../xai" import { asyncStreamFrom } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" describe("XAIHandler", () => { let handler: XAIHandler beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() mockResponsesCreate.mockClear() mockCaptureException.mockClear() handler = new XAIHandler({}) @@ -52,7 +45,7 @@ describe("XAIHandler", () => { }) it("should use the provided API key", () => { - vi.clearAllMocks() + clearAllMocks() const xaiApiKey = "test-api-key" new XAIHandler({ xaiApiKey }) expect(OpenAI).toHaveBeenCalledWith( diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 4e7aa1ca46..ac13152f37 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -15,6 +15,7 @@ import { import { ZAiHandler } from "../zai" import { asyncStreamFrom } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" vitest.mock("openai", () => { const createMock = vitest.fn() @@ -30,7 +31,7 @@ describe("ZAiHandler", () => { let mockCreate: any beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() mockCreate = (OpenAI as unknown as any)().chat.completions.create }) diff --git a/src/api/providers/__tests__/zoo-gateway.spec.ts b/src/api/providers/__tests__/zoo-gateway.spec.ts index 6806b40377..66131d7cb1 100644 --- a/src/api/providers/__tests__/zoo-gateway.spec.ts +++ b/src/api/providers/__tests__/zoo-gateway.spec.ts @@ -35,6 +35,7 @@ import { ApiHandlerOptions } from "../../../shared/api" import { Package } from "../../../shared/package" import { clearZooCodeToken } from "../../../services/zoo-code-auth" import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" +import { clearAllMocks } from "../../../test-utils/reset" vitest.mock("openai") vitest.mock("delay", () => ({ @@ -119,7 +120,7 @@ describe("ZooGatewayHandler", () => { } beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() mockSessionCleared.value = false mockGetCachedZooCodeToken.mockReturnValue(undefined) mockCreate.mockClear() diff --git a/src/api/providers/mimo.ts b/src/api/providers/mimo.ts index 2901c2e926..05b2167a98 100644 --- a/src/api/providers/mimo.ts +++ b/src/api/providers/mimo.ts @@ -1,4 +1,5 @@ import OpenAI from "openai" +import type { Anthropic } from "@anthropic-ai/sdk" import { mimoModels, mimoDefaultModelId, MIMO_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types" @@ -15,6 +16,134 @@ import { OpenAiHandler } from "./openai" import type { ApiHandlerCreateMessageMetadata } from "../index" import { sanitizeOpenAiCallId } from "../../utils/tool-id" +/** + * Detects whether an API error is specifically caused by the endpoint + * rejecting the `parallel_tool_calls` field. Some OpenAI-compatible + * endpoints don't support this field and return a 400 Bad Request with + * a message referencing the unrecognized parameter. + */ +function isParallelToolCallsRejected(error: unknown): boolean { + if (error instanceof Error) { + const message = error.message.toLowerCase() + const status = (error as { status?: number }).status + // OpenAI SDK APIError carries an HTTP status; some endpoints return 400 + if (message.includes("parallel_tool_calls") || (status === 400 && message.includes("unrecognized"))) { + return true + } + } + return false +} + +/** + * Detects whether an API error is specifically caused by the endpoint + * rejecting the `strict` tool flag or a hardened strict-mode schema + * (`additionalProperties: false`, forced `required`, ...). OpenAI-compatible + * endpoints that don't support structured outputs typically return a 400 + * Bad Request naming the offending field. + * + * Detection is intentionally narrow (400 status plus a schema-specific + * keyword) so unrelated 400s — e.g. MiMo's missing-reasoning_content + * rejection — are NOT mistaken for schema rejections and retried pointlessly. + */ +function isStrictToolSchemaRejected(error: unknown): boolean { + if (error instanceof Error) { + const message = error.message.toLowerCase() + const status = (error as { status?: number }).status + if (status !== 400) { + return false + } + if (message.includes("strict")) { + return true + } + const mentionsTools = message.includes("tool") || message.includes("function") + const mentionsSchemaField = + message.includes("additionalproperties") || message.includes("additional_properties") + return mentionsTools && mentionsSchemaField + } + return false +} + +/** + * Removes the `strict` flag from function tools, keeping their original + * (non-hardened) schemas. Used by the one-time retry fallback when an + * endpoint rejects strict tool schemas. + */ +function stripStrictFromTools(tools: OpenAI.Chat.ChatCompletionTool[]): OpenAI.Chat.ChatCompletionTool[] { + return tools.map((tool) => { + if (tool.type !== "function") { + return tool + } + const { strict: _omit, ...functionWithoutStrict } = tool.function + return { ...tool, function: functionWithoutStrict } + }) +} + +/** + * Filters a streamed delta so that only the FIRST tool call (index 0) survives. + * MiMo v2.5 Pro ignores `parallel_tool_calls: false` and may emit multiple + * parallel tool_calls in one turn. Downstream (ToolCallRetentionPolicy) is + * configured for maxCallsPerTurn === 1, which rejects ALL calls when two or + * more valid calls arrive; dropping extras here lets the first call execute + * normally instead of failing the whole turn. + * + * Some providers reuse `index: 0` with a NEW id for a disguised second + * parallel call. Once such an id chunk is dropped, its subsequent id-less + * argument-continuation fragments must be dropped too — an id-less fragment + * belongs to the most recent id chunk seen at that index — otherwise they + * concatenate into the FIRST call's argument accumulator and corrupt its + * JSON. `state.droppedIndexes` tracks indexes currently owned by a dropped + * call. + * + * Confined to MimoHandler — no other provider is affected. + */ +function filterToFirstToolCall( + delta: OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta, + state: { firstToolCallId: string | undefined; droppedIndexes: Set }, +): OpenAI.Chat.Completions.ChatCompletionChunk.Choice.Delta { + if (!delta.tool_calls || delta.tool_calls.length === 0) { + return delta + } + + const kept = delta.tool_calls.filter((toolCall) => { + const index = toolCall.index ?? 0 + if (index > 0) { + return false // parallel call — drop + } + if (toolCall.id) { + if (state.firstToolCallId === undefined) { + state.firstToolCallId = toolCall.id + return true + } + if (toolCall.id === state.firstToolCallId) { + // Provider re-sent the kept call's id — this index belongs to + // the kept call again. + state.droppedIndexes.delete(index) + return true + } + // A second distinct id at index 0 is a disguised parallel call. + // Mark the index so its argument fragments are dropped as well. + state.droppedIndexes.add(index) + return false + } + // Argument-continuation fragment for the most recent id chunk seen at + // this index — keep it only if that call was not dropped. + return !state.droppedIndexes.has(index) + }) + + if (kept.length === delta.tool_calls.length) { + return delta + } + if (kept.length === 0) { + const { tool_calls: _omit, ...rest } = delta + return rest + } + return { ...delta, tool_calls: kept } +} + +type MiMoCompletionParams = OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming & { + extra_body: { thinking: { type: string } } +} + /** * MiMoHandler extends OpenAiHandler with MiMo-specific adaptations. * @@ -68,7 +197,7 @@ export class MimoHandler extends OpenAiHandler { */ override async *createMessage( systemPrompt: string, - messages: any[], + messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const { id: modelId, info: modelInfo } = this.getModel() @@ -85,7 +214,7 @@ export class MimoHandler extends OpenAiHandler { // https://developer.puter.com/ai/xiaomi/mimo-v2.5-pro/ // Note: temperature is omitted because MiMo forces it to 1.0 when thinking mode // is enabled, regardless of what is passed (see model-hyperparameters docs). - const params: Record = { + const params: MiMoCompletionParams = { model: modelId, messages: [{ role: "system", content: systemPrompt }, ...convertedMessages], stream: true, @@ -95,31 +224,63 @@ export class MimoHandler extends OpenAiHandler { } if (tools && tools.length > 0) { - params.tools = tools + params.tools = this.convertToolsForOpenAI(tools) + } + + // Honor tool_choice from metadata (OpenAI-compatible passthrough) + if (metadata?.tool_choice !== undefined) { + params.tool_choice = metadata.tool_choice + } + + // Send parallel_tool_calls based on resolved metadata policy. + // Sub-task 1's resolver sets parallelToolCalls=false for MiMo to + // prevent malformed parallel tool calls from MiMo v2.5 Pro. + if (metadata?.parallelToolCalls !== undefined) { + params.parallel_tool_calls = metadata.parallelToolCalls } let stream: AsyncIterable try { - stream = (await this.client.chat.completions.create(params as any)) as any + stream = await this.client.chat.completions.create(params) } catch (error) { - throw handleProviderError(error, "MiMo") + // Fallback: if the endpoint rejects the parallel_tool_calls field, + // retry once without it. Some OpenAI-compatible endpoints don't + // support this field and return a 400 Bad Request. + if (params.parallel_tool_calls !== undefined && isParallelToolCallsRejected(error)) { + const { parallel_tool_calls: _omit, ...paramsWithoutParallel } = params + stream = await this.client.chat.completions.create(paramsWithoutParallel as MiMoCompletionParams) + } else if (params.tools !== undefined && isStrictToolSchemaRejected(error)) { + // Fallback: if the endpoint rejects the strict tool flag or a + // hardened strict-mode schema, retry once with the original + // schemas and no strict flag. Build a new params object so the + // rejected request is left untouched. + const paramsWithoutStrict = { ...params, tools: stripStrictFromTools(tools ?? []) } + stream = await this.client.chat.completions.create(paramsWithoutStrict) + } else { + throw handleProviderError(error, "MiMo") + } } let lastUsage: OpenAI.CompletionUsage | undefined const activeToolCallIds = new Set() + const firstCallState: { firstToolCallId: string | undefined; droppedIndexes: Set } = { + firstToolCallId: undefined, + droppedIndexes: new Set(), + } for await (const chunk of stream) { const delta = chunk.choices?.[0]?.delta ?? {} const finishReason = chunk.choices?.[0]?.finish_reason - const sanitizedDelta = delta.tool_calls + const filteredDelta = filterToFirstToolCall(delta, firstCallState) + const sanitizedDelta = filteredDelta.tool_calls ? { - ...delta, - tool_calls: delta.tool_calls.map((toolCall) => ({ + ...filteredDelta, + tool_calls: filteredDelta.tool_calls.map((toolCall) => ({ ...toolCall, id: toolCall.id ? sanitizeOpenAiCallId(toolCall.id) : toolCall.id, })), } - : delta + : filteredDelta if (delta.content) { yield { @@ -143,7 +304,9 @@ export class MimoHandler extends OpenAiHandler { if (lastUsage) { const inputTokens = lastUsage?.prompt_tokens || 0 const outputTokens = lastUsage?.completion_tokens || 0 - const cacheWriteTokens = (lastUsage?.prompt_tokens_details as any)?.cache_write_tokens || 0 + const cacheWriteTokens = + (lastUsage?.prompt_tokens_details as { cache_write_tokens?: number } | undefined)?.cache_write_tokens || + 0 const cacheReadTokens = lastUsage?.prompt_tokens_details?.cached_tokens || 0 const { totalCost } = calculateApiCostOpenAI( diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index c7816feaa2..4b5b48d2f8 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -15,6 +15,7 @@ import { ApiHandlerOptions } from "../../shared/api" import { convertToMistralMessages } from "../transform/mistral-format" import { ApiStream } from "../transform/stream" +import { calculateApiCostOpenAI } from "../../shared/cost" import { handleProviderError } from "./utils/error-handler" import { BaseProvider } from "./base-provider" @@ -155,10 +156,17 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand } if (event.data.usage) { + const inputTokens = event.data.usage.promptTokens || 0 + const outputTokens = event.data.usage.completionTokens || 0 + const { totalCost } = info + ? calculateApiCostOpenAI(info, inputTokens, outputTokens, 0, 0) + : { totalCost: 0 } + yield { type: "usage", - inputTokens: event.data.usage.promptTokens || 0, - outputTokens: event.data.usage.completionTokens || 0, + inputTokens, + outputTokens, + totalCost, } } } diff --git a/src/core/assistant-message/NativeToolCallParser.ts b/src/core/assistant-message/NativeToolCallParser.ts index 9639ae1baa..4057ecb6c9 100644 --- a/src/core/assistant-message/NativeToolCallParser.ts +++ b/src/core/assistant-message/NativeToolCallParser.ts @@ -38,6 +38,32 @@ type NativeArgsFor = TName extends keyof NativeToolArgs */ export type ToolCallStreamEvent = ApiStreamToolCallStartChunk | ApiStreamToolCallDeltaChunk | ApiStreamToolCallEndChunk +/** + * Discriminated union for parser failure kinds. + * + * - `json_syntax`: The arguments string could not be parsed as JSON. + * - `missing_required_arguments`: The JSON was valid but one or more required + * fields were absent (including the empty-object case). + * - `invalid_argument_shape`: The JSON was valid and required field names were + * present, but the structural shape did not match the tool schema (e.g. a + * field had the wrong type or the value could not be coerced). + */ +export type ParserFailureKind = "json_syntax" | "missing_required_arguments" | "invalid_argument_shape" + +/** + * Typed, sanitized descriptor for a parser failure. + * + * IMPORTANT: This descriptor MUST NOT contain raw argument bodies, file paths, + * commands, task IDs, or secrets. It carries only structural facts needed for + * error classification and model guidance. + */ +export interface NativeToolParseFailure { + kind: ParserFailureKind + toolName?: string + missingParameters?: string[] // Known missing required field names from parser's tool contract + emptyArguments?: boolean // true if the input was {} or "" +} + /** * Parser for native tool calls (OpenAI-style function calling). * Converts native tool call format to ToolUse format for compatibility @@ -73,6 +99,118 @@ export class NativeToolCallParser { } >() + /** + * Stores JSON.parse error messages keyed by tool call ID. + * When parseToolCall() catches a JSON.parse failure, it records the error + * message here so it can be retrieved later via {@link consumeParseError} + * / {@link hasParseError} (currently exercised by tests and diagnostics; + * no production consumer exists). Entries persist until consumed or until + * {@link clearParseFailures} runs at the start of the next API request. + * + * @deprecated Use {@link parseFailures} and {@link consumeParseFailure} for + * typed failure descriptors. This legacy string map is retained only as a + * compatibility wrapper for human diagnostics. + */ + private static parseErrors = new Map() + + /** + * Stores typed parser failure descriptors keyed by tool call ID. + * When parseToolCall() catches any failure (JSON syntax, missing required + * arguments, or invalid argument shape), it records a typed descriptor here + * so downstream consumers can classify the failure precisely instead of + * relying on raw error strings. Entries persist until consumed via + * {@link consumeParseFailure} or until {@link clearParseFailures} runs at + * the start of the next API request. + */ + private static parseFailures = new Map() + + /** + * Required parameter names for each native tool, derived from + * {@link NativeToolArgs}. Used to classify missing-required-arguments + * failures with precise field names. + */ + private static readonly REQUIRED_PARAMETERS: Record = { + access_mcp_resource: ["server_name", "uri"], + read_file: ["path"], + read_command_output: ["artifact_id"], + attempt_completion: ["result"], + execute_command: ["command"], + apply_diff: ["path", "diff"], + edit: ["file_path", "old_string", "new_string"], + search_and_replace: ["file_path", "old_string", "new_string"], + search_replace: ["file_path", "old_string", "new_string"], + edit_file: ["file_path", "old_string", "new_string"], + apply_patch: ["patch"], + list_files: ["path"], + new_task: ["mode", "message"], + ask_followup_question: ["question", "follow_up"], + codebase_search: ["query"], + generate_image: ["prompt", "path"], + run_slash_command: ["command"], + skill: ["skill"], + search_files: ["path", "regex"], + switch_mode: ["mode_slug", "reason"], + update_todo_list: ["todos"], + use_mcp_tool: ["server_name", "tool_name"], + write_to_file: ["path", "content"], + } + + /** + * Retrieve and remove the typed parse failure descriptor for a given tool + * call ID. Returns undefined if no failure was recorded or if it was + * already consumed. + * + * Atomic consume-and-delete, matching the lifecycle of the legacy + * {@link consumeParseError} string side channel. + */ + public static consumeParseFailure(toolCallId: string): NativeToolParseFailure | undefined { + const failure = NativeToolCallParser.parseFailures.get(toolCallId) + if (failure !== undefined) { + NativeToolCallParser.parseFailures.delete(toolCallId) + } + return failure + } + + /** + * Retrieve and remove the parse error for a given tool call ID. + * Returns undefined if no parse error was recorded. + * + * @deprecated Compatibility wrapper. New production code should use + * {@link consumeParseFailure} for typed failure descriptors. This method + * returns the string representation for human diagnostics only. + */ + public static consumeParseError(toolCallId: string): string | undefined { + const error = NativeToolCallParser.parseErrors.get(toolCallId) + if (error !== undefined) { + NativeToolCallParser.parseErrors.delete(toolCallId) + } + return error + } + + /** + * Check whether a parse error was recorded for a given tool call ID + * without consuming it. + */ + public static hasParseError(toolCallId: string): boolean { + return NativeToolCallParser.parseErrors.has(toolCallId) + } + + /** + * Clear all recorded parse failures — both the typed {@link parseFailures} + * descriptors and the legacy {@link parseErrors} strings. + * + * Called alongside {@link clearAllStreamingToolCalls} / + * {@link clearRawChunkState} when a new API request starts (see + * Task.recursivelyMakeClineRequests), so failures recorded by an + * interrupted or completed stream do not accumulate for the lifetime of + * the extension host. The consume* APIs keep working for per-call + * retrieval; this clears everything still unconsumed. + */ + public static clearParseFailures(): void { + NativeToolCallParser.parseFailures.clear() + NativeToolCallParser.parseErrors.clear() + } + private static coerceOptionalBoolean(value: unknown): boolean | undefined { if (typeof value === "boolean") { return value @@ -225,6 +363,45 @@ export class NativeToolCallParser { }) } + /** + * Get the current state of a streaming tool call. + * + * Returns a snapshot object or undefined if the ID is not being tracked. + */ + public static getStreamingToolCallState(id: string): + | { + id: string + name: string + argumentsAccumulator: string + } + | undefined { + const entry = this.streamingToolCalls.get(id) + if (!entry) { + return undefined + } + return { + id: entry.id, + name: entry.name, + argumentsAccumulator: entry.argumentsAccumulator, + } + } + + /** + * Discard a streaming tool call's state without finalizing it. + * + * This is used by the ghost quarantine path: when a call is classified as + * `drop-provably-empty` (no name, no arguments, stream ended), its + * streaming state is removed so it never becomes a `tool_use` block in + * `assistantMessageContent` and never receives a `tool_result`. + * + * This is the ONLY safe way to remove a call before history insertion. + * Once a `tool_use` block is pushed into `assistantMessageContent`, it + * MUST receive exactly one matching `tool_result`. + */ + public static discardStreamingToolCall(id: string): boolean { + return this.streamingToolCalls.delete(id) + } + /** * Clear all streaming tool call state. * Should be called when a new API request starts to prevent memory leaks @@ -1003,11 +1180,43 @@ export class NativeToolCallParser { // Native-only: core tools must always have typed nativeArgs. // If we couldn't construct it, the model produced an invalid tool call payload. if (!nativeArgs && !customToolRegistry.has(resolvedName)) { - throw new Error( - `[NativeToolCallParser] Invalid arguments for tool '${resolvedName}'. ` + - `Native tool calls require a valid JSON payload matching the tool schema. ` + - `Received: ${JSON.stringify(args)}`, - ) + // Classify the failure precisely so the catch block can store a + // typed descriptor instead of a raw error string. + // + // If args is not a plain object (e.g. a primitive, array, or null), + // the structural shape is fundamentally wrong. + const isPlainObject = typeof args === "object" && args !== null && !Array.isArray(args) + + if (!isPlainObject) { + throw { + __parserFailureKind: "invalid_argument_shape" as const, + toolName: resolvedName as string, + missingParameters: [], + emptyArguments: false, + } + } + + const required = NativeToolCallParser.REQUIRED_PARAMETERS[resolvedName as string] ?? [] + const missing = required.filter((p) => args[p] === undefined) + const isEmpty = Object.keys(args).length === 0 + + if (missing.length > 0) { + throw { + __parserFailureKind: "missing_required_arguments" as const, + toolName: resolvedName as string, + missingParameters: missing, + emptyArguments: isEmpty, + } + } + + // Required fields are present but the structural shape didn't match + // any known pattern in the switch above. + throw { + __parserFailureKind: "invalid_argument_shape" as const, + toolName: resolvedName as string, + missingParameters: [], + emptyArguments: isEmpty, + } } const result: ToolUse = { @@ -1030,15 +1239,67 @@ export class NativeToolCallParser { return result } catch (error) { - console.error( - `Failed to parse tool call arguments: ${error instanceof Error ? error.message : String(error)}`, - ) + // Determine whether this is a JSON.parse syntax failure or a + // post-parse structural failure (missing required arguments or + // invalid argument shape). The structural failures are thrown as + // tagged objects with __parserFailureKind; JSON.parse failures are + // standard SyntaxError instances. + const failure = NativeToolCallParser.classifyParseFailure(error, resolvedName as string) + + const errorMessage = error instanceof Error ? error.message : String(error) + + console.error(`Failed to parse tool call arguments: ${errorMessage}`) console.error(`Tool call: ${JSON.stringify(toolCall, null, 2)}`) + + // Store the legacy string error for backward compatibility with + // existing callers of consumeParseError(). + NativeToolCallParser.parseErrors.set(toolCall.id, errorMessage) + + // Store the typed failure descriptor for new callers that use + // consumeParseFailure(). + NativeToolCallParser.parseFailures.set(toolCall.id, failure) + return null } } + /** + * Classify a caught error from parseToolCall() into a typed + * {@link NativeToolParseFailure} descriptor. + * + * - If the error is a tagged object with `__parserFailureKind`, it was + * thrown by the structural validation logic and carries precise metadata. + * - Otherwise, the error originated from JSON.parse (a SyntaxError) and is + * classified as `json_syntax`. + */ + private static classifyParseFailure(error: unknown, toolName: string): NativeToolParseFailure { + // Check for tagged structural failure objects thrown by the validation + // logic above. These are not Error instances — they are plain objects + // with a __parserFailureKind discriminator. + if (typeof error === "object" && error !== null && "__parserFailureKind" in error) { + const tagged = error as { + __parserFailureKind: ParserFailureKind + toolName?: string + missingParameters?: string[] + emptyArguments?: boolean + } + return { + kind: tagged.__parserFailureKind, + toolName: tagged.toolName ?? toolName, + missingParameters: tagged.missingParameters, + emptyArguments: tagged.emptyArguments, + } + } + + // Any other error (SyntaxError from JSON.parse, or unexpected runtime + // error) is classified as a JSON syntax failure. + return { + kind: "json_syntax", + toolName, + } + } + /** * Parse dynamic MCP tools (named mcp--serverName--toolName). * These are generated dynamically by getMcpServerTools() and are returned diff --git a/src/core/assistant-message/ToolCallRetentionPolicy.ts b/src/core/assistant-message/ToolCallRetentionPolicy.ts new file mode 100644 index 0000000000..91d8e5d612 --- /dev/null +++ b/src/core/assistant-message/ToolCallRetentionPolicy.ts @@ -0,0 +1,310 @@ +import { TelemetryService } from "@roo-code/telemetry" + +import type { NativeToolParseFailure } from "./NativeToolCallParser" + +/** + * # Tool Call Retention Policy + * + * Pure functions for classifying streamed tool calls and enforcing per-turn + * call-count limits. These functions are intentionally side-effect-free so + * they can be unit-tested in isolation and composed into the stream-processing + * and presentation pipelines without hidden state. + * + * ## Ghost Quarantine + * + * A "ghost" is a streamed tool call that arrived with a unique stream index/ID + * but never resolved a tool name and never accumulated any non-whitespace + * argument bytes. Such calls are transport artifacts, not model intent, and + * can be silently dropped **before** they are inserted into + * `assistantMessageContent` or conversation history. + * + * A call with a resolved name (even if arguments are `{}`) is NOT a ghost — + * it is a malformed named call that must receive a `tool_result`. + * A call with any argument bytes (even without a name) is NOT a ghost — it + * carries partial model intent and must be retained. + * + * ## Max-One Enforcement + * + * When the resolved tool-call policy sets `maxCallsPerTurn === 1`, at most + * one structurally valid call may execute per assistant turn. If two or more + * valid side-effecting calls arrive, neither auto-executes — both receive + * error results instructing the model to resubmit a single call. This prevents + * ambiguous side-effect ordering when a provider violates the single-call + * contract. + */ + +/** + * Discriminated union describing the disposition of a single streamed tool + * call after stream completion. + * + * - `retain`: The call is structurally valid and may proceed to execution. + * - `drop-provably-empty`: The call is a transport ghost (no name, no args) + * and must be silently removed before history insertion. + * - `retain-as-error`: The call is named or has argument bytes but is + * malformed; it must receive exactly one error `tool_result`. + */ +export type StreamedCallDisposition = + | { kind: "retain"; callId: string } + | { kind: "drop-provably-empty"; callId: string; reason: "no-name-and-no-arguments" } + | { kind: "retain-as-error"; callId: string; failure: NativeToolParseFailure } + +/** + * Input for {@link classifyStreamedCall}. + */ +export interface ClassifyStreamedCallInput { + /** The tool call identifier from the stream. */ + callId: string + /** The resolved tool name, or empty/undefined if none arrived. */ + toolName: string | undefined + /** The full accumulated argument string at stream completion. */ + argumentsAccumulator: string + /** Whether the stream has ended for this call. Ghosts can only be dropped after stream end. */ + streamEnded: boolean + /** Optional typed parse failure if the parser already classified this call. */ + parseFailure?: NativeToolParseFailure +} + +/** + * Classify a streamed tool call into its disposition. + * + * **Drop criteria (all must hold):** + * 1. `streamEnded` is true. + * 2. `toolName` is empty, undefined, or whitespace-only. + * 3. `argumentsAccumulator` is empty or whitespace-only. + * + * If a {@link NativeToolParseFailure} is present, the call is retained as an + * error (it was named or had argument bytes but failed structural validation). + * + * Otherwise the call is retained for normal execution. + */ +export function classifyStreamedCall(input: ClassifyStreamedCallInput): StreamedCallDisposition { + const { callId, toolName, argumentsAccumulator, streamEnded, parseFailure } = input + + // If the parser already recorded a failure, the call had enough structure + // to be classified — it is NOT a ghost. Retain it as an error. + if (parseFailure) { + return { kind: "retain-as-error", callId, failure: parseFailure } + } + + // Ghost check: only drop after stream completion, and only when there is + // no resolved name AND no non-whitespace argument bytes. + const hasName = toolName !== undefined && toolName.trim().length > 0 + const hasArgs = argumentsAccumulator.trim().length > 0 + + if (streamEnded && !hasName && !hasArgs) { + return { + kind: "drop-provably-empty", + callId, + reason: "no-name-and-no-arguments", + } + } + + return { kind: "retain", callId } +} + +/** + * Predicate: true when the disposition is a silent ghost drop. + */ +export function isProvablyEmptyGhost(disposition: StreamedCallDisposition): boolean { + return disposition.kind === "drop-provably-empty" +} + +/** + * Input for {@link selectExecutableCall}. + */ +export interface SelectExecutableCallInput { + /** All tool calls in the current assistant turn. */ + calls: Array<{ + /** The tool call identifier. */ + callId: string + /** The resolved tool name (may be empty for ghosts). */ + toolName: string | undefined + /** Whether the parser successfully constructed `nativeArgs`. */ + hasNativeArgs: boolean + /** Whether the block is still partial (streaming in progress). */ + isPartial: boolean + }> + /** The resolved max-calls-per-turn limit. */ + maxCallsPerTurn: 1 | "unbounded" +} + +/** + * Result of max-one enforcement selection. + */ +export interface SelectExecutableCallResult { + /** The call ID that may proceed to execution, or undefined if none. */ + executableCallId: string | undefined + /** Call IDs that must receive error results instead of executing. */ + rejectedCallIds: string[] + /** Human-readable reason for the selection (for error messages / telemetry). */ + reason: string +} + +/** + * Under a single-call policy (`maxCallsPerTurn === 1`), select at most one + * structurally valid call for execution. + * + * Rules: + * - Only non-partial calls with `hasNativeArgs === true` are candidates. + * - If zero candidates: no call executes (existing error handling covers + * malformed calls). + * - If exactly one candidate: it may execute. + * - If two or more candidates: **neither auto-executes**. All candidates + * receive error results instructing the model to resubmit one call. + * This prevents ambiguous side-effect ordering. + * + * Under an unbounded policy, all valid calls may execute (returns the first + * valid call ID with no rejections — the caller processes the rest normally). + */ +export function selectExecutableCall(input: SelectExecutableCallInput): SelectExecutableCallResult { + const { calls, maxCallsPerTurn } = input + + if (maxCallsPerTurn === "unbounded") { + // Parallel-capable providers: no local enforcement needed. + const firstValid = calls.find((c) => c.hasNativeArgs && !c.isPartial) + return { + executableCallId: firstValid?.callId, + rejectedCallIds: [], + reason: "unbounded-policy", + } + } + + // Single-call policy: collect all structurally valid, non-partial calls. + const validCandidates = calls.filter((c) => c.hasNativeArgs && !c.isPartial) + + if (validCandidates.length === 0) { + return { + executableCallId: undefined, + rejectedCallIds: [], + reason: "no-valid-candidates", + } + } + + if (validCandidates.length === 1) { + return { + executableCallId: validCandidates[0].callId, + rejectedCallIds: [], + reason: "single-valid-candidate", + } + } + + // Two or more valid candidates under single-call policy: + // execute NEITHER automatically. All receive error results. + return { + executableCallId: undefined, + rejectedCallIds: validCandidates.map((c) => c.callId), + reason: "multiple-valid-calls-under-single-policy", + } +} + +/** + * Input for {@link emitGhostDropTelemetry}. + */ +export interface GhostDropTelemetryInput { + /** The task identifier. */ + taskId: string + /** The provider name (e.g. "mimo", "openai"). */ + provider: string + /** The model ID. */ + model: string + /** The resolved policy source. */ + policySource: string + /** The resolved max-calls-per-turn limit. */ + maxCallsPerTurn: 1 | "unbounded" + /** The resolved enforcement mode. */ + enforcement: string + /** Total tool calls in the turn (including the ghost). */ + callCount: number + /** How many ghosts were dropped so far in this turn. */ + ghostDroppedCount: number + /** How many error results were emitted so far in this turn. */ + errorResultCount: number + /** What the metadata requested for parallel tool calls. */ + parallelToolCallsRequested: boolean + /** What was sent to the provider (if known). */ + parallelToolCallsSent?: boolean +} + +/** + * Emit a tool-call enforcement telemetry event for a ghost quarantine drop. + * + * **Privacy:** This function emits ONLY counts and metadata. It does NOT + * emit the call ID, tool name, argument bytes, command strings, file paths, + * or any raw user data. The ghost's identity is intentionally discarded. + * + * This is safe to call from the stream-processing hot path because + * `TelemetryService.captureEvent` is fire-and-forget (it returns void and + * queues internally). + */ +export function emitGhostDropTelemetry(input: GhostDropTelemetryInput): void { + if (!TelemetryService.hasInstance()) { + return + } + + TelemetryService.instance.captureToolCallEnforcement(input.taskId, { + provider: input.provider, + model: input.model, + policySource: input.policySource, + maxCallsPerTurn: input.maxCallsPerTurn, + enforcement: input.enforcement, + callCount: input.callCount, + ghostDroppedCount: input.ghostDroppedCount, + errorResultCount: input.errorResultCount, + parallelToolCallsRequested: input.parallelToolCallsRequested, + parallelToolCallsSent: input.parallelToolCallsSent, + }) +} + +/** + * Input for {@link emitMaxOneEnforcementTelemetry}. + */ +export interface MaxOneEnforcementTelemetryInput { + /** The task identifier. */ + taskId: string + /** The provider name. */ + provider: string + /** The model ID. */ + model: string + /** The resolved policy source. */ + policySource: string + /** The resolved max-calls-per-turn limit. */ + maxCallsPerTurn: 1 | "unbounded" + /** The resolved enforcement mode. */ + enforcement: string + /** Total tool calls in the turn. */ + callCount: number + /** How many ghosts were dropped in this turn. */ + ghostDroppedCount: number + /** How many error results were emitted in this turn (including this one). */ + errorResultCount: number + /** What the metadata requested for parallel tool calls. */ + parallelToolCallsRequested: boolean + /** What was sent to the provider (if known). */ + parallelToolCallsSent?: boolean +} + +/** + * Emit a tool-call enforcement telemetry event for a max-one rejection. + * + * **Privacy:** This function emits ONLY counts and metadata. It does NOT + * emit the call ID, tool name, argument values, command strings, file paths, + * or any raw user data. + */ +export function emitMaxOneEnforcementTelemetry(input: MaxOneEnforcementTelemetryInput): void { + if (!TelemetryService.hasInstance()) { + return + } + + TelemetryService.instance.captureToolCallEnforcement(input.taskId, { + provider: input.provider, + model: input.model, + policySource: input.policySource, + maxCallsPerTurn: input.maxCallsPerTurn, + enforcement: input.enforcement, + callCount: input.callCount, + ghostDroppedCount: input.ghostDroppedCount, + errorResultCount: input.errorResultCount, + parallelToolCallsRequested: input.parallelToolCallsRequested, + parallelToolCallsSent: input.parallelToolCallsSent, + }) +} diff --git a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts index 2c15e12069..7008f08d3b 100644 --- a/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts +++ b/src/core/assistant-message/__tests__/NativeToolCallParser.spec.ts @@ -4,6 +4,7 @@ describe("NativeToolCallParser", () => { beforeEach(() => { NativeToolCallParser.clearAllStreamingToolCalls() NativeToolCallParser.clearRawChunkState() + NativeToolCallParser.clearParseFailures() }) describe("parseToolCall", () => { @@ -343,4 +344,70 @@ describe("NativeToolCallParser", () => { }) }) }) + + describe("parse failure lifecycle", () => { + it("records a failure on malformed JSON and empties both maps via clearParseFailures", () => { + const result = NativeToolCallParser.parseToolCall({ + id: "call_bad_json", + name: "read_file", + arguments: "{not valid json", + }) + + expect(result).toBeNull() + expect(NativeToolCallParser.hasParseError("call_bad_json")).toBe(true) + + // This is what Task.recursivelyMakeClineRequests invokes when a new + // API request starts — the maps must not outlive the stream. + NativeToolCallParser.clearParseFailures() + + expect(NativeToolCallParser.hasParseError("call_bad_json")).toBe(false) + expect(NativeToolCallParser.consumeParseError("call_bad_json")).toBeUndefined() + expect(NativeToolCallParser.consumeParseFailure("call_bad_json")).toBeUndefined() + }) + + it("clears structural failures (not just JSON syntax failures) via clearParseFailures", () => { + // Valid JSON, but missing the required "path" argument. + const result = NativeToolCallParser.parseToolCall({ + id: "call_missing_args", + name: "read_file", + arguments: "{}", + }) + + expect(result).toBeNull() + expect(NativeToolCallParser.consumeParseFailure("call_missing_args")).toBeDefined() + + // Record another failure and clear everything unconsumed. + NativeToolCallParser.parseToolCall({ + id: "call_missing_args_2", + name: "write_to_file", + arguments: "{}", + }) + + NativeToolCallParser.clearParseFailures() + + expect(NativeToolCallParser.hasParseError("call_missing_args")).toBe(false) + expect(NativeToolCallParser.hasParseError("call_missing_args_2")).toBe(false) + expect(NativeToolCallParser.consumeParseFailure("call_missing_args_2")).toBeUndefined() + }) + + it("keeps the consume* API working for recorded failures", () => { + NativeToolCallParser.parseToolCall({ + id: "call_consume", + name: "read_file", + arguments: "{}", + }) + + const failure = NativeToolCallParser.consumeParseFailure("call_consume") + expect(failure).toBeDefined() + expect(failure?.kind).toBe("missing_required_arguments") + expect(failure?.missingParameters).toEqual(["path"]) + + // Consume is atomic — a second read returns undefined. + expect(NativeToolCallParser.consumeParseFailure("call_consume")).toBeUndefined() + + // The legacy string side channel is independent and still available. + expect(NativeToolCallParser.consumeParseError("call_consume")).toBeDefined() + expect(NativeToolCallParser.consumeParseError("call_consume")).toBeUndefined() + }) + }) }) diff --git a/src/core/assistant-message/__tests__/ToolCallRetentionPolicy-telemetry.spec.ts b/src/core/assistant-message/__tests__/ToolCallRetentionPolicy-telemetry.spec.ts new file mode 100644 index 0000000000..061a2bdb47 --- /dev/null +++ b/src/core/assistant-message/__tests__/ToolCallRetentionPolicy-telemetry.spec.ts @@ -0,0 +1,238 @@ +// npx vitest run core/assistant-message/__tests__/ToolCallRetentionPolicy-telemetry.spec.ts + +import { describe, it, expect, beforeEach, vi } from "vitest" +import type { Mock } from "vitest" + +// Mock TelemetryService before importing the module under test. +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn(() => true), + instance: { + captureToolCallPolicyResolution: vi.fn(), + captureToolCallEnforcement: vi.fn(), + }, + }, +})) + +import { TelemetryService } from "@roo-code/telemetry" +import { + emitGhostDropTelemetry, + emitMaxOneEnforcementTelemetry, +} from "../ToolCallRetentionPolicy" + +const mockCaptureToolCallEnforcement = TelemetryService.instance.captureToolCallEnforcement as unknown as Mock +const mockHasInstance = TelemetryService.hasInstance as unknown as Mock + +describe("Tool-call policy telemetry helpers", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("emitGhostDropTelemetry", () => { + it("calls captureToolCallEnforcement with counts and metadata only", () => { + emitGhostDropTelemetry({ + taskId: "task-001", + provider: "mimo", + model: "mimo-v2.5-pro", + policySource: "model-capability", + maxCallsPerTurn: 1, + enforcement: "provider-and-local", + callCount: 2, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: false, + }) + + expect(TelemetryService.instance.captureToolCallEnforcement).toHaveBeenCalledTimes(1) + const args = mockCaptureToolCallEnforcement.mock.calls[0] + expect(args[0]).toBe("task-001") + expect(args[1]).toEqual({ + provider: "mimo", + model: "mimo-v2.5-pro", + policySource: "model-capability", + maxCallsPerTurn: 1, + enforcement: "provider-and-local", + callCount: 2, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: false, + }) + }) + + it("does NOT include call ID, tool name, arguments, commands, or paths", () => { + emitGhostDropTelemetry({ + taskId: "task-002", + provider: "mimo", + model: "mimo-v2.5-pro", + policySource: "model-capability", + maxCallsPerTurn: 1, + enforcement: "local", + callCount: 1, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: false, + }) + + const args = mockCaptureToolCallEnforcement.mock.calls[0][1] as Record + // Verify no raw data fields are present + expect(args).not.toHaveProperty("callId") + expect(args).not.toHaveProperty("toolName") + expect(args).not.toHaveProperty("arguments") + expect(args).not.toHaveProperty("command") + expect(args).not.toHaveProperty("cwd") + expect(args).not.toHaveProperty("path") + expect(args).not.toHaveProperty("fileContent") + expect(args).not.toHaveProperty("apiKey") + expect(args).not.toHaveProperty("token") + }) + + it("includes parallelToolCallsSent when provided", () => { + emitGhostDropTelemetry({ + taskId: "task-003", + provider: "openai", + model: "gpt-4", + policySource: "model-capability", + maxCallsPerTurn: "unbounded", + enforcement: "provider", + callCount: 3, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: true, + parallelToolCallsSent: true, + }) + + const args = mockCaptureToolCallEnforcement.mock.calls[0][1] as Record + expect(args.parallelToolCallsSent).toBe(true) + }) + + it("skips emission when TelemetryService has no instance", () => { + mockHasInstance.mockReturnValueOnce(false) + emitGhostDropTelemetry({ + taskId: "task-004", + provider: "mimo", + model: "mimo-v2.5-pro", + policySource: "model-capability", + maxCallsPerTurn: 1, + enforcement: "local", + callCount: 1, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: false, + }) + + expect(TelemetryService.instance.captureToolCallEnforcement).not.toHaveBeenCalled() + }) + }) + + describe("emitMaxOneEnforcementTelemetry", () => { + it("calls captureToolCallEnforcement with rejection counts", () => { + emitMaxOneEnforcementTelemetry({ + taskId: "task-005", + provider: "mimo", + model: "mimo-v2.5-pro", + policySource: "model-capability", + maxCallsPerTurn: 1, + enforcement: "provider-and-local", + callCount: 2, + ghostDroppedCount: 0, + errorResultCount: 2, + parallelToolCallsRequested: false, + }) + + expect(TelemetryService.instance.captureToolCallEnforcement).toHaveBeenCalledTimes(1) + const args = mockCaptureToolCallEnforcement.mock.calls[0] + expect(args[0]).toBe("task-005") + expect(args[1]).toEqual({ + provider: "mimo", + model: "mimo-v2.5-pro", + policySource: "model-capability", + maxCallsPerTurn: 1, + enforcement: "provider-and-local", + callCount: 2, + ghostDroppedCount: 0, + errorResultCount: 2, + parallelToolCallsRequested: false, + }) + }) + + it("does NOT include call ID, tool name, arguments, commands, or paths", () => { + emitMaxOneEnforcementTelemetry({ + taskId: "task-006", + provider: "mimo", + model: "mimo-v2.5-pro", + policySource: "model-capability", + maxCallsPerTurn: 1, + enforcement: "local", + callCount: 2, + ghostDroppedCount: 0, + errorResultCount: 2, + parallelToolCallsRequested: false, + }) + + const args = mockCaptureToolCallEnforcement.mock.calls[0][1] as Record + expect(args).not.toHaveProperty("callId") + expect(args).not.toHaveProperty("toolName") + expect(args).not.toHaveProperty("arguments") + expect(args).not.toHaveProperty("command") + expect(args).not.toHaveProperty("cwd") + expect(args).not.toHaveProperty("path") + expect(args).not.toHaveProperty("fileContent") + expect(args).not.toHaveProperty("apiKey") + expect(args).not.toHaveProperty("token") + }) + + it("skips emission when TelemetryService has no instance", () => { + mockHasInstance.mockReturnValueOnce(false) + emitMaxOneEnforcementTelemetry({ + taskId: "task-007", + provider: "mimo", + model: "mimo-v2.5-pro", + policySource: "model-capability", + maxCallsPerTurn: 1, + enforcement: "local", + callCount: 2, + ghostDroppedCount: 0, + errorResultCount: 2, + parallelToolCallsRequested: false, + }) + + expect(TelemetryService.instance.captureToolCallEnforcement).not.toHaveBeenCalled() + }) + }) + + describe("privacy verification — cardinality bounds", () => { + it("telemetry properties only contain allowed metadata keys", () => { + const allowedKeys = new Set([ + "taskId", + "provider", + "model", + "policySource", + "maxCallsPerTurn", + "enforcement", + "callCount", + "ghostDroppedCount", + "errorResultCount", + "parallelToolCallsRequested", + "parallelToolCallsSent", + ]) + + emitGhostDropTelemetry({ + taskId: "task-priv-001", + provider: "mimo", + model: "mimo-v2.5-pro", + policySource: "model-capability", + maxCallsPerTurn: 1, + enforcement: "local", + callCount: 1, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: false, + }) + + const args = mockCaptureToolCallEnforcement.mock.calls[0][1] as Record + for (const key of Object.keys(args)) { + expect(allowedKeys.has(key)).toBe(true) + } + }) + }) +}) diff --git a/src/core/assistant-message/__tests__/ToolCallRetentionPolicy.spec.ts b/src/core/assistant-message/__tests__/ToolCallRetentionPolicy.spec.ts new file mode 100644 index 0000000000..1f402ea63f --- /dev/null +++ b/src/core/assistant-message/__tests__/ToolCallRetentionPolicy.spec.ts @@ -0,0 +1,342 @@ +// npx vitest core/assistant-message/__tests__/ToolCallRetentionPolicy.spec.ts + +import { describe, it, expect } from "vitest" + +import type { NativeToolParseFailure } from "../NativeToolCallParser" +import { + classifyStreamedCall, + isProvablyEmptyGhost, + selectExecutableCall, + type StreamedCallDisposition, +} from "../ToolCallRetentionPolicy" + +describe("ToolCallRetentionPolicy", () => { + describe("classifyStreamedCall", () => { + it("drops a call with no name and no arguments after stream end", () => { + const disposition = classifyStreamedCall({ + callId: "call_ghost_001", + toolName: "", + argumentsAccumulator: "", + streamEnded: true, + }) + + expect(disposition.kind).toBe("drop-provably-empty") + if (disposition.kind === "drop-provably-empty") { + expect(disposition.callId).toBe("call_ghost_001") + expect(disposition.reason).toBe("no-name-and-no-arguments") + } + }) + + it("drops a call with whitespace-only name and whitespace-only arguments", () => { + const disposition = classifyStreamedCall({ + callId: "call_ghost_002", + toolName: " ", + argumentsAccumulator: " \n\t ", + streamEnded: true, + }) + + expect(disposition.kind).toBe("drop-provably-empty") + }) + + it("drops a call with undefined name and empty arguments", () => { + const disposition = classifyStreamedCall({ + callId: "call_ghost_003", + toolName: undefined, + argumentsAccumulator: "", + streamEnded: true, + }) + + expect(disposition.kind).toBe("drop-provably-empty") + }) + + it("does NOT drop when stream has not ended (even if name and args are empty)", () => { + const disposition = classifyStreamedCall({ + callId: "call_streaming_004", + toolName: "", + argumentsAccumulator: "", + streamEnded: false, + }) + + expect(disposition.kind).toBe("retain") + }) + + it("retains a named call even with empty arguments (not a ghost)", () => { + const disposition = classifyStreamedCall({ + callId: "call_named_empty_005", + toolName: "search_files", + argumentsAccumulator: "{}", + streamEnded: true, + }) + + // A named call with {} is a malformed named call, NOT a ghost. + expect(disposition.kind).toBe("retain") + }) + + it("retains a call with argument bytes even without a name", () => { + const disposition = classifyStreamedCall({ + callId: "call_args_no_name_006", + toolName: "", + argumentsAccumulator: '{"path":"src"}', + streamEnded: true, + }) + + // Has argument bytes → carries partial model intent → NOT a ghost. + expect(disposition.kind).toBe("retain") + }) + + it("retains as error when a parse failure is present", () => { + const failure: NativeToolParseFailure = { + kind: "json_syntax", + } + + const disposition = classifyStreamedCall({ + callId: "call_parse_failure_007", + toolName: "search_files", + argumentsAccumulator: '{"path":"src" broken}', + streamEnded: true, + parseFailure: failure, + }) + + expect(disposition.kind).toBe("retain-as-error") + if (disposition.kind === "retain-as-error") { + expect(disposition.callId).toBe("call_parse_failure_007") + expect(disposition.failure).toBe(failure) + } + }) + + it("retains as error when parse failure is present even without a name", () => { + const failure: NativeToolParseFailure = { + kind: "missing_required_arguments", + emptyArguments: true, + } + + const disposition = classifyStreamedCall({ + callId: "call_failure_no_name_008", + toolName: "", + argumentsAccumulator: "", + streamEnded: true, + parseFailure: failure, + }) + + // If the parser already classified a failure, the call had enough + // structure to be classified — it is NOT a ghost. + expect(disposition.kind).toBe("retain-as-error") + }) + }) + + describe("isProvablyEmptyGhost", () => { + it("returns true for drop-provably-empty disposition", () => { + const disposition: StreamedCallDisposition = { + kind: "drop-provably-empty", + callId: "call_009", + reason: "no-name-and-no-arguments", + } + + expect(isProvablyEmptyGhost(disposition)).toBe(true) + }) + + it("returns false for retain disposition", () => { + const disposition: StreamedCallDisposition = { + kind: "retain", + callId: "call_010", + } + + expect(isProvablyEmptyGhost(disposition)).toBe(false) + }) + + it("returns false for retain-as-error disposition", () => { + const disposition: StreamedCallDisposition = { + kind: "retain-as-error", + callId: "call_011", + failure: { kind: "json_syntax" }, + } + + expect(isProvablyEmptyGhost(disposition)).toBe(false) + }) + }) + + describe("selectExecutableCall", () => { + it("selects the single valid candidate under single-call policy", () => { + const result = selectExecutableCall({ + calls: [ + { + callId: "call_valid_012", + toolName: "search_files", + hasNativeArgs: true, + isPartial: false, + }, + ], + maxCallsPerTurn: 1, + }) + + expect(result.executableCallId).toBe("call_valid_012") + expect(result.rejectedCallIds).toEqual([]) + expect(result.reason).toBe("single-valid-candidate") + }) + + it("rejects all valid candidates when two arrive under single-call policy", () => { + const result = selectExecutableCall({ + calls: [ + { + callId: "call_valid_a_013", + toolName: "search_files", + hasNativeArgs: true, + isPartial: false, + }, + { + callId: "call_valid_b_013", + toolName: "read_file", + hasNativeArgs: true, + isPartial: false, + }, + ], + maxCallsPerTurn: 1, + }) + + expect(result.executableCallId).toBeUndefined() + expect(result.rejectedCallIds).toContain("call_valid_a_013") + expect(result.rejectedCallIds).toContain("call_valid_b_013") + expect(result.reason).toBe("multiple-valid-calls-under-single-policy") + }) + + it("selects the valid call when first is malformed and second is valid", () => { + const result = selectExecutableCall({ + calls: [ + { + callId: "call_malformed_014", + toolName: "search_files", + hasNativeArgs: false, + isPartial: false, + }, + { + callId: "call_valid_014", + toolName: "search_files", + hasNativeArgs: true, + isPartial: false, + }, + ], + maxCallsPerTurn: 1, + }) + + // Only one valid candidate → it may execute. + expect(result.executableCallId).toBe("call_valid_014") + expect(result.rejectedCallIds).toEqual([]) + }) + + it("selects the valid call when first is valid and second is malformed", () => { + const result = selectExecutableCall({ + calls: [ + { + callId: "call_valid_015", + toolName: "search_files", + hasNativeArgs: true, + isPartial: false, + }, + { + callId: "call_malformed_015", + toolName: "search_files", + hasNativeArgs: false, + isPartial: false, + }, + ], + maxCallsPerTurn: 1, + }) + + expect(result.executableCallId).toBe("call_valid_015") + expect(result.rejectedCallIds).toEqual([]) + }) + + it("returns no executable when no valid candidates exist", () => { + const result = selectExecutableCall({ + calls: [ + { + callId: "call_malformed_016", + toolName: "search_files", + hasNativeArgs: false, + isPartial: false, + }, + ], + maxCallsPerTurn: 1, + }) + + expect(result.executableCallId).toBeUndefined() + expect(result.rejectedCallIds).toEqual([]) + expect(result.reason).toBe("no-valid-candidates") + }) + + it("ignores partial calls when selecting under single-call policy", () => { + const result = selectExecutableCall({ + calls: [ + { + callId: "call_partial_017", + toolName: "search_files", + hasNativeArgs: true, + isPartial: true, + }, + ], + maxCallsPerTurn: 1, + }) + + // Partial calls are not candidates. + expect(result.executableCallId).toBeUndefined() + expect(result.reason).toBe("no-valid-candidates") + }) + + it("returns first valid call under unbounded policy with no rejections", () => { + const result = selectExecutableCall({ + calls: [ + { + callId: "call_valid_a_018", + toolName: "search_files", + hasNativeArgs: true, + isPartial: false, + }, + { + callId: "call_valid_b_018", + toolName: "read_file", + hasNativeArgs: true, + isPartial: false, + }, + ], + maxCallsPerTurn: "unbounded", + }) + + // Unbounded policy: no local enforcement, all valid calls may execute. + expect(result.executableCallId).toBe("call_valid_a_018") + expect(result.rejectedCallIds).toEqual([]) + expect(result.reason).toBe("unbounded-policy") + }) + + it("rejects three valid calls under single-call policy", () => { + const result = selectExecutableCall({ + calls: [ + { + callId: "call_a_019", + toolName: "search_files", + hasNativeArgs: true, + isPartial: false, + }, + { + callId: "call_b_019", + toolName: "read_file", + hasNativeArgs: true, + isPartial: false, + }, + { + callId: "call_c_019", + toolName: "list_files", + hasNativeArgs: true, + isPartial: false, + }, + ], + maxCallsPerTurn: 1, + }) + + expect(result.executableCallId).toBeUndefined() + expect(result.rejectedCallIds).toHaveLength(3) + expect(result.rejectedCallIds).toContain("call_a_019") + expect(result.rejectedCallIds).toContain("call_b_019") + expect(result.rejectedCallIds).toContain("call_c_019") + }) + }) +}) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts index 7838553835..1ef25e852b 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-custom-tool.spec.ts @@ -23,6 +23,17 @@ vi.mock("@roo-code/core", () => ({ }, })) +// presentAssistantMessage records tool usage through TelemetryService.instance. +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + captureEvent: vi.fn(), + }, + }, +})) + import { customToolRegistry } from "@roo-code/core" describe("presentAssistantMessage - Custom Tool Recording", () => { diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts new file mode 100644 index 0000000000..c75eb6ee18 --- /dev/null +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts @@ -0,0 +1,319 @@ +// npx vitest src/core/assistant-message/__tests__/presentAssistantMessage-tool-usage-attribution.spec.ts + +import type { Anthropic } from "@anthropic-ai/sdk" +import { describe, it, expect, beforeEach, vi } from "vitest" +import { presentAssistantMessage } from "../presentAssistantMessage" +import { validateToolUse } from "../../tools/validateToolUse" +import { getModeBySlug } from "../../../shared/modes" +import type { Task } from "../../task/Task" + +vi.mock("../../task/Task") +vi.mock("../../../shared/modes", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getModeBySlug: vi.fn(actual.getModeBySlug), + } +}) +// isValidToolName is left as the real implementation (only validateToolUse is +// mocked): it has its own independent mcp_ prefix carve-out, and a hand-rolled +// mock allowlist here would mask a regression in toTelemetryToolName's +// ordering relative to isValidToolName. +vi.mock("../../tools/validateToolUse", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + validateToolUse: vi.fn(), + } +}) + +vi.mock("@roo-code/core", () => ({ + customToolRegistry: { + has: vi.fn(() => false), + get: vi.fn(), + }, +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + captureEvent: vi.fn(), + }, + }, +})) + +import { TelemetryService } from "@roo-code/telemetry" + +interface MockTask { + taskId: string + instanceId: string + abort: boolean + presentAssistantMessageLocked: boolean + presentAssistantMessageHasPendingUpdates: boolean + currentStreamingContentIndex: number + assistantMessageContent: unknown[] + userMessageContent: Anthropic.ToolResultBlockParam[] + didCompleteReadingStream: boolean + didRejectTool: boolean + didAlreadyUseTool: boolean + consecutiveMistakeCount: number + clineMessages: unknown[] + api: { getModel: () => { id: string; info: Record } } + recordToolUsage: ReturnType + recordToolError: ReturnType + toolRepetitionDetector: { check: ReturnType } + providerRef: { + deref: () => { + getState: ReturnType + getMcpHub?: () => { findServerNameBySanitizedName: (name: string) => string | undefined } + } + } + say: ReturnType + ask: ReturnType + pushToolResultToUserContent: ReturnType +} + +describe("presentAssistantMessage - tool usage attribution", () => { + let mockTask: MockTask + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(validateToolUse).mockImplementation(() => undefined) + + mockTask = { + taskId: "test-task-id", + instanceId: "test-instance", + abort: false, + presentAssistantMessageLocked: false, + presentAssistantMessageHasPendingUpdates: false, + currentStreamingContentIndex: 0, + assistantMessageContent: [], + userMessageContent: [], + didCompleteReadingStream: false, + didRejectTool: false, + didAlreadyUseTool: false, + consecutiveMistakeCount: 0, + clineMessages: [], + api: { + getModel: () => ({ id: "test-model", info: {} }), + }, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + toolRepetitionDetector: { + check: vi.fn().mockReturnValue({ allowExecution: true }), + }, + providerRef: { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + }), + }, + say: vi.fn().mockResolvedValue(undefined), + ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked" }), + pushToolResultToUserContent: vi.fn(), + } + + mockTask.pushToolResultToUserContent = vi + .fn() + .mockImplementation((toolResult: Anthropic.ToolResultBlockParam) => { + const existingResult = mockTask.userMessageContent.find( + (block) => block.type === "tool_result" && block.tool_use_id === toolResult.tool_use_id, + ) + if (existingResult) { + return false + } + mockTask.userMessageContent.push(toolResult) + return true + }) + }) + + it("records exactly one attempt for a normal static tool", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_1", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.recordToolUsage).toHaveBeenCalledTimes(1) + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("read_file") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledTimes(1) + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "read_file") + }) + + it("records a valid dynamic mcp_ tool name as use_mcp_tool", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_mcp", + name: "mcp_my_server_do_thing", + params: {}, + nativeArgs: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "use_mcp_tool") + }) + + it("records a malformed mcp_ tool name as use_mcp_tool, not the raw name", async () => { + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_mcp_bad", + name: "mcp_", + params: {}, + nativeArgs: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool") + expect(mockTask.recordToolUsage).not.toHaveBeenCalledWith("mcp_") + }) + + it("records a safe failure key without leaking the raw tool name when validation fails", async () => { + vi.mocked(validateToolUse).mockImplementation(() => { + throw new Error('Tool "read_file" is not allowed in this mode.') + }) + + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_bad_mode", + name: "read_file", + params: { path: "test.txt" }, + nativeArgs: { path: "test.txt" }, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + // A known static tool that fails validation still maps to its own name + // (it's a real, recognized tool - just disallowed here), never left raw/unmapped. + expect(mockTask.recordToolError).toHaveBeenCalledWith("read_file", expect.any(String)) + // No success attempt should be recorded for a validation failure. + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + }) + + it("records invalid_tool_call, not the raw name, when an arbitrary unknown tool fails validation", async () => { + vi.mocked(validateToolUse).mockImplementation(() => { + throw new Error('Unknown tool "totally_made_up_tool". This tool does not exist.') + }) + + mockTask.assistantMessageContent = [ + { + type: "tool_use", + id: "call_unknown", + name: "totally_made_up_tool", + params: {}, + nativeArgs: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.recordToolError).toHaveBeenCalledWith("invalid_tool_call", expect.any(String)) + expect(mockTask.recordToolError).not.toHaveBeenCalledWith("totally_made_up_tool", expect.anything()) + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + }) + + describe("native mcp_tool_use block", () => { + it("records exactly one attempt once the MCP tool's own validation passes", async () => { + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + getMcpHub: () => ({ + findServerNameBySanitizedName: () => "my_server", + getAllServers: () => [ + { + name: "my_server", + tools: [{ name: "do_thing", enabledForPrompt: true }], + }, + ], + }), + }), + } + + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp", + name: "mcp_my_server_do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + expect(mockTask.recordToolUsage).toHaveBeenCalledTimes(1) + expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledTimes(1) + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "use_mcp_tool") + }) + + it("records no attempt when the MCP server is not on the mode's allow-list", async () => { + vi.mocked(getModeBySlug).mockReturnValueOnce({ + slug: "code", + name: "Code", + roleDefinition: "", + groups: [], + allowedMcpServers: ["some-other-server"], + }) + + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + mode: "code", + customModes: [], + }), + getMcpHub: () => ({ + findServerNameBySanitizedName: () => "my_server", + }), + }), + } + + mockTask.assistantMessageContent = [ + { + type: "mcp_tool_use", + id: "call_native_mcp_disallowed", + name: "mcp_my_server_do_thing", + serverName: "my_server", + toolName: "do_thing", + arguments: {}, + partial: false, + }, + ] + + await presentAssistantMessage(mockTask as unknown as Task) + + // The server is disallowed, so the call never reaches onValidated: + // no success attempt is recorded for a call that was never permitted to execute. + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + expect(TelemetryService.instance.captureToolUsage).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts index 8e6c8d9d9e..819cfffcd9 100644 --- a/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts +++ b/src/core/assistant-message/__tests__/presentAssistantMessage-unknown-tool.spec.ts @@ -101,9 +101,10 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // Verify consecutiveMistakeCount was incremented expect(mockTask.consecutiveMistakeCount).toBe(1) - // Verify recordToolError was called + // Verify recordToolError was called with a safe static key, never the + // raw model-controlled tool name. expect(mockTask.recordToolError).toHaveBeenCalledWith( - "nonexistent_tool", + "invalid_tool_call", expect.stringContaining("Unknown tool"), ) @@ -135,8 +136,9 @@ describe("presentAssistantMessage - Unknown Tool Handling", () => { // Verify consecutiveMistakeCount was incremented expect(mockTask.consecutiveMistakeCount).toBe(1) - // Verify recordToolError was called - expect(mockTask.recordToolError).toHaveBeenCalled() + // Verify recordToolError was called with a safe static key, never the + // raw model-reported tool name ("fake_tool_that_does_not_exist"). + expect(mockTask.recordToolError).toHaveBeenCalledWith("invalid_tool_call", expect.anything()) // Verify error message was shown to user expect(mockTask.say).toHaveBeenCalledWith("error", expect.anything()) diff --git a/src/core/assistant-message/__tests__/toTelemetryToolName.spec.ts b/src/core/assistant-message/__tests__/toTelemetryToolName.spec.ts new file mode 100644 index 0000000000..699cececdd --- /dev/null +++ b/src/core/assistant-message/__tests__/toTelemetryToolName.spec.ts @@ -0,0 +1,54 @@ +// npx vitest src/core/assistant-message/__tests__/toTelemetryToolName.spec.ts + +import { describe, it, expect, vi } from "vitest" + +// Only validateToolUse itself needs mocking (unused by toTelemetryToolName, +// but imported by the same module). isValidToolName is left as the real +// implementation: it has its own independent mcp_ prefix carve-out, and a +// hand-rolled mock allowlist here would mask a regression where +// toTelemetryToolName's ordering relative to isValidToolName changes. +vi.mock("../../tools/validateToolUse", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + validateToolUse: vi.fn(), + } +}) + +import { toTelemetryToolName } from "../presentAssistantMessage" + +describe("toTelemetryToolName", () => { + it("maps a known static tool to its own name", () => { + expect(toTelemetryToolName("read_file", false, undefined)).toBe("read_file") + }) + + it("maps a registered custom tool to custom_tool", () => { + expect(toTelemetryToolName("my_custom_tool", true, undefined)).toBe("custom_tool") + }) + + it("maps a valid dynamic mcp_ tool name to use_mcp_tool", () => { + expect(toTelemetryToolName("mcp_my_server_do_thing", false, undefined)).toBe("use_mcp_tool") + }) + + it("maps a malformed mcp_ tool name to use_mcp_tool", () => { + expect(toTelemetryToolName("mcp_", false, undefined)).toBe("use_mcp_tool") + }) + + it("maps an arbitrary unknown tool name to invalid_tool_call", () => { + expect(toTelemetryToolName("drop_table_users", false, undefined)).toBe("invalid_tool_call") + }) + + it("never returns the raw name for an unrecognized tool", () => { + const raw = "'; DROP TABLE users; --" + const result = toTelemetryToolName(raw, false, undefined) + expect(result).not.toBe(raw) + expect(result).toBe("invalid_tool_call") + }) + + it("maps mcp_ names to use_mcp_tool against the real isValidToolName, not a mock allowlist", () => { + // isValidToolName is unmocked in this file (see the vi.mock factory above), + // so this exercises the real mcp_ prefix carve-out ordering rather than one + // a hand-rolled mock could silently keep agreeing with after a regression. + expect(toTelemetryToolName("mcp_my_server_do_thing", false, undefined)).toBe("use_mcp_tool") + }) +}) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 12a5bfb4a2..7383a7a35a 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -41,6 +41,31 @@ import { codebaseSearchTool } from "../tools/CodebaseSearchTool" import { formatResponse } from "../prompts/responses" import { sanitizeToolUseId } from "../../utils/tool-id" +/** + * Maps a raw, potentially model-controlled tool name to a safe analytics key. + * Never returns the raw name unless it is a known static tool, so an + * arbitrary model-supplied string can never become a `toolsUsed` property key. + */ +export function toTelemetryToolName( + toolName: string, + isCustomTool: boolean, + experiments?: Record, +): ToolName { + if (isCustomTool) { + return "custom_tool" + } + + if (toolName.startsWith("mcp_")) { + return "use_mcp_tool" + } + + if (isValidToolName(toolName, experiments)) { + return toolName + } + + return "invalid_tool_call" +} + /** * Processes and presents assistant message content to the user interface. * @@ -233,10 +258,6 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult(formatResponse.toolError(errorString)) } - if (!mcpBlock.partial) { - cline.recordToolUsage("use_mcp_tool") // Record as use_mcp_tool for analytics - } - // Resolve sanitized server name back to original server name // The serverName from parsing is sanitized (e.g., "my_server" from "my server") // We need the original name to find the actual MCP connection @@ -272,6 +293,12 @@ export async function presentAssistantMessage(cline: Task) { askApproval, handleError, pushToolResult, + onValidated: mcpBlock.partial + ? undefined + : () => { + cline.recordToolUsage("use_mcp_tool") + TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool") + }, }) break } @@ -301,14 +328,10 @@ export async function presentAssistantMessage(cline: Task) { if (!toolCallId) { const errorMessage = "Invalid tool call: missing tool_use.id. XML tool calls are no longer supported. Remove any XML tool markup (e.g. ...) and use native tool calling instead." - // Record a tool error for visibility/telemetry. Use the reported tool name if present. + // Record a safe, static analytics key. Never key telemetry on the + // model-reported tool name, which is untrusted here. try { - if ( - typeof (cline as any).recordToolError === "function" && - typeof (block as any).name === "string" - ) { - ;(cline as any).recordToolError((block as any).name as ToolName, errorMessage) - } + cline.recordToolError("invalid_tool_call", errorMessage) } catch { // Best-effort only } @@ -424,7 +447,7 @@ export async function presentAssistantMessage(cline: Task) { cline.consecutiveMistakeCount++ try { - cline.recordToolError(block.name as ToolName, errorMessage) + cline.recordToolError(toTelemetryToolName(block.name, false, stateExperiments), errorMessage) } catch { // Best-effort only } @@ -552,22 +575,6 @@ export async function presentAssistantMessage(cline: Task) { pushToolResult(formatResponse.toolError(errorString)) } - if (!block.partial) { - // Check if this is a custom tool - if so, record as "custom_tool" (like MCP tools) - const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.name) - const recordName = isCustomTool ? "custom_tool" : block.name - cline.recordToolUsage(recordName) - - // Track legacy format usage for read_file tool (for migration monitoring) - if (block.name === "read_file" && block.usedLegacyFormat) { - const modelInfo = cline.api.getModel() - TelemetryService.instance.captureEvent(TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, { - taskId: cline.taskId, - model: modelInfo?.id, - }) - } - } - // Validate tool use before execution - ONLY for complete (non-partial) blocks. // Validating partial blocks would cause validation errors to be thrown repeatedly // during streaming, pushing multiple tool_results for the same tool_use_id and @@ -580,6 +587,8 @@ export async function presentAssistantMessage(cline: Task) { const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode") const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) + const isCustomTool = Boolean(stateExperiments?.customTools && customToolRegistry.has(block.name)) + try { const toolRequirements = disabledTools?.reduce( @@ -617,8 +626,30 @@ export async function presentAssistantMessage(cline: Task) { is_error: true, }) + // Record a safe failure key. Never key telemetry on the raw, + // model-controlled tool name. + cline.recordToolError( + toTelemetryToolName(block.name, isCustomTool, stateExperiments), + error.message, + ) + break } + + // Validation passed: record exactly one attempt at this single + // central point. Individual tool handlers must not also record + // usage, or the attempt would be double-counted. + const recordName = toTelemetryToolName(block.name, isCustomTool, stateExperiments) + cline.recordToolUsage(recordName) + TelemetryService.instance.captureToolUsage(cline.taskId, recordName) + + // Track legacy format usage for read_file tool (for migration monitoring) + if (block.name === "read_file" && block.usedLegacyFormat) { + TelemetryService.instance.captureEvent(TelemetryEventName.READ_FILE_LEGACY_FORMAT_USED, { + taskId: cline.taskId, + model: modelInfo?.id, + }) + } } // Check for identical consecutive tool calls. @@ -901,7 +932,7 @@ export async function presentAssistantMessage(cline: Task) { // Not a custom tool - handle as unknown tool error const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.` cline.consecutiveMistakeCount++ - cline.recordToolError(block.name as ToolName, errorMessage) + cline.recordToolError("invalid_tool_call", errorMessage) await cline.say("error", t("tools:unknownToolError", { toolName: block.name })) // Push tool_result directly WITHOUT setting didAlreadyUseTool // This prevents the stream from being interrupted with "Response interrupted by tool use result" diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 0a24141155..551591b0ed 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -4,6 +4,9 @@ import * as vscode from "vscode" import { GLOBAL_STATE_KEYS, SECRET_STATE_KEYS, GLOBAL_SECRET_KEYS } from "@roo-code/types" +import { clearAllMocks } from "../../../test-utils/reset" +import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" + import { ContextProxy } from "../ContextProxy" vi.mock("vscode", () => ({ @@ -25,7 +28,7 @@ describe("ContextProxy", () => { beforeEach(async () => { // Reset mocks - vi.clearAllMocks() + clearAllMocks() // Mock globalState mockGlobalState = { @@ -42,14 +45,16 @@ describe("ContextProxy", () => { // Mock the extension context mockContext = { - globalState: mockGlobalState, - secrets: mockSecrets, - extensionUri: { path: "/test/extension" }, - extensionPath: "/test/extension", - globalStorageUri: { path: "/test/storage" }, - logUri: { path: "/test/logs" }, + ...makeExtensionContext({ + globalState: mockGlobalState, + secrets: mockSecrets, + extensionUri: makeUri("/test/extension"), + extensionPath: "/test/extension", + extensionMode: vscode.ExtensionMode.Development, + }), + globalStorageUri: makeUri("/test/storage"), + logUri: makeUri("/test/logs"), extension: { packageJSON: { version: "1.0.0" } }, - extensionMode: vscode.ExtensionMode.Development, } // Create proxy instance @@ -436,7 +441,7 @@ describe("ContextProxy", () => { describe("invalid apiProvider migration", () => { it("should clear Roo provider state during initialization", async () => { - vi.clearAllMocks() + clearAllMocks() mockGlobalState.get.mockImplementation((key: string) => { if (key === "apiProvider") { return "roo" @@ -460,7 +465,7 @@ describe("ContextProxy", () => { it("should clear invalid apiProvider from storage during initialization", async () => { // Reset and create a new proxy with invalid provider in state - vi.clearAllMocks() + clearAllMocks() mockGlobalState.get.mockImplementation((key: string) => { if (key === "apiProvider") { return "invalid-removed-provider" // Invalid/removed provider @@ -477,7 +482,7 @@ describe("ContextProxy", () => { it("should not clear retired apiProvider from storage during initialization", async () => { // Reset and create a new proxy with retired provider in state - vi.clearAllMocks() + clearAllMocks() mockGlobalState.get.mockImplementation((key: string) => { if (key === "apiProvider") { return "groq" // Retired provider @@ -496,7 +501,7 @@ describe("ContextProxy", () => { it("should not modify valid apiProvider during initialization", async () => { // Reset and create a new proxy with valid provider in state - vi.clearAllMocks() + clearAllMocks() mockGlobalState.get.mockImplementation((key: string) => { if (key === "apiProvider") { return "anthropic" // Valid provider @@ -517,7 +522,7 @@ describe("ContextProxy", () => { describe("getProviderSettings", () => { it("should sanitize invalid apiProvider before parsing", async () => { // Reset and create a new proxy with an unknown provider in state - vi.clearAllMocks() + clearAllMocks() mockGlobalState.get.mockImplementation((key: string) => { if (key === "apiProvider") { return "invalid-removed-provider" @@ -620,7 +625,7 @@ Output only the summary of the conversation so far, without any additional comme it("should clear old v1 default condensing prompt from customSupportPrompts during initialization", async () => { // Reset and create a new proxy with old v1 default prompt in customSupportPrompts - vi.clearAllMocks() + clearAllMocks() mockGlobalState.get.mockImplementation((key: string) => { if (key === "customSupportPrompts") { return { CONDENSE: OLD_V1_DEFAULT_CONDENSE_PROMPT } @@ -638,7 +643,7 @@ Output only the summary of the conversation so far, without any additional comme it("should preserve other custom prompts when clearing old v1 default", async () => { // Reset and create a new proxy with old v1 default plus other custom prompts - vi.clearAllMocks() + clearAllMocks() mockGlobalState.get.mockImplementation((key: string) => { if (key === "customSupportPrompts") { return { @@ -660,7 +665,7 @@ Output only the summary of the conversation so far, without any additional comme it("should not clear truly customized condensing prompts", async () => { // Reset and create a new proxy with a truly customized condensing prompt - vi.clearAllMocks() + clearAllMocks() const customPrompt = "My custom condensing instructions" mockGlobalState.get.mockImplementation((key: string) => { if (key === "customSupportPrompts") { @@ -682,7 +687,7 @@ Output only the summary of the conversation so far, without any additional comme it("should not fail when customSupportPrompts is undefined", async () => { // Reset and create a new proxy with no customSupportPrompts - vi.clearAllMocks() + clearAllMocks() mockGlobalState.get.mockReturnValue(undefined) const proxyWithNoPrompts = new ContextProxy(mockContext) diff --git a/src/core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts b/src/core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts index 3c9655ed08..b141006c85 100644 --- a/src/core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts +++ b/src/core/config/__tests__/CustomModesManager.exportImportSlugChange.spec.ts @@ -13,6 +13,8 @@ import type { ModeConfig } from "@roo-code/types" import { fileExistsAtPath } from "../../../utils/fs" import { getWorkspacePath } from "../../../utils/path" import { GlobalFileNames } from "../../../shared/globalFileNames" +import { clearAllMocks } from "../../../test-utils/reset" +import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" import { CustomModesManager } from "../CustomModesManager" @@ -53,20 +55,11 @@ describe("CustomModesManager - Export/Import with Slug Changes", () => { beforeEach(() => { mockOnUpdate = vi.fn() - mockContext = { - globalState: { - get: vi.fn(), - update: vi.fn(), - keys: vi.fn(() => []), - setKeysForSync: vi.fn(), - }, - globalStorageUri: { - fsPath: mockStoragePath, - }, - } as unknown as vscode.ExtensionContext + mockContext = makeExtensionContext({ globalStorageUri: makeUri(mockStoragePath) }) + mockContext.globalState.setKeysForSync = vi.fn() // mockWorkspacePath is now defined at the top level - mockWorkspaceFolders = [{ uri: { fsPath: mockWorkspacePath } }] + mockWorkspaceFolders = [{ uri: makeUri(mockWorkspacePath) }] ;(vscode.workspace as any).workspaceFolders = mockWorkspaceFolders ;(vscode.workspace.onDidSaveTextDocument as Mock).mockReturnValue({ dispose: vi.fn() }) ;(getWorkspacePath as Mock).mockReturnValue(mockWorkspacePath) @@ -90,7 +83,7 @@ describe("CustomModesManager - Export/Import with Slug Changes", () => { }) afterEach(() => { - vi.clearAllMocks() + clearAllMocks() }) describe("Export Path Calculation", () => { diff --git a/src/core/config/__tests__/CustomModesManager.spec.ts b/src/core/config/__tests__/CustomModesManager.spec.ts index 775ae64489..71ed60ee40 100644 --- a/src/core/config/__tests__/CustomModesManager.spec.ts +++ b/src/core/config/__tests__/CustomModesManager.spec.ts @@ -13,6 +13,8 @@ import type { ModeConfig } from "@roo-code/types" import { fileExistsAtPath } from "../../../utils/fs" import { getWorkspacePath, arePathsEqual } from "../../../utils/path" import { GlobalFileNames } from "../../../shared/globalFileNames" +import { clearAllMocks } from "../../../test-utils/reset" +import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" import { CustomModesManager } from "../CustomModesManager" @@ -53,20 +55,11 @@ describe("CustomModesManager", () => { beforeEach(() => { mockOnUpdate = vi.fn() - mockContext = { - globalState: { - get: vi.fn(), - update: vi.fn(), - keys: vi.fn(() => []), - setKeysForSync: vi.fn(), - }, - globalStorageUri: { - fsPath: mockStoragePath, - }, - } as unknown as vscode.ExtensionContext + mockContext = makeExtensionContext({ globalStorageUri: makeUri(mockStoragePath) }) + mockContext.globalState.setKeysForSync = vi.fn() // mockWorkspacePath is now defined at the top level - mockWorkspaceFolders = [{ uri: { fsPath: mockWorkspacePath } }] + mockWorkspaceFolders = [{ uri: makeUri(mockWorkspacePath) }] ;(vscode.workspace as any).workspaceFolders = mockWorkspaceFolders ;(vscode.workspace.onDidSaveTextDocument as Mock).mockReturnValue({ dispose: vi.fn() }) ;(getWorkspacePath as Mock).mockReturnValue(mockWorkspacePath) @@ -90,7 +83,7 @@ describe("CustomModesManager", () => { }) afterEach(() => { - vi.clearAllMocks() + clearAllMocks() }) describe("getCustomModes", () => { diff --git a/src/core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts b/src/core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts index cad28ef94c..eab01eee41 100644 --- a/src/core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts +++ b/src/core/config/__tests__/CustomModesManager.yamlEdgeCases.spec.ts @@ -13,6 +13,8 @@ import type { ModeConfig } from "@roo-code/types" import { fileExistsAtPath } from "../../../utils/fs" import { getWorkspacePath } from "../../../utils/path" import { GlobalFileNames } from "../../../shared/globalFileNames" +import { clearAllMocks } from "../../../test-utils/reset" +import { makeExtensionContext, makeUri } from "../../../test-utils/vscode" import { CustomModesManager } from "../CustomModesManager" @@ -52,19 +54,10 @@ describe("CustomModesManager - YAML Edge Cases", () => { beforeEach(() => { mockOnUpdate = vi.fn() - mockContext = { - globalState: { - get: vi.fn(), - update: vi.fn(), - keys: vi.fn(() => []), - setKeysForSync: vi.fn(), - }, - globalStorageUri: { - fsPath: mockStoragePath, - }, - } as unknown as vscode.ExtensionContext - - mockWorkspaceFolders = [{ uri: { fsPath: "/mock/workspace" } }] + mockContext = makeExtensionContext({ globalStorageUri: makeUri(mockStoragePath) }) + mockContext.globalState.setKeysForSync = vi.fn() + + mockWorkspaceFolders = [{ uri: makeUri("/mock/workspace") }] ;(vscode.workspace as any).workspaceFolders = mockWorkspaceFolders ;(vscode.workspace.onDidSaveTextDocument as Mock).mockReturnValue({ dispose: vi.fn() }) ;(getWorkspacePath as Mock).mockReturnValue("/mock/workspace") @@ -92,7 +85,7 @@ describe("CustomModesManager - YAML Edge Cases", () => { }) afterEach(() => { - vi.clearAllMocks() + clearAllMocks() }) describe("BOM (Byte Order Mark) handling", () => { diff --git a/src/core/config/__tests__/CustomModesSettings.spec.ts b/src/core/config/__tests__/CustomModesSettings.spec.ts index 186ef5aeba..b8e52963a1 100644 --- a/src/core/config/__tests__/CustomModesSettings.spec.ts +++ b/src/core/config/__tests__/CustomModesSettings.spec.ts @@ -51,7 +51,7 @@ describe("CustomModesSettings", () => { }) it("rejects missing customModes field", () => { - const invalidSettings = {} as any + const invalidSettings = {} expect(() => { customModesSettingsSchema.parse(invalidSettings) @@ -115,7 +115,7 @@ describe("CustomModesSettings", () => { customModes: [ { ...validMode, - groups: ["invalid_group"] as any, + groups: ["invalid_group"], }, ], } diff --git a/src/core/config/__tests__/ModeConfig.spec.ts b/src/core/config/__tests__/ModeConfig.spec.ts index 74cbc0c437..20379d751d 100644 --- a/src/core/config/__tests__/ModeConfig.spec.ts +++ b/src/core/config/__tests__/ModeConfig.spec.ts @@ -95,7 +95,7 @@ describe("CustomModeSchema", () => { slug: "123e4567-e89b-12d3-a456-426614174000", name: "Test Mode", roleDefinition: "Test role definition", - groups: ["not-a-valid-group"] as any, + groups: ["not-a-valid-group"], } expect(() => validateCustomMode(invalidGroupMode)).toThrow(ZodError) @@ -209,7 +209,7 @@ describe("CustomModeSchema", () => { test("rejects non-array group format", () => { const mode = { ...validBaseMode, - groups: "not-an-array" as any, + groups: "not-an-array", } expect(() => modeConfigSchema.parse(mode)).toThrow() @@ -218,7 +218,7 @@ describe("CustomModeSchema", () => { test("rejects invalid group names", () => { const mode = { ...validBaseMode, - groups: ["invalid_group"] as any, + groups: ["invalid_group"], } expect(() => modeConfigSchema.parse(mode)).toThrow() @@ -227,7 +227,7 @@ describe("CustomModeSchema", () => { test("rejects duplicate groups", () => { const mode = { ...validBaseMode, - groups: ["read", "read"] as any, + groups: ["read", "read"], } expect(() => modeConfigSchema.parse(mode)).toThrow("Duplicate groups are not allowed") @@ -236,12 +236,12 @@ describe("CustomModeSchema", () => { test("rejects null or undefined groups", () => { const modeWithNull = { ...validBaseMode, - groups: null as any, + groups: null, } const modeWithUndefined = { ...validBaseMode, - groups: undefined as any, + groups: undefined, } expect(() => modeConfigSchema.parse(modeWithNull)).toThrow() diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index 491ef3b18a..56d4a6951b 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -1,7 +1,5 @@ // npx vitest src/core/config/__tests__/ProviderSettingsManager.spec.ts -import { ExtensionContext } from "vscode" - import { OPEN_AI_CODEX_SERVICE_TIER_KEY, OpenAiCodexServiceTier, @@ -9,6 +7,9 @@ import { type ProviderSettings, } from "@roo-code/types" +import { clearAllMocks } from "../../../test-utils/reset" +import { makeExtensionContext } from "../../../test-utils/vscode" + import { ProviderSettingsManager, ProviderProfiles, SyncCloudProfilesResult } from "../ProviderSettingsManager" // `export()` builds an API handler per profile to read model capabilities. Mock @@ -38,7 +39,6 @@ vi.mock("../../../api", async () => { } }) -// Mock VSCode ExtensionContext const mockSecrets = { get: vi.fn(), store: vi.fn(), @@ -50,16 +50,17 @@ const mockGlobalState = { update: vi.fn(), } -const mockContext = { - secrets: mockSecrets, - globalState: mockGlobalState, -} as unknown as ExtensionContext +const baseContext = makeExtensionContext() +const mockContext = makeExtensionContext({ + secrets: { ...baseContext.secrets, ...mockSecrets }, + globalState: { ...baseContext.globalState, ...mockGlobalState }, +}) describe("ProviderSettingsManager", () => { let providerSettingsManager: ProviderSettingsManager beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() // Reset all mock implementations to default successful behavior mockSecrets.get.mockResolvedValue(null) mockSecrets.store.mockResolvedValue(undefined) diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 313183c795..94cbc934cf 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -8,6 +8,9 @@ import * as vscode from "vscode" import type { ProviderName } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" +import { clearAllMocks } from "../../../test-utils/reset" +import { makeExtensionContext } from "../../../test-utils/vscode" + import { importSettings, importSettingsFromFile, importSettingsWithFeedback, exportSettings } from "../importExport" import { ProviderSettingsManager } from "../ProviderSettingsManager" import { ContextProxy } from "../ContextProxy" @@ -97,11 +100,11 @@ vi.mock("../../../api", () => ({ describe("importExport", () => { let mockProviderSettingsManager: ReturnType> let mockContextProxy: ReturnType> - let mockExtensionContext: ReturnType> + let mockExtensionContext: vscode.ExtensionContext let mockCustomModesManager: ReturnType> beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() if (!TelemetryService.hasInstance()) { TelemetryService.createInstance([]) @@ -129,8 +132,12 @@ describe("importExport", () => { const map = new Map() - mockExtensionContext = { + // Secrets are Map-backed so real ProviderSettingsManager instances can + // round-trip configs; the rest of the context comes from the shared builder. + const baseContext = makeExtensionContext() + mockExtensionContext = makeExtensionContext({ secrets: { + ...baseContext.secrets, get: vi.fn().mockImplementation((key: string) => { return map.get(key) }), @@ -138,7 +145,7 @@ describe("importExport", () => { return map.set(key, value) }), }, - } as unknown as ReturnType> + }) }) describe("importSettings", () => { @@ -2030,7 +2037,7 @@ describe("importExport", () => { ;(fs.readFile as Mock).mockResolvedValue(exportedFileContent) // Reset mocks for import - vi.clearAllMocks() + clearAllMocks() mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, @@ -2118,7 +2125,7 @@ describe("importExport", () => { ;(fs.readFile as Mock).mockResolvedValue(exportedFileContent) // Reset mocks for import - vi.clearAllMocks() + clearAllMocks() mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "default", apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" } }, diff --git a/src/core/prompts/tools/native-tools/execute_command.ts b/src/core/prompts/tools/native-tools/execute_command.ts index 68c68dc5fd..2d0987c80e 100644 --- a/src/core/prompts/tools/native-tools/execute_command.ts +++ b/src/core/prompts/tools/native-tools/execute_command.ts @@ -21,7 +21,7 @@ Example: Running a build with a timeout const COMMAND_PARAMETER_DESCRIPTION = `Shell command to execute` -const CWD_PARAMETER_DESCRIPTION = `Optional working directory for the command, relative or absolute` +const CWD_PARAMETER_DESCRIPTION = `Optional working directory for the command, relative or absolute. Must be a string when provided; omit to use the default workspace directory.` const TIMEOUT_PARAMETER_DESCRIPTION = `Timeout in seconds. When exceeded, the command continues running in the background and output collected so far is returned. Use this for long-running processes like dev servers, file watchers, or any command that may not exit on its own` diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f55078b6ff..2674138ea4 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -60,7 +60,7 @@ import { TelemetryService } from "@roo-code/telemetry" import { CloudService } from "@roo-code/cloud" // api -import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" +import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler, resolveToolCallPolicy } from "../../api" import { ApiStream, GroundingSource } from "../../api/transform/stream" import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" @@ -106,6 +106,11 @@ import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" import { NativeToolCallParser } from "../assistant-message/NativeToolCallParser" +import { + classifyStreamedCall, + isProvablyEmptyGhost, + emitGhostDropTelemetry, +} from "../assistant-message/ToolCallRetentionPolicy" import { manageContext, willManageContext } from "../context-management" import { ClineProvider } from "../webview/ClineProvider" import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace" @@ -1613,6 +1618,7 @@ export class Task extends EventEmitter implements TaskLike { } // Build metadata with tools and taskId for the condensing API call + const toolCallPolicy = resolveToolCallPolicy(this.api.getModel().info, this.apiConfiguration.apiProvider) const metadata: ApiHandlerCreateMessageMetadata = { mode, taskId: this.taskId, @@ -1625,7 +1631,7 @@ export class Task extends EventEmitter implements TaskLike { ? { tools: allTools, tool_choice: "auto", - parallelToolCalls: true, + parallelToolCalls: toolCallPolicy.generation === "parallel", } : {}), } @@ -2258,9 +2264,16 @@ export class Task extends EventEmitter implements TaskLike { console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error) // Don't rethrow - we want abort to always succeed } - // Save the countdown message in the automatic retry or other content. + // Guard: a history task whose message load has not finished yet has + // clineMessages = []. Saving now would call taskMetadata() with an + // empty array, which writes the "no messages" placeholder as the + // title and permanently clobbers the real title in the history store + // (the "Work #1 (no message)" / "工作 #1 (無訊息)" bug, v3.76.0). + // The on-disk data is still correct at this point, so skip the save. + if (this._isHistoryTask && this.clineMessages.length === 0) { + return + } try { - // Save the countdown message in the automatic retry or other content. await this.saveClineMessages() } catch (error) { console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error) @@ -2755,6 +2768,9 @@ export class Task extends EventEmitter implements TaskLike { // Clear any leftover streaming tool call state from previous interrupted streams NativeToolCallParser.clearAllStreamingToolCalls() NativeToolCallParser.clearRawChunkState() + // Clear recorded parse failures from previous streams so they + // don't accumulate for the extension-host lifetime. + NativeToolCallParser.clearParseFailures() await this.diffViewProvider.reset() @@ -2924,6 +2940,79 @@ export class Task extends EventEmitter implements TaskLike { } } } else if (event.type === "tool_call_end") { + // Ghost quarantine: inspect streaming state BEFORE + // finalizeStreamingToolCall() (which deletes it). + // A "ghost" is a call with no resolved tool name and no + // non-whitespace argument bytes at stream completion. + // Such calls are transport artifacts, not model intent, + // and must be silently dropped BEFORE insertion into + // assistantMessageContent or conversation history. + // + // A named call (even with `{}` args) is NOT a ghost — + // it is a malformed named call that must receive a + // tool_result. A call with any argument bytes is NOT a + // ghost — it carries partial model intent. + const preFinalizeState = NativeToolCallParser.getStreamingToolCallState( + event.id, + ) + const ghostDisposition = preFinalizeState + ? classifyStreamedCall({ + callId: event.id, + toolName: preFinalizeState.name, + argumentsAccumulator: preFinalizeState.argumentsAccumulator, + streamEnded: true, + }) + : undefined + + if (ghostDisposition && isProvablyEmptyGhost(ghostDisposition)) { + // Silently drop the ghost: remove its partial block + // from assistantMessageContent and discard streaming + // state. It will NOT receive a tool_result. + const ghostIndex = this.streamingToolCallIndices.get(event.id) + if (ghostIndex !== undefined) { + // Remove the partial tool_use block that was pushed + // at tool_call_start. This is safe because the call + // never resolved a name or arguments — it carries + // no model intent and has not been presented to the + // user as a tool call. + this.assistantMessageContent.splice(ghostIndex, 1) + // Re-index remaining streaming tool call indices + // since we removed an element from the array. + for (const [cid, idx] of this.streamingToolCallIndices.entries()) { + if (idx > ghostIndex) { + this.streamingToolCallIndices.set(cid, idx - 1) + } + } + this.streamingToolCallIndices.delete(event.id) + } + // Discard streaming state (finalizeStreamingToolCall + // would also delete it, but we bypass that path). + NativeToolCallParser.discardStreamingToolCall(event.id) + // Emit telemetry for the ghost drop. Only counts and + // metadata are sent — no call ID, tool name, or args. + const ghostPolicy1 = resolveToolCallPolicy( + this.api.getModel().info, + this.apiConfiguration.apiProvider, + ) + emitGhostDropTelemetry({ + taskId: this.taskId, + provider: this.apiConfiguration.apiProvider ?? "unknown", + model: this.api.getModel().id, + policySource: ghostPolicy1.source, + maxCallsPerTurn: ghostPolicy1.maxCallsPerTurn, + enforcement: ghostPolicy1.enforcement, + callCount: this.assistantMessageContent.filter( + (b: AssistantMessageContent): b is ToolUse => b.type === "tool_use", + ).length, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: ghostPolicy1.generation === "parallel", + }) + // Do NOT call presentAssistantMessageSafe — there is + // nothing to present for a ghost. + continue + } + // Finalize the streaming tool call const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id) @@ -2976,6 +3065,43 @@ export class Task extends EventEmitter implements TaskLike { case "tool_call": { // Legacy: Handle complete tool calls (for backward compatibility) + // Ghost quarantine: classify before any history insertion. + // A ghost has no name and no argument bytes — it is a transport + // artifact and must be silently dropped before becoming a + // tool_use block in assistantMessageContent. + const legacyDisposition = classifyStreamedCall({ + callId: chunk.id ?? "", + toolName: chunk.name, + argumentsAccumulator: chunk.arguments ?? "", + streamEnded: true, + }) + + if (isProvablyEmptyGhost(legacyDisposition)) { + // Silently drop the ghost. Do not push to + // assistantMessageContent, do not present. + // Emit telemetry for the ghost drop. Only counts + // and metadata — no call ID, tool name, or args. + const ghostPolicy2 = resolveToolCallPolicy( + this.api.getModel().info, + this.apiConfiguration.apiProvider, + ) + emitGhostDropTelemetry({ + taskId: this.taskId, + provider: this.apiConfiguration.apiProvider ?? "unknown", + model: this.api.getModel().id, + policySource: ghostPolicy2.source, + maxCallsPerTurn: ghostPolicy2.maxCallsPerTurn, + enforcement: ghostPolicy2.enforcement, + callCount: this.assistantMessageContent.filter( + (b: AssistantMessageContent): b is ToolUse => b.type === "tool_use", + ).length, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: ghostPolicy2.generation === "parallel", + }) + break + } + // Convert native tool call to ToolUse format const toolUse = NativeToolCallParser.parseToolCall({ id: chunk.id, @@ -3326,6 +3452,57 @@ export class Task extends EventEmitter implements TaskLike { const finalizeEvents = NativeToolCallParser.finalizeRawChunks() for (const event of finalizeEvents) { if (event.type === "tool_call_end") { + // Ghost quarantine (same logic as the streaming tool_call_end + // handler above): inspect streaming state BEFORE + // finalizeStreamingToolCall() deletes it. + const preFinalizeState = NativeToolCallParser.getStreamingToolCallState(event.id) + const ghostDisposition = preFinalizeState + ? classifyStreamedCall({ + callId: event.id, + toolName: preFinalizeState.name, + argumentsAccumulator: preFinalizeState.argumentsAccumulator, + streamEnded: true, + }) + : undefined + + if (ghostDisposition && isProvablyEmptyGhost(ghostDisposition)) { + // Silently drop the ghost: remove its partial block + // from assistantMessageContent and discard streaming + // state. It will NOT receive a tool_result. + const ghostIndex = this.streamingToolCallIndices.get(event.id) + if (ghostIndex !== undefined) { + this.assistantMessageContent.splice(ghostIndex, 1) + for (const [cid, idx] of this.streamingToolCallIndices.entries()) { + if (idx > ghostIndex) { + this.streamingToolCallIndices.set(cid, idx - 1) + } + } + this.streamingToolCallIndices.delete(event.id) + } + NativeToolCallParser.discardStreamingToolCall(event.id) + // Emit telemetry for the ghost drop. Only counts and + // metadata are sent — no call ID, tool name, or args. + const ghostPolicy3 = resolveToolCallPolicy( + this.api.getModel().info, + this.apiConfiguration.apiProvider, + ) + emitGhostDropTelemetry({ + taskId: this.taskId, + provider: this.apiConfiguration.apiProvider ?? "unknown", + model: this.api.getModel().id, + policySource: ghostPolicy3.source, + maxCallsPerTurn: ghostPolicy3.maxCallsPerTurn, + enforcement: ghostPolicy3.enforcement, + callCount: this.assistantMessageContent.filter( + (b: AssistantMessageContent): b is ToolUse => b.type === "tool_use", + ).length, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: ghostPolicy3.generation === "parallel", + }) + continue + } + // Finalize the streaming tool call const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id) @@ -3915,6 +4092,7 @@ export class Task extends EventEmitter implements TaskLike { } // Build metadata with tools and taskId for the condensing API call + const toolCallPolicy = resolveToolCallPolicy(this.api.getModel().info, this.apiConfiguration.apiProvider) const metadata: ApiHandlerCreateMessageMetadata = { mode, taskId: this.taskId, @@ -3927,7 +4105,7 @@ export class Task extends EventEmitter implements TaskLike { ? { tools: allTools, tool_choice: "auto", - parallelToolCalls: true, + parallelToolCalls: toolCallPolicy.generation === "parallel", } : {}), } @@ -4153,7 +4331,9 @@ export class Task extends EventEmitter implements TaskLike { ? { tools: contextMgmtTools, tool_choice: "auto", - parallelToolCalls: true, + parallelToolCalls: + resolveToolCallPolicy(this.api.getModel().info, this.apiConfiguration.apiProvider) + .generation === "parallel", } : {}), } @@ -4316,6 +4496,8 @@ export class Task extends EventEmitter implements TaskLike { this.currentRequestAbortController = new AbortController() const abortSignal = this.currentRequestAbortController.signal + const toolCallPolicy = resolveToolCallPolicy(this.api.getModel().info, this.apiConfiguration.apiProvider) + const parallelToolCallsRequested = toolCallPolicy.generation === "parallel" const metadata: ApiHandlerCreateMessageMetadata = { mode: mode, taskId: this.taskId, @@ -4326,13 +4508,24 @@ export class Task extends EventEmitter implements TaskLike { ? { tools: allTools, tool_choice: "auto", - parallelToolCalls: true, + parallelToolCalls: parallelToolCallsRequested, // When mode restricts tools, provide allowedFunctionNames so providers // like Gemini can see all tools in history but only call allowed ones ...(allowedFunctionNames ? { allowedFunctionNames } : {}), } : {}), } + // Emit telemetry for the policy resolution. Only metadata is sent — + // no raw commands, paths, file contents, tool arguments, or API keys. + TelemetryService.instance.captureToolCallPolicyResolution(this.taskId, { + provider: this.apiConfiguration.apiProvider ?? "unknown", + model: this.api.getModel().id, + policySource: toolCallPolicy.source, + maxCallsPerTurn: toolCallPolicy.maxCallsPerTurn, + enforcement: toolCallPolicy.enforcement, + parallelToolCallsRequested, + parallelToolCallsSent: shouldIncludeTools ? parallelToolCallsRequested : undefined, + }) // Reset the flag after using it this.skipPrevResponseIdOnce = false diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts index 1761db5bc3..60510a71d1 100644 --- a/src/core/task/__tests__/Task.persistence.spec.ts +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -4,13 +4,30 @@ import * as os from "os" import * as path from "path" import * as vscode from "vscode" -import type { GlobalState, ProviderSettings } from "@roo-code/types" +import type { ClineMessage, GlobalState, ProviderSettings } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../Task" import { ClineProvider } from "../../webview/ClineProvider" import { ContextProxy } from "../../config/ContextProxy" +type TaskPersistenceAccess = { + resumeTaskFromHistory: () => Promise + saveClineMessages: () => Promise +} + +function getTaskPersistenceAccess(task: Task): TaskPersistenceAccess { + return task as unknown as TaskPersistenceAccess +} + +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + // ─── Hoisted mocks ─────────────────────────────────────────────────────────── const { @@ -470,6 +487,102 @@ describe("Task persistence", () => { }) }) + // ── abortTask history hydration guard ───────────────────────────────── + + describe("abortTask", () => { + it("skips persistence when a history task aborts before messages load", async () => { + const messagesDeferred = createDeferred() + mockReadTaskMessages.mockReturnValueOnce(messagesDeferred.promise) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "history-task", + number: 1, + ts: Date.now(), + task: "Original task title", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + }) + + const resumePromise = task.run().catch(() => {}) + + await task.abortTask() + + expect(mockSaveTaskMessages).not.toHaveBeenCalled() + expect(mockProvider.updateTaskHistory).not.toHaveBeenCalled() + + messagesDeferred.resolve([]) + await resumePromise + }) + + it("persists a history task when messages load before abort", async () => { + const messages = [ + { + ts: Date.now(), + type: "say" as const, + say: "text" as const, + text: "Loaded task message", + }, + ] satisfies ClineMessage[] + const messagesDeferred = createDeferred() + mockReadTaskMessages.mockReturnValueOnce(messagesDeferred.promise).mockResolvedValue(messages) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + historyItem: { + id: "history-task", + number: 1, + ts: Date.now(), + task: "Original task title", + tokensIn: 10, + tokensOut: 5, + totalCost: 0.001, + }, + startTask: false, + }) + vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" }) + + mockReadApiMessages.mockResolvedValue([ + { + role: "user", + content: [{ type: "text", text: "Original task" }], + }, + ]) + + const resumePromise = getTaskPersistenceAccess(task).resumeTaskFromHistory() + messagesDeferred.resolve(messages) + await resumePromise + + const saveCallsBeforeAbort = mockSaveTaskMessages.mock.calls.length + expect(saveCallsBeforeAbort).toBeGreaterThan(0) + expect(mockProvider.updateTaskHistory).toHaveBeenCalled() + + await task.abortTask() + expect(mockSaveTaskMessages.mock.calls.length).toBeGreaterThan(saveCallsBeforeAbort) + }) + + it("persists an empty non-history task when aborted", async () => { + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "New task", + startTask: false, + }) + const saveClineMessagesSpy = vi.spyOn(getTaskPersistenceAccess(task), "saveClineMessages") + + await task.abortTask() + + expect(saveClineMessagesSpy).toHaveBeenCalledTimes(1) + expect(mockSaveTaskMessages).toHaveBeenCalledTimes(1) + }) + }) + // ── flushPendingToolResultsToHistory — save failure/success ─────────── describe("flushPendingToolResultsToHistory persistence", () => { diff --git a/src/core/task/__tests__/Task.resume-eviction-race.spec.ts b/src/core/task/__tests__/Task.resume-eviction-race.spec.ts new file mode 100644 index 0000000000..31e532e0e9 --- /dev/null +++ b/src/core/task/__tests__/Task.resume-eviction-race.spec.ts @@ -0,0 +1,225 @@ +// cd src && npx vitest run core/task/__tests__/Task.resume-eviction-race.spec.ts +// +// Regression anchor for the "Work #1 (no message)" title-clobber bug +// (Zoo Code v3.76.0, Discord report 2026-08-06). +// +// Root cause: resumeTaskFromHistory() starts with an async disk read. Until +// that read resolves, clineMessages is []. evictCurrentTask() calls +// abortTask(), which called saveClineMessages() -> taskMetadata(). With an +// empty array, taskMetadata() writes the "no_messages" placeholder as the +// title, permanently clobbering the real one in the history store. +// +// Fix: abortTask() skips saveClineMessages() for history tasks whose message +// load has not completed. The on-disk data is already correct at that point. +import * as os from "os" +import * as path from "path" + +import type { ClineMessage, GlobalState, HistoryItem, ProviderSettings } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" + +// ─── Hoisted mocks ─────────────────────────────────────────────────────────── + +const { mockSaveApiMessages, mockSaveTaskMessages, mockReadApiMessages, mockReadTaskMessages, mockPWaitFor } = + vi.hoisted(() => ({ + mockSaveApiMessages: vi.fn().mockResolvedValue(undefined), + mockSaveTaskMessages: vi.fn().mockResolvedValue(undefined), + mockReadApiMessages: vi.fn().mockResolvedValue([]), + // Controlled per-test via a deferred promise so we can hold the "disk + // read" open while a rival navigation aborts the still-loading task. + mockReadTaskMessages: vi.fn<() => Promise>(), + mockPWaitFor: vi.fn().mockResolvedValue(undefined), + })) + +// ─── Module mocks ──────────────────────────────────────────────────────────── +// vscode and fs/promises are globally aliased in vitest.config — no inline +// mock needed. + +vi.mock("delay", () => ({ __esModule: true, default: vi.fn().mockResolvedValue(undefined) })) +vi.mock("execa", () => ({ execa: vi.fn() })) +vi.mock("p-wait-for", () => ({ default: mockPWaitFor })) + +// taskMetadata is NOT mocked — the real implementation is under test. +vi.mock("../../task-persistence", async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + saveApiMessages: mockSaveApiMessages, + saveTaskMessages: mockSaveTaskMessages, + readApiMessages: mockReadApiMessages, + readTaskMessages: mockReadTaskMessages, + TaskHistoryStore: vi.fn().mockImplementation(function () { + return { + initialize: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + get: vi.fn(), + getAll: vi.fn().mockReturnValue([]), + upsert: vi.fn().mockResolvedValue([]), + delete: vi.fn().mockResolvedValue(undefined), + deleteMany: vi.fn().mockResolvedValue(undefined), + reconcile: vi.fn().mockResolvedValue(undefined), + initialized: Promise.resolve(), + } + }), + } +}) + +vi.mock("../../mentions", () => ({ + parseMentions: vi + .fn() + .mockImplementation((text) => + Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }), + ), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) +vi.mock("../../ignore/RooIgnoreController") +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), + getSettingsDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), +})) +vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockReturnValue(false) })) + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +function createDeferred() { + let resolve!: (value: T) => void + const promise = new Promise((res) => { + resolve = res + }) + return { promise, resolve } +} + +/** + * Minimal slice of ClineProvider that Task reads during construction and abort. + * All types are derived from ClineProvider so TypeScript validates property + * names and signatures without requiring the full class to be satisfied. + */ +type MockProvider = Pick & { + taskHistoryStore: Pick + context: { + globalStorageUri: Pick + globalState: Pick + workspaceState: Pick + secrets: Pick + extensionUri: Pick + extension: Pick + } +} + +function makeMockProvider(updateTaskHistory: ReturnType): MockProvider { + return { + log: vi.fn(), + taskHistoryStore: { get: () => undefined }, + // vi.fn() is not directly assignable to the typed method signature. + updateTaskHistory: updateTaskHistory as unknown as ClineProvider["updateTaskHistory"], + context: { + globalStorageUri: { fsPath: path.join(os.tmpdir(), "test-storage") }, + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + workspaceState: { + get: vi.fn().mockImplementation(() => undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockResolvedValue(undefined), + store: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + }, + extensionUri: { fsPath: "/mock/extension/path" }, + extension: { packageJSON: { version: "1.0.0" } }, + }, + } +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe("Task resume/eviction race (Work #1 (no message) regression)", () => { + let mockApiConfig: ProviderSettings + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + mockApiConfig = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } + }) + + it("does not clobber the real task title when evicted mid-resume", async () => { + const REAL_TITLE = "Write a short paragraph about the benefits of regular code reviews" + + const historyItem: HistoryItem = { + id: "parent-task-1", + number: 1, + task: REAL_TITLE, + ts: Date.now() - 60_000, + tokensIn: 500, + tokensOut: 300, + totalCost: 0.01, + workspace: path.join(os.tmpdir(), "mock-workspace"), + } + + // Hold the disk read open so the task is aborted while clineMessages is + // still empty — the same window a user hits by navigating away quickly. + const readDeferred = createDeferred() + mockReadTaskMessages + .mockReturnValueOnce(readDeferred.promise) // first read: held open to simulate the race window + .mockResolvedValue([]) // second read (resumeTaskFromHistory:2023): post-abort, safe fallback + + const updateTaskHistory = vi.fn().mockResolvedValue([]) + const mockProvider = makeMockProvider(updateTaskHistory) + + const task = new Task({ + provider: mockProvider as unknown as ClineProvider, + apiConfiguration: mockApiConfig, + historyItem, + taskNumber: historyItem.number, + startTask: false, + }) + + // Fire task.run() without awaiting — mirrors the fire-and-forget pattern + // in ClineProvider#createTaskWithHistoryItem. For history tasks, run() + // calls resumeTaskFromHistory(), which starts with an async disk read. + const runPromise = task.run().catch(() => { + // After abort, downstream steps (e.g. ask()) throw — expected. + }) + + // Abort while the disk read is still in flight, as evictCurrentTask() + // does when the user navigates away before messages load. + await task.abortTask(true) + + // The fix: saveClineMessages() must not be called for a history task + // with clineMessages still empty. Verify the call was skipped entirely, + // not just that the specific "no_messages" key was not written. + expect(updateTaskHistory).not.toHaveBeenCalled() + + // Let the read resolve so the promise does not leak into the next test. + readDeferred.resolve([ + { ts: historyItem.ts, type: "say", say: "text", text: REAL_TITLE }, + { ts: historyItem.ts + 1, type: "say", say: "completion_result", text: "Done." }, + ]) + await runPromise + }) +}) diff --git a/src/core/task/__tests__/ghost-quarantine.spec.ts b/src/core/task/__tests__/ghost-quarantine.spec.ts new file mode 100644 index 0000000000..1ad73437e9 --- /dev/null +++ b/src/core/task/__tests__/ghost-quarantine.spec.ts @@ -0,0 +1,775 @@ +/** + * Tests for ghost tool call quarantine logic. + * + * These tests verify the ghost quarantine paths in Task.ts that silently drop + * "ghost" tool calls — calls with no resolved tool name and no non-whitespace + * argument bytes at stream completion. Ghosts are transport artifacts, not + * model intent, and must be removed before insertion into conversation history. + * + * The ghost quarantine logic lives in three code paths in Task.ts: + * - Lines 2937-3009: streaming `tool_call_end` handler (ghostPolicy1) + * - Lines 3062-3098: legacy `tool_call` chunk handler (ghostPolicy2) + * - Lines 3449-3499: finalize-raw-chunks handler (ghostPolicy3) + * + * Since Task.ts is a massive orchestrator (~5000 lines) requiring extensive + * VS Code / terminal / filesystem mocking, these tests simulate the quarantine + * logic in isolation — the same pattern used by `duplicate-tool-use-ids.spec.ts`. + * The core classification functions (`classifyStreamedCall`, + * `isProvablyEmptyGhost`) are tested in `ToolCallRetentionPolicy.spec.ts`. + */ + +import { classifyStreamedCall, isProvablyEmptyGhost } from "../../assistant-message/ToolCallRetentionPolicy" +import { resolveToolCallPolicy } from "../../../api" +import { mimoModels } from "@roo-code/types" +import type { ModelInfo } from "@roo-code/types" + +// Type for the streaming tool call state that Task.ts reads from +// NativeToolCallParser.getStreamingToolCallState() +interface StreamingToolCallState { + name: string | undefined + argumentsAccumulator: string +} + +// Type for assistant message content blocks +interface AssistantMessageContent { + type: string + id?: string + name?: string + partial?: boolean +} + +// Type for ghost drop telemetry payload +interface GhostDropTelemetry { + taskId: string + provider: string + model: string + policySource: string + maxCallsPerTurn: number | string + enforcement: string + callCount: number + ghostDroppedCount: number + errorResultCount: number + parallelToolCallsRequested: boolean +} + +/** + * Simulates the ghost quarantine logic from Task.ts lines 2937-3009 + * (streaming tool_call_end handler). + * + * This is the first quarantine path: when a `tool_call_end` event arrives, + * the handler inspects the streaming state BEFORE finalizeStreamingToolCall() + * deletes it. If the call is a provably empty ghost, it is silently dropped. + */ +function handleStreamingToolCallEnd( + event: { type: "tool_call_end"; id: string }, + streamingToolCallState: Map, + streamingToolCallIndices: Map, + assistantMessageContent: AssistantMessageContent[], + telemetryLog: GhostDropTelemetry[], + telemetryContext: { taskId: string; provider: string; model: string; modelInfo: ModelInfo }, +): { dropped: boolean; policyLabel: string } { + const preFinalizeState = streamingToolCallState.get(event.id) + const ghostDisposition = preFinalizeState + ? classifyStreamedCall({ + callId: event.id, + toolName: preFinalizeState.name, + argumentsAccumulator: preFinalizeState.argumentsAccumulator, + streamEnded: true, + }) + : undefined + + if (ghostDisposition && isProvablyEmptyGhost(ghostDisposition)) { + const ghostIndex = streamingToolCallIndices.get(event.id) + if (ghostIndex !== undefined) { + assistantMessageContent.splice(ghostIndex, 1) + for (const [cid, idx] of streamingToolCallIndices.entries()) { + if (idx > ghostIndex) { + streamingToolCallIndices.set(cid, idx - 1) + } + } + streamingToolCallIndices.delete(event.id) + } + streamingToolCallState.delete(event.id) + + const ghostPolicy1 = resolveToolCallPolicy(telemetryContext.modelInfo, telemetryContext.provider) + telemetryLog.push({ + taskId: telemetryContext.taskId, + provider: telemetryContext.provider, + model: telemetryContext.model, + policySource: ghostPolicy1.source, + maxCallsPerTurn: ghostPolicy1.maxCallsPerTurn, + enforcement: ghostPolicy1.enforcement, + callCount: assistantMessageContent.filter((b) => b.type === "tool_use").length, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: ghostPolicy1.generation === "parallel", + }) + + return { dropped: true, policyLabel: "ghostPolicy1" } + } + + return { dropped: false, policyLabel: "none" } +} + +/** + * Simulates the ghost quarantine logic from Task.ts lines 3062-3098 + * (legacy tool_call chunk handler). + * + * This is the second quarantine path: when a complete `tool_call` chunk + * arrives (legacy non-streaming format), the handler classifies it before + * any history insertion. + */ +function handleLegacyToolCall( + chunk: { type: "tool_call"; id?: string; name?: string; arguments?: string }, + telemetryLog: GhostDropTelemetry[], + telemetryContext: { taskId: string; provider: string; model: string; modelInfo: ModelInfo }, +): { dropped: boolean; policyLabel: string } { + const legacyDisposition = classifyStreamedCall({ + callId: chunk.id ?? "", + toolName: chunk.name, + argumentsAccumulator: chunk.arguments ?? "", + streamEnded: true, + }) + + if (isProvablyEmptyGhost(legacyDisposition)) { + const ghostPolicy2 = resolveToolCallPolicy(telemetryContext.modelInfo, telemetryContext.provider) + telemetryLog.push({ + taskId: telemetryContext.taskId, + provider: telemetryContext.provider, + model: telemetryContext.model, + policySource: ghostPolicy2.source, + maxCallsPerTurn: ghostPolicy2.maxCallsPerTurn, + enforcement: ghostPolicy2.enforcement, + callCount: 0, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: ghostPolicy2.generation === "parallel", + }) + + return { dropped: true, policyLabel: "ghostPolicy2" } + } + + return { dropped: false, policyLabel: "none" } +} + +/** + * Simulates the ghost quarantine logic from Task.ts lines 3449-3499 + * (finalize-raw-chunks handler). + * + * This is the third quarantine path: when the stream ends, any remaining + * streaming tool calls are finalized via finalizeRawChunks(). Each resulting + * `tool_call_end` event goes through the same ghost quarantine as path 1. + */ +function handleFinalizeRawChunks( + finalizeEvents: Array<{ type: "tool_call_end"; id: string }>, + streamingToolCallState: Map, + streamingToolCallIndices: Map, + assistantMessageContent: AssistantMessageContent[], + telemetryLog: GhostDropTelemetry[], + telemetryContext: { taskId: string; provider: string; model: string; modelInfo: ModelInfo }, +): { dropped: boolean; policyLabel: string }[] { + const results: { dropped: boolean; policyLabel: string }[] = [] + + for (const event of finalizeEvents) { + if (event.type === "tool_call_end") { + const preFinalizeState = streamingToolCallState.get(event.id) + const ghostDisposition = preFinalizeState + ? classifyStreamedCall({ + callId: event.id, + toolName: preFinalizeState.name, + argumentsAccumulator: preFinalizeState.argumentsAccumulator, + streamEnded: true, + }) + : undefined + + if (ghostDisposition && isProvablyEmptyGhost(ghostDisposition)) { + const ghostIndex = streamingToolCallIndices.get(event.id) + if (ghostIndex !== undefined) { + assistantMessageContent.splice(ghostIndex, 1) + for (const [cid, idx] of streamingToolCallIndices.entries()) { + if (idx > ghostIndex) { + streamingToolCallIndices.set(cid, idx - 1) + } + } + streamingToolCallIndices.delete(event.id) + } + streamingToolCallState.delete(event.id) + + const ghostPolicy3 = resolveToolCallPolicy(telemetryContext.modelInfo, telemetryContext.provider) + telemetryLog.push({ + taskId: telemetryContext.taskId, + provider: telemetryContext.provider, + model: telemetryContext.model, + policySource: ghostPolicy3.source, + maxCallsPerTurn: ghostPolicy3.maxCallsPerTurn, + enforcement: ghostPolicy3.enforcement, + callCount: assistantMessageContent.filter((b) => b.type === "tool_use").length, + ghostDroppedCount: 1, + errorResultCount: 0, + parallelToolCallsRequested: ghostPolicy3.generation === "parallel", + }) + + results.push({ dropped: true, policyLabel: "ghostPolicy3" }) + } else { + results.push({ dropped: false, policyLabel: "none" }) + } + } + } + + return results +} + +describe("Ghost Tool Call Quarantine", () => { + const telemetryContext = { + taskId: "test-task-001", + provider: "mimo", + model: "mimo-v2.5-pro", + modelInfo: mimoModels["mimo-v2.5-pro"] as ModelInfo, + } + + describe("Path 1: Streaming tool_call_end handler (ghostPolicy1)", () => { + it("should drop a ghost with no name and no arguments", () => { + const streamingToolCallState = new Map([ + ["call_ghost_1", { name: "", argumentsAccumulator: "" }], + ]) + const streamingToolCallIndices = new Map([["call_ghost_1", 0]]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_ghost_1", name: "", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_ghost_1" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(true) + expect(result.policyLabel).toBe("ghostPolicy1") + + // Ghost should be removed from assistantMessageContent + expect(assistantMessageContent).toHaveLength(0) + + // Streaming state should be cleaned up + expect(streamingToolCallState.has("call_ghost_1")).toBe(false) + expect(streamingToolCallIndices.has("call_ghost_1")).toBe(false) + + // Telemetry should be emitted + expect(telemetryLog).toHaveLength(1) + expect(telemetryLog[0].ghostDroppedCount).toBe(1) + expect(telemetryLog[0].taskId).toBe("test-task-001") + expect(telemetryLog[0].provider).toBe("mimo") + expect(telemetryLog[0].model).toBe("mimo-v2.5-pro") + }) + + it("should drop a ghost with whitespace-only name and arguments", () => { + const streamingToolCallState = new Map([ + ["call_ghost_2", { name: " ", argumentsAccumulator: " \n\t " }], + ]) + const streamingToolCallIndices = new Map([["call_ghost_2", 0]]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_ghost_2", name: " ", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_ghost_2" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(true) + expect(assistantMessageContent).toHaveLength(0) + expect(telemetryLog).toHaveLength(1) + }) + + it("should drop a ghost with undefined name and empty arguments", () => { + const streamingToolCallState = new Map([ + ["call_ghost_3", { name: undefined, argumentsAccumulator: "" }], + ]) + const streamingToolCallIndices = new Map([["call_ghost_3", 0]]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_ghost_3", name: undefined, partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_ghost_3" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(true) + expect(assistantMessageContent).toHaveLength(0) + }) + + it("should NOT drop a named call with empty arguments (not a ghost)", () => { + const streamingToolCallState = new Map([ + ["call_named", { name: "read_file", argumentsAccumulator: "{}" }], + ]) + const streamingToolCallIndices = new Map([["call_named", 0]]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_named", name: "read_file", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_named" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(false) + expect(assistantMessageContent).toHaveLength(1) + expect(telemetryLog).toHaveLength(0) + }) + + it("should NOT drop a call with argument bytes even without a name", () => { + const streamingToolCallState = new Map([ + ["call_args", { name: "", argumentsAccumulator: '{"path":"test.ts"}' }], + ]) + const streamingToolCallIndices = new Map([["call_args", 0]]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_args", name: "", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_args" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(false) + expect(assistantMessageContent).toHaveLength(1) + }) + + it("should re-index remaining streaming tool call indices after ghost removal", () => { + // Ghost is at index 0, a real call is at index 1. + // After removing the ghost, the real call should be re-indexed to 0. + const streamingToolCallState = new Map([ + ["call_ghost", { name: "", argumentsAccumulator: "" }], + ["call_real", { name: "read_file", argumentsAccumulator: '{"path":"a.ts"}' }], + ]) + const streamingToolCallIndices = new Map([ + ["call_ghost", 0], + ["call_real", 1], + ]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_ghost", name: "", partial: true }, + { type: "tool_use", id: "call_real", name: "read_file", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_ghost" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(true) + expect(assistantMessageContent).toHaveLength(1) + expect(assistantMessageContent[0].id).toBe("call_real") + + // The real call's index should be decremented from 1 to 0 + expect(streamingToolCallIndices.get("call_real")).toBe(0) + expect(streamingToolCallIndices.has("call_ghost")).toBe(false) + }) + + it("should handle ghost when streaming state is undefined (preFinalizeState is undefined)", () => { + // When getStreamingToolCallState returns undefined (already cleaned up), + // ghostDisposition is undefined and the call is NOT dropped. + const streamingToolCallState = new Map() + const streamingToolCallIndices = new Map([["call_missing", 0]]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_missing", name: "read_file", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_missing" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + // No state → no disposition → not dropped + expect(result.dropped).toBe(false) + expect(telemetryLog).toHaveLength(0) + }) + + it("should handle ghost when streamingToolCallIndices has no entry for the id", () => { + // Ghost is detected but ghostIndex is undefined — the splice/index + // cleanup is skipped, but discardStreamingToolCall still runs. + const streamingToolCallState = new Map([ + ["call_ghost_no_idx", { name: "", argumentsAccumulator: "" }], + ]) + const streamingToolCallIndices = new Map() + const assistantMessageContent: AssistantMessageContent[] = [] + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_ghost_no_idx" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(true) + // assistantMessageContent is unchanged (no index to splice) + expect(assistantMessageContent).toHaveLength(0) + // But streaming state is still cleaned up + expect(streamingToolCallState.has("call_ghost_no_idx")).toBe(false) + // Telemetry is still emitted + expect(telemetryLog).toHaveLength(1) + }) + }) + + describe("Path 2: Legacy tool_call chunk handler (ghostPolicy2)", () => { + it("should drop a ghost with no name and no arguments", () => { + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleLegacyToolCall( + { type: "tool_call", id: "call_legacy_ghost", name: "", arguments: "" }, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(true) + expect(result.policyLabel).toBe("ghostPolicy2") + expect(telemetryLog).toHaveLength(1) + expect(telemetryLog[0].ghostDroppedCount).toBe(1) + }) + + it("should drop a ghost with undefined name and undefined arguments", () => { + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleLegacyToolCall( + { type: "tool_call", id: "call_legacy_undef" }, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(true) + expect(telemetryLog).toHaveLength(1) + }) + + it("should drop a ghost with whitespace-only name and arguments", () => { + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleLegacyToolCall( + { type: "tool_call", id: "call_legacy_ws", name: " ", arguments: " \n " }, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(true) + expect(telemetryLog).toHaveLength(1) + }) + + it("should NOT drop a named call with empty arguments", () => { + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleLegacyToolCall( + { type: "tool_call", id: "call_legacy_named", name: "read_file", arguments: "{}" }, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(false) + expect(telemetryLog).toHaveLength(0) + }) + + it("should NOT drop a call with argument bytes even without a name", () => { + const telemetryLog: GhostDropTelemetry[] = [] + + const result = handleLegacyToolCall( + { type: "tool_call", id: "call_legacy_args", name: "", arguments: '{"path":"x"}' }, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(false) + expect(telemetryLog).toHaveLength(0) + }) + }) + + describe("Path 3: Finalize-raw-chunks handler (ghostPolicy3)", () => { + it("should drop a ghost from finalizeRawChunks output", () => { + const streamingToolCallState = new Map([ + ["call_fin_ghost", { name: "", argumentsAccumulator: "" }], + ]) + const streamingToolCallIndices = new Map([["call_fin_ghost", 0]]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_fin_ghost", name: "", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const results = handleFinalizeRawChunks( + [{ type: "tool_call_end", id: "call_fin_ghost" }], + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(results).toHaveLength(1) + expect(results[0].dropped).toBe(true) + expect(results[0].policyLabel).toBe("ghostPolicy3") + expect(assistantMessageContent).toHaveLength(0) + expect(telemetryLog).toHaveLength(1) + expect(telemetryLog[0].ghostDroppedCount).toBe(1) + }) + + it("should drop multiple ghosts from finalizeRawChunks", () => { + const streamingToolCallState = new Map([ + ["call_fin_ghost1", { name: "", argumentsAccumulator: "" }], + ["call_fin_ghost2", { name: " ", argumentsAccumulator: " " }], + ]) + const streamingToolCallIndices = new Map([ + ["call_fin_ghost1", 0], + ["call_fin_ghost2", 1], + ]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_fin_ghost1", name: "", partial: true }, + { type: "tool_use", id: "call_fin_ghost2", name: " ", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const results = handleFinalizeRawChunks( + [ + { type: "tool_call_end", id: "call_fin_ghost1" }, + { type: "tool_call_end", id: "call_fin_ghost2" }, + ], + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(results).toHaveLength(2) + expect(results.every((r) => r.dropped)).toBe(true) + expect(assistantMessageContent).toHaveLength(0) + expect(telemetryLog).toHaveLength(2) + }) + + it("should NOT drop a named call from finalizeRawChunks", () => { + const streamingToolCallState = new Map([ + ["call_fin_named", { name: "read_file", argumentsAccumulator: '{"path":"x"}' }], + ]) + const streamingToolCallIndices = new Map([["call_fin_named", 0]]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_fin_named", name: "read_file", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const results = handleFinalizeRawChunks( + [{ type: "tool_call_end", id: "call_fin_named" }], + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(results).toHaveLength(1) + expect(results[0].dropped).toBe(false) + expect(assistantMessageContent).toHaveLength(1) + expect(telemetryLog).toHaveLength(0) + }) + + it("should handle mixed ghosts and real calls in finalizeRawChunks", () => { + const streamingToolCallState = new Map([ + ["call_fin_ghost", { name: "", argumentsAccumulator: "" }], + ["call_fin_real", { name: "write_to_file", argumentsAccumulator: '{"path":"a"}' }], + ]) + const streamingToolCallIndices = new Map([ + ["call_fin_ghost", 0], + ["call_fin_real", 1], + ]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_fin_ghost", name: "", partial: true }, + { type: "tool_use", id: "call_fin_real", name: "write_to_file", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + const results = handleFinalizeRawChunks( + [ + { type: "tool_call_end", id: "call_fin_ghost" }, + { type: "tool_call_end", id: "call_fin_real" }, + ], + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(results).toHaveLength(2) + expect(results[0].dropped).toBe(true) + expect(results[1].dropped).toBe(false) + + // Only the real call should remain + expect(assistantMessageContent).toHaveLength(1) + expect(assistantMessageContent[0].id).toBe("call_fin_real") + + // Real call should be re-indexed to 0 + expect(streamingToolCallIndices.get("call_fin_real")).toBe(0) + + // Only one telemetry entry (for the ghost) + expect(telemetryLog).toHaveLength(1) + }) + + it("should handle empty finalizeEvents array", () => { + const streamingToolCallState = new Map() + const streamingToolCallIndices = new Map() + const assistantMessageContent: AssistantMessageContent[] = [] + const telemetryLog: GhostDropTelemetry[] = [] + + const results = handleFinalizeRawChunks( + [], + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(results).toHaveLength(0) + expect(telemetryLog).toHaveLength(0) + }) + }) + + describe("Telemetry payload correctness", () => { + it("should emit correct telemetry for MiMo provider (single generation)", () => { + const streamingToolCallState = new Map([ + ["call_telemetry", { name: "", argumentsAccumulator: "" }], + ]) + const streamingToolCallIndices = new Map([["call_telemetry", 0]]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_telemetry", name: "", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_telemetry" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(telemetryLog).toHaveLength(1) + const t = telemetryLog[0] + expect(t.taskId).toBe("test-task-001") + expect(t.provider).toBe("mimo") + expect(t.model).toBe("mimo-v2.5-pro") + expect(t.policySource).toBe("model-capability") + expect(t.maxCallsPerTurn).toBe(1) + expect(t.enforcement).toBe("local") + expect(t.ghostDroppedCount).toBe(1) + expect(t.errorResultCount).toBe(0) + expect(t.parallelToolCallsRequested).toBe(false) + }) + + it("should count remaining tool_use blocks in callCount", () => { + const streamingToolCallState = new Map([ + ["call_ghost_count", { name: "", argumentsAccumulator: "" }], + ]) + const streamingToolCallIndices = new Map([["call_ghost_count", 1]]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "text", id: "text_block" }, // not a tool_use + { type: "tool_use", id: "call_ghost_count", name: "", partial: true }, + { type: "tool_use", id: "call_other", name: "read_file", partial: false }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_ghost_count" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + // After splice, assistantMessageContent has text + one tool_use + // callCount is computed AFTER the splice, so it should be 1 + expect(telemetryLog[0].callCount).toBe(1) + }) + }) + + describe("Integration scenario: Ghost among real calls", () => { + it("should drop only the ghost and preserve real calls in correct order", () => { + // Simulate a stream that produced: real call, ghost, real call + const streamingToolCallState = new Map([ + ["call_real1", { name: "read_file", argumentsAccumulator: '{"path":"a.ts"}' }], + ["call_ghost_mid", { name: "", argumentsAccumulator: "" }], + ["call_real2", { name: "write_to_file", argumentsAccumulator: '{"path":"b.ts"}' }], + ]) + const streamingToolCallIndices = new Map([ + ["call_real1", 0], + ["call_ghost_mid", 1], + ["call_real2", 2], + ]) + const assistantMessageContent: AssistantMessageContent[] = [ + { type: "tool_use", id: "call_real1", name: "read_file", partial: true }, + { type: "tool_use", id: "call_ghost_mid", name: "", partial: true }, + { type: "tool_use", id: "call_real2", name: "write_to_file", partial: true }, + ] + const telemetryLog: GhostDropTelemetry[] = [] + + // Process the ghost's tool_call_end + const result = handleStreamingToolCallEnd( + { type: "tool_call_end", id: "call_ghost_mid" }, + streamingToolCallState, + streamingToolCallIndices, + assistantMessageContent, + telemetryLog, + telemetryContext, + ) + + expect(result.dropped).toBe(true) + + // Only the ghost should be removed + expect(assistantMessageContent).toHaveLength(2) + expect(assistantMessageContent[0].id).toBe("call_real1") + expect(assistantMessageContent[1].id).toBe("call_real2") + + // Indices should be re-indexed: call_real1 stays at 0, call_real2 moves from 2 to 1 + expect(streamingToolCallIndices.get("call_real1")).toBe(0) + expect(streamingToolCallIndices.get("call_real2")).toBe(1) + expect(streamingToolCallIndices.has("call_ghost_mid")).toBe(false) + + // Telemetry should record 1 ghost drop with callCount=2 (after splice) + expect(telemetryLog).toHaveLength(1) + expect(telemetryLog[0].ghostDroppedCount).toBe(1) + expect(telemetryLog[0].callCount).toBe(2) + }) + }) +}) diff --git a/src/core/task/__tests__/tool-call-policy.spec.ts b/src/core/task/__tests__/tool-call-policy.spec.ts new file mode 100644 index 0000000000..d566c22338 --- /dev/null +++ b/src/core/task/__tests__/tool-call-policy.spec.ts @@ -0,0 +1,233 @@ +import { describe, it, expect } from "vitest" +import { resolveToolCallPolicy } from "../../../api" +import type { ModelInfo } from "@roo-code/types" +import { mimoModels } from "@roo-code/types" + +describe("resolveToolCallPolicy", () => { + // Helper: create a minimal ModelInfo with only the fields needed for testing. + function makeModelInfo(overrides: Partial = {}): ModelInfo { + return { + contextWindow: 200_000, + supportsPromptCache: false, + ...overrides, + } + } + + describe("MiMo models", () => { + it("resolves mimo-v2.5-pro to single generation with maxCallsPerTurn=1", () => { + const modelInfo = mimoModels["mimo-v2.5-pro"] as ModelInfo + const policy = resolveToolCallPolicy(modelInfo, "mimo") + + expect(policy.generation).toBe("single") + expect(policy.maxCallsPerTurn).toBe(1) + expect(policy.source).toBe("model-capability") + }) + + it("resolves mimo-v2.5 to single generation with maxCallsPerTurn=1", () => { + const modelInfo = mimoModels["mimo-v2.5"] as ModelInfo + const policy = resolveToolCallPolicy(modelInfo, "mimo") + + expect(policy.generation).toBe("single") + expect(policy.maxCallsPerTurn).toBe(1) + expect(policy.source).toBe("model-capability") + }) + + it("uses local enforcement when request control is 'none'", () => { + const modelInfo = mimoModels["mimo-v2.5-pro"] as ModelInfo + const policy = resolveToolCallPolicy(modelInfo, "mimo") + + expect(policy.enforcement).toBe("local") + }) + }) + + describe("OpenAI-capable models", () => { + it("resolves to parallel generation with unbounded maxCallsPerTurn", () => { + const modelInfo = makeModelInfo({ + toolCallCapabilities: { + supportsParallelToolCalls: true, + parallelToolCallsRequestControl: "openai", + }, + }) + const policy = resolveToolCallPolicy(modelInfo, "openai") + + expect(policy.generation).toBe("parallel") + expect(policy.maxCallsPerTurn).toBe("unbounded") + expect(policy.enforcement).toBe("provider") + expect(policy.source).toBe("model-capability") + }) + }) + + describe("Anthropic-capable models", () => { + it("resolves to parallel generation with provider enforcement", () => { + const modelInfo = makeModelInfo({ + toolCallCapabilities: { + supportsParallelToolCalls: true, + parallelToolCallsRequestControl: "anthropic", + }, + }) + const policy = resolveToolCallPolicy(modelInfo, "anthropic") + + expect(policy.generation).toBe("parallel") + expect(policy.maxCallsPerTurn).toBe("unbounded") + expect(policy.enforcement).toBe("provider") + expect(policy.source).toBe("model-capability") + }) + }) + + describe("Models without explicit toolCallCapabilities", () => { + it("OpenAI model without capabilities resolves to parallel (preserves existing behavior)", () => { + const modelInfo = makeModelInfo() + const policy = resolveToolCallPolicy(modelInfo, "openai") + + expect(policy.generation).toBe("parallel") + expect(policy.maxCallsPerTurn).toBe("unbounded") + expect(policy.enforcement).toBe("provider") + expect(policy.source).toBe("provider-default") + }) + + it("Anthropic model without capabilities resolves to parallel (preserves existing behavior)", () => { + const modelInfo = makeModelInfo() + const policy = resolveToolCallPolicy(modelInfo, "anthropic") + + expect(policy.generation).toBe("parallel") + expect(policy.maxCallsPerTurn).toBe("unbounded") + expect(policy.enforcement).toBe("provider") + expect(policy.source).toBe("provider-default") + }) + + it("Bedrock (Anthropic-family) model without capabilities resolves to parallel", () => { + const modelInfo = makeModelInfo() + const policy = resolveToolCallPolicy(modelInfo, "bedrock") + + expect(policy.generation).toBe("parallel") + expect(policy.maxCallsPerTurn).toBe("unbounded") + expect(policy.enforcement).toBe("provider") + expect(policy.source).toBe("provider-default") + }) + + it("OpenRouter model without capabilities resolves to parallel", () => { + const modelInfo = makeModelInfo() + const policy = resolveToolCallPolicy(modelInfo, "openrouter") + + expect(policy.generation).toBe("parallel") + expect(policy.maxCallsPerTurn).toBe("unbounded") + expect(policy.enforcement).toBe("provider") + expect(policy.source).toBe("provider-default") + }) + + it("Unknown provider (mimo) without capabilities resolves to conservative single", () => { + const modelInfo = makeModelInfo() + const policy = resolveToolCallPolicy(modelInfo, "mimo") + + expect(policy.generation).toBe("single") + expect(policy.maxCallsPerTurn).toBe(1) + expect(policy.enforcement).toBe("local") + expect(policy.source).toBe("provider-default") + }) + + it("Unknown provider without capabilities resolves to conservative single", () => { + const modelInfo = makeModelInfo() + const policy = resolveToolCallPolicy(modelInfo, "some-unknown-provider") + + expect(policy.generation).toBe("single") + expect(policy.maxCallsPerTurn).toBe(1) + expect(policy.enforcement).toBe("local") + expect(policy.source).toBe("provider-default") + }) + + it("resolves to parallel for OpenAI when capabilities are 'unknown' (provider fallback)", () => { + const modelInfo = makeModelInfo({ + toolCallCapabilities: { + supportsParallelToolCalls: "unknown", + parallelToolCallsRequestControl: "unknown", + }, + }) + const policy = resolveToolCallPolicy(modelInfo, "openai") + + expect(policy.generation).toBe("parallel") + expect(policy.maxCallsPerTurn).toBe("unbounded") + expect(policy.enforcement).toBe("provider") + expect(policy.source).toBe("provider-default") + }) + + it("resolves to conservative single for unknown provider when capabilities are 'unknown'", () => { + const modelInfo = makeModelInfo({ + toolCallCapabilities: { + supportsParallelToolCalls: "unknown", + parallelToolCallsRequestControl: "unknown", + }, + }) + const policy = resolveToolCallPolicy(modelInfo, "mimo") + + expect(policy.generation).toBe("single") + expect(policy.maxCallsPerTurn).toBe(1) + expect(policy.enforcement).toBe("local") + expect(policy.source).toBe("provider-default") + }) + + it("resolves to conservative single when providerName is absent", () => { + const modelInfo = makeModelInfo() + const policy = resolveToolCallPolicy(modelInfo) + + expect(policy.generation).toBe("single") + expect(policy.maxCallsPerTurn).toBe(1) + expect(policy.enforcement).toBe("local") + expect(policy.source).toBe("provider-default") + }) + }) + + describe("Model with supportsParallelToolCalls=false but request control set", () => { + it("uses provider-and-local enforcement when request control is 'openai'", () => { + const modelInfo = makeModelInfo({ + toolCallCapabilities: { + supportsParallelToolCalls: false, + parallelToolCallsRequestControl: "openai", + }, + }) + const policy = resolveToolCallPolicy(modelInfo, "openai") + + expect(policy.generation).toBe("single") + expect(policy.maxCallsPerTurn).toBe(1) + expect(policy.enforcement).toBe("provider-and-local") + expect(policy.source).toBe("model-capability") + }) + + it("uses provider-and-local enforcement when request control is 'anthropic'", () => { + const modelInfo = makeModelInfo({ + toolCallCapabilities: { + supportsParallelToolCalls: false, + parallelToolCallsRequestControl: "anthropic", + }, + }) + const policy = resolveToolCallPolicy(modelInfo, "anthropic") + + expect(policy.generation).toBe("single") + expect(policy.maxCallsPerTurn).toBe(1) + expect(policy.enforcement).toBe("provider-and-local") + expect(policy.source).toBe("model-capability") + }) + }) + + describe("Pure function properties", () => { + it("returns the same result for the same input", () => { + const modelInfo = mimoModels["mimo-v2.5-pro"] as ModelInfo + const policy1 = resolveToolCallPolicy(modelInfo, "mimo") + const policy2 = resolveToolCallPolicy(modelInfo, "mimo") + + expect(policy1).toEqual(policy2) + }) + + it("does not mutate the input modelInfo", () => { + const modelInfo = makeModelInfo({ + toolCallCapabilities: { + supportsParallelToolCalls: true, + parallelToolCallsRequestControl: "openai", + }, + }) + const original = JSON.parse(JSON.stringify(modelInfo)) + resolveToolCallPolicy(modelInfo, "openai") + + expect(JSON.parse(JSON.stringify(modelInfo))).toEqual(original) + }) + }) +}) diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 3f3295404b..56b2bf8909 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -131,7 +131,6 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } task.consecutiveMistakeCount = 0 - task.recordToolUsage("apply_patch") } catch (error) { await handleError("apply patch", error as Error) await task.diffViewProvider.reset() diff --git a/src/core/tools/BaseTool.ts b/src/core/tools/BaseTool.ts index 7d574068a9..83a733c7b0 100644 --- a/src/core/tools/BaseTool.ts +++ b/src/core/tools/BaseTool.ts @@ -108,9 +108,16 @@ export abstract class BaseTool { * * @param task - Task instance * @param block - ToolUse block from assistant message - * @param callbacks - Tool execution callbacks + * @param callbacks - Tool execution callbacks. Accepts any subclass-specific + * extension of ToolCallbacks (e.g. UseMcpToolCallbacks) so callers can pass + * extra fields through to a matching execute() override without widening + * the shared ToolCallbacks interface for every tool. */ - async handle(task: Task, block: ToolUse, callbacks: ToolCallbacks): Promise { + async handle( + task: Task, + block: ToolUse, + callbacks: TCallbacks, + ): Promise { // Handle partial messages if (block.partial) { try { diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index 2495a372bc..a7301e2ac9 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -463,8 +463,6 @@ export class EditFileTool extends BaseTool<"edit_file"> { pushToolResult(message + replacementInfo) - // Record successful tool usage and cleanup - task.recordToolUsage("edit_file") await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 79338c17a6..2ae8bf4ed0 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -229,8 +229,6 @@ export class EditTool extends BaseTool<"edit"> { const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) pushToolResult(message) - // Record successful tool usage and cleanup - task.recordToolUsage("edit") await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/ExecuteCommandTool.ts b/src/core/tools/ExecuteCommandTool.ts index f2fc4889f8..75fa664f0b 100644 --- a/src/core/tools/ExecuteCommandTool.ts +++ b/src/core/tools/ExecuteCommandTool.ts @@ -47,7 +47,7 @@ export function getTerminalProviderForExecution(terminalShellIntegrationDisabled interface ExecuteCommandParams { command: string cwd?: string - timeout?: number | null + timeout?: number } export function formatDcgBlockedMessage(reason?: string, ruleId?: string): string { diff --git a/src/core/tools/GenerateImageTool.ts b/src/core/tools/GenerateImageTool.ts index c32fc85bf1..b036a71977 100644 --- a/src/core/tools/GenerateImageTool.ts +++ b/src/core/tools/GenerateImageTool.ts @@ -242,8 +242,6 @@ export class GenerateImageTool extends BaseTool<"generate_image"> { task.didEditFile = true - task.recordToolUsage("generate_image") - const fullImagePath = path.join(task.cwd, finalPath) let imageUri = provider?.convertToWebviewUri?.(fullImagePath) ?? vscode.Uri.file(fullImagePath).toString() diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index 2d8817364f..e29b124010 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -225,8 +225,6 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, false) pushToolResult(message) - // Record successful tool usage and cleanup - task.recordToolUsage("search_replace") await task.diffViewProvider.reset() this.resetPartialState() diff --git a/src/core/tools/UseMcpToolTool.ts b/src/core/tools/UseMcpToolTool.ts index da5ceb9403..9b2870060c 100644 --- a/src/core/tools/UseMcpToolTool.ts +++ b/src/core/tools/UseMcpToolTool.ts @@ -15,6 +15,18 @@ interface UseMcpToolParams { arguments?: Record } +/** + * Extends the shared callbacks with a hook invoked once this tool's own + * internal validation (params, tool existence, server allow-list) has + * passed, before side-effecting execution begins. Native MCP tool calls + * (presentAssistantMessage's mcp_tool_use branch) use this to defer telemetry + * attribution past validation that lives inside this class rather than the + * shared validateToolUse path. + */ +export interface UseMcpToolCallbacks extends ToolCallbacks { + onValidated?: () => void +} + type ValidationResult = | { isValid: false } | { @@ -27,8 +39,8 @@ type ValidationResult = export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { readonly name = "use_mcp_tool" as const - async execute(params: UseMcpToolParams, task: Task, callbacks: ToolCallbacks): Promise { - const { askApproval, handleError, pushToolResult } = callbacks + async execute(params: UseMcpToolParams, task: Task, callbacks: UseMcpToolCallbacks): Promise { + const { askApproval, handleError, pushToolResult, onValidated } = callbacks try { // Validate parameters @@ -66,6 +78,10 @@ export class UseMcpToolTool extends BaseTool<"use_mcp_tool"> { // Reset mistake count on successful validation task.consecutiveMistakeCount = 0 + // All internal validation (params, tool existence, server allow-list) has + // passed. Only now is it safe to attribute this as an attempted tool use. + onValidated?.() + // Get user approval const completeMessage = JSON.stringify({ type: "use_mcp_tool", diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts new file mode 100644 index 0000000000..72ffb112bc --- /dev/null +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -0,0 +1,96 @@ +// npx vitest run core/tools/__tests__/applyPatchTool.execute.spec.ts + +import type { MockedFunction } from "vitest" + +import { fileExistsAtPath } from "../../../utils/fs" +import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import type { Task } from "../../task/Task" +import { ApplyPatchTool } from "../ApplyPatchTool" + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original file content\n"), + unlink: vi.fn().mockResolvedValue(undefined), + }, +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../../utils/pathUtils", () => ({ + isPathOutsideWorkspace: vi.fn().mockReturnValue(false), +})) + +describe("ApplyPatchTool.execute - delete file success path", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction + + let tool: ApplyPatchTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "recordToolUsage" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + > + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + recordToolUsage: vi.fn(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyPatchTool() + }) + + it("deletes the file and records no local tool usage on success", async () => { + const patch = `*** Begin Patch +*** Delete File: src/obsolete.ts +*** End Patch` + + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + expect(mockAskApproval).toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + + // Usage is recorded once at the central presentAssistantMessage + // attribution point, not locally by the handler. + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + expect(mockTask.recordToolError).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 0e7343905e..1ff8d52a8d 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -560,7 +560,9 @@ describe("editFileTool", () => { expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() expect(mockTask.didEditFile).toBe(true) - expect(mockTask.recordToolUsage).toHaveBeenCalledWith("edit_file") + // Usage is recorded once at the central presentAssistantMessage + // attribution point, not locally by the handler. + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() }) it("reverts changes when user rejects", async () => { diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index cbf635554c..a5f665b9e5 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -345,7 +345,9 @@ describe("editTool", () => { expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() expect(mockTask.didEditFile).toBe(true) - expect(mockTask.recordToolUsage).toHaveBeenCalledWith("edit") + // Usage is recorded once at the central presentAssistantMessage + // attribution point, not locally by the handler. + expect(mockTask.recordToolUsage).not.toHaveBeenCalled() }) it("reverts changes when user rejects", async () => { diff --git a/src/core/tools/__tests__/generateImageTool.test.ts b/src/core/tools/__tests__/generateImageTool.test.ts index ca12cc6d24..4436c55e87 100644 --- a/src/core/tools/__tests__/generateImageTool.test.ts +++ b/src/core/tools/__tests__/generateImageTool.test.ts @@ -163,6 +163,9 @@ describe("generateImageTool", () => { expect(mockAskApproval).toHaveBeenCalled() expect(mockGenerateImage).toHaveBeenCalled() expect(mockPushToolResult).toHaveBeenCalled() + // Usage is recorded once at the central presentAssistantMessage + // attribution point, not locally by the handler. + expect(mockCline.recordToolUsage).not.toHaveBeenCalled() }) it("should add cache-busting parameter to image URI", async () => { diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index ed08f9acbd..5cf10790d4 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -314,7 +314,9 @@ describe("searchReplaceTool", () => { expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() expect(mockCline.didEditFile).toBe(true) - expect(mockCline.recordToolUsage).toHaveBeenCalledWith("search_replace") + // Usage is recorded once at the central presentAssistantMessage + // attribution point, not locally by the handler. + expect(mockCline.recordToolUsage).not.toHaveBeenCalled() }) it("reverts changes when user rejects", async () => { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 206d6ca611..2263257cd6 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1438,7 +1438,7 @@ export class ClineProvider `style-src ${webview.cspSource} 'unsafe-inline' https://* http://${localServerUrl} http://0.0.0.0:${localPort}`, `img-src ${webview.cspSource} https://storage.googleapis.com https://img.clerk.com https://avatars.githubusercontent.com https://lh3.googleusercontent.com data:`, `media-src ${webview.cspSource}`, - `script-src 'unsafe-eval' ${webview.cspSource} https://* https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`, + `script-src 'unsafe-eval' ${webview.cspSource} https://*.posthog.com http://${localServerUrl} http://0.0.0.0:${localPort} 'nonce-${nonce}'`, `connect-src ${webview.cspSource} ${openRouterDomain} https://* https://*.posthog.com ws://${localServerUrl} ws://0.0.0.0:${localPort} http://${localServerUrl} http://0.0.0.0:${localPort}`, ] diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 53d5ba4441..3639de52f4 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -179,11 +179,6 @@ "count": 3 } }, - "api/providers/__tests__/mimo.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 18 - } - }, "api/providers/__tests__/minimax.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 @@ -379,11 +374,6 @@ "count": 2 } }, - "api/providers/mimo.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 5 - } - }, "api/providers/moonshot.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 @@ -591,7 +581,7 @@ }, "core/assistant-message/presentAssistantMessage.ts": { "@typescript-eslint/no-explicit-any": { - "count": 7 + "count": 3 } }, "core/auto-approval/__tests__/AutoApprovalHandler.spec.ts": { @@ -659,16 +649,6 @@ "count": 1 } }, - "core/config/__tests__/CustomModesSettings.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 2 - } - }, - "core/config/__tests__/ModeConfig.spec.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 6 - } - }, "core/config/__tests__/ProviderSettingsManager.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts index c7f3ee2145..8292875b87 100644 --- a/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts +++ b/src/integrations/terminal/__tests__/ExecaTerminalProcess.spec.ts @@ -28,6 +28,8 @@ import { ExecaTerminalProcess } from "../ExecaTerminalProcess" import { BaseTerminal } from "../BaseTerminal" import type { RooTerminal } from "../types" +import { clearAllMocks } from "../../../test-utils/reset" + describe("ExecaTerminalProcess", () => { let mockTerminal: RooTerminal let terminalProcess: ExecaTerminalProcess @@ -56,7 +58,7 @@ describe("ExecaTerminalProcess", () => { afterEach(() => { process.env = originalEnv - vitest.clearAllMocks() + clearAllMocks() }) describe("UTF-8 encoding fix", () => { diff --git a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts index ed308cff13..ace293e811 100644 --- a/src/integrations/terminal/__tests__/OutputInterceptor.test.ts +++ b/src/integrations/terminal/__tests__/OutputInterceptor.test.ts @@ -2,6 +2,7 @@ import * as fs from "fs" import * as path from "path" import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" +import { clearAllMocks, restoreGlobals } from "../../../test-utils/reset" import { OutputInterceptor } from "../OutputInterceptor" import { TerminalOutputPreviewSize } from "@roo-code/types" @@ -30,7 +31,7 @@ describe("OutputInterceptor", () => { let storageDir: string beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() storageDir = path.normalize("/tmp/test-storage") @@ -49,7 +50,7 @@ describe("OutputInterceptor", () => { }) afterEach(() => { - vi.restoreAllMocks() + restoreGlobals() }) describe("Buffering behavior", () => { diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts index 8e9af919ea..487e6273d5 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.bash.spec.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode" import { execSync } from "child_process" +import { clearAllMocks } from "../../../test-utils/reset" import { ExitCodeDetails } from "../types" import { TerminalProcess } from "../TerminalProcess" import { Terminal } from "../Terminal" @@ -298,7 +299,7 @@ describe("TerminalProcess with Bash Command Output", () => { beforeEach(() => { // Reset the terminals array before each test TerminalRegistry["terminals"] = [] - vi.clearAllMocks() + clearAllMocks() }) // Each test uses Bash-specific commands to test the same functionality diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts index e129160731..2505cf2c4a 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.cmd.spec.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode" +import { clearAllMocks } from "../../../test-utils/reset" import { ExitCodeDetails } from "../types" import { TerminalProcess } from "../TerminalProcess" import { Terminal } from "../Terminal" @@ -251,7 +252,7 @@ describePlatform("TerminalProcess with CMD Command Output", () => { beforeEach(() => { // Reset state between tests TerminalRegistry["terminals"] = [] - vi.clearAllMocks() + clearAllMocks() }) // Each test uses CMD-specific commands to test the same functionality diff --git a/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts b/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts index 6f82634110..fa9b0d0549 100644 --- a/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProcessExec.pwsh.spec.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode" +import { clearAllMocks } from "../../../test-utils/reset" import { ExitCodeDetails } from "../types" import { TerminalProcess } from "../TerminalProcess" import { Terminal } from "../Terminal" @@ -245,7 +246,7 @@ describePlatform("TerminalProcess with PowerShell Command Output", () => { beforeEach(() => { // Reset state between tests TerminalRegistry["terminals"] = [] - vi.clearAllMocks() + clearAllMocks() }) // Each test uses PowerShell-specific commands to test the same functionality diff --git a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts index 00cfaa3bc9..2510b5b89d 100644 --- a/src/integrations/terminal/__tests__/TerminalProfile.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalProfile.spec.ts @@ -4,6 +4,7 @@ import { existsSync } from "fs" import * as vscode from "vscode" +import { restoreGlobals } from "../../../test-utils/reset" import { Terminal } from "../Terminal" import { TerminalRegistry } from "../TerminalRegistry" import { ShellIntegrationManager } from "../ShellIntegrationManager" @@ -73,7 +74,7 @@ describe("Terminal VS Code terminal profile (#277)", () => { afterEach(() => { Terminal.setTerminalProfile(undefined) - vi.restoreAllMocks() + restoreGlobals() }) describe("getTerminalProfile / setTerminalProfile", () => { @@ -748,7 +749,7 @@ describe("Terminal VS Code terminal profile (#277)", () => { Terminal.setTerminalZdotdir(false) Terminal.setTerminalProfile(undefined) TerminalRegistry["terminals"] = [] - vi.restoreAllMocks() + restoreGlobals() }) it("sets ZDOTDIR when zdotdir is enabled and no profile is configured", () => { diff --git a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts index dbfb362d52..f60c0d0722 100644 --- a/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts +++ b/src/integrations/terminal/__tests__/TerminalRegistry.spec.ts @@ -1,6 +1,8 @@ // npx vitest run src/integrations/terminal/__tests__/TerminalRegistry.spec.ts import * as vscode from "vscode" + +import { restoreGlobals } from "../../../test-utils/reset" import { ExecaTerminal } from "../ExecaTerminal" import { ShellIntegrationManager } from "../ShellIntegrationManager" import { Terminal } from "../Terminal" @@ -44,7 +46,7 @@ describe("TerminalRegistry", () => { afterEach(() => { TerminalRegistry["terminals"] = [] Terminal.setTerminalProfile(undefined) - vi.restoreAllMocks() + restoreGlobals() }) describe("createTerminal", () => { diff --git a/src/services/code-index/__tests__/cache-manager.spec.ts b/src/services/code-index/__tests__/cache-manager.spec.ts index 5b9c17e36f..77bd5be198 100644 --- a/src/services/code-index/__tests__/cache-manager.spec.ts +++ b/src/services/code-index/__tests__/cache-manager.spec.ts @@ -4,6 +4,8 @@ import { createHash } from "crypto" import debounce from "lodash.debounce" import { CacheManager } from "../cache-manager" +import { clearAllMocks } from "../../../test-utils/reset" + // Mock safeWriteJson utility vitest.mock("../../../utils/safeWriteJson", () => ({ safeWriteJson: vitest.fn().mockResolvedValue(undefined), @@ -50,7 +52,7 @@ describe("CacheManager", () => { beforeEach(() => { // Reset all mocks - vitest.clearAllMocks() + clearAllMocks() // Mock context mockWorkspacePath = "/mock/workspace" diff --git a/src/services/code-index/__tests__/config-manager.spec.ts b/src/services/code-index/__tests__/config-manager.spec.ts index 1839eb464f..665c83314f 100644 --- a/src/services/code-index/__tests__/config-manager.spec.ts +++ b/src/services/code-index/__tests__/config-manager.spec.ts @@ -3,6 +3,8 @@ import { CodeIndexConfigManager } from "../config-manager" import { PreviousConfigSnapshot } from "../interfaces/config" +import { clearAllMocks } from "../../../test-utils/reset" + // Mock ContextProxy vi.mock("../../../core/config/ContextProxy") @@ -23,7 +25,7 @@ describe("CodeIndexConfigManager", () => { beforeEach(() => { // Reset mocks - vi.clearAllMocks() + clearAllMocks() // Setup mock ContextProxy mockContextProxy = { @@ -1790,7 +1792,7 @@ describe("CodeIndexConfigManager", () => { describe("currentModelDimension", () => { beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() }) it("should return model's built-in dimension when available", async () => { diff --git a/src/services/code-index/__tests__/orchestrator.spec.ts b/src/services/code-index/__tests__/orchestrator.spec.ts index db98e0eb20..86b0f94808 100644 --- a/src/services/code-index/__tests__/orchestrator.spec.ts +++ b/src/services/code-index/__tests__/orchestrator.spec.ts @@ -1,6 +1,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest" import { CodeIndexOrchestrator } from "../orchestrator" +import { clearAllMocks } from "../../../test-utils/reset" + // Mock vscode workspace so startIndexing passes workspace check vi.mock("vscode", () => { const path = require("path") @@ -60,7 +62,7 @@ describe("CodeIndexOrchestrator - error path cleanup gating", () => { let fileWatcher: any beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() configManager = { isFeatureConfigured: true, @@ -234,7 +236,7 @@ describe("CodeIndexOrchestrator - stopIndexing", () => { let fileWatcher: any beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() configManager = { isFeatureConfigured: true, diff --git a/src/services/code-index/__tests__/service-factory.spec.ts b/src/services/code-index/__tests__/service-factory.spec.ts index 627617a2b6..aafc198d85 100644 --- a/src/services/code-index/__tests__/service-factory.spec.ts +++ b/src/services/code-index/__tests__/service-factory.spec.ts @@ -6,6 +6,8 @@ import { OpenAICompatibleEmbedder } from "../embedders/openai-compatible" import { GeminiEmbedder } from "../embedders/gemini" import { QdrantVectorStore } from "../vector-store/qdrant-client" +import { clearAllMocks } from "../../../test-utils/reset" + // Mock the embedders and vector store vitest.mock("../embedders/openai") vitest.mock("../embedders/ollama") @@ -45,7 +47,7 @@ describe("CodeIndexServiceFactory", () => { let mockCacheManager: any beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() mockConfigManager = { getConfig: vitest.fn(), @@ -371,7 +373,7 @@ describe("CodeIndexServiceFactory", () => { describe("createVectorStore", () => { beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() mockGetDefaultModelId.mockReturnValue("default-model") }) diff --git a/src/services/code-index/embedders/__tests__/bedrock.spec.ts b/src/services/code-index/embedders/__tests__/bedrock.spec.ts index 76e39dc3a2..dfa9544715 100644 --- a/src/services/code-index/embedders/__tests__/bedrock.spec.ts +++ b/src/services/code-index/embedders/__tests__/bedrock.spec.ts @@ -4,6 +4,8 @@ import { BedrockRuntimeClient, InvokeModelCommand } from "@aws-sdk/client-bedroc import { BedrockEmbedder } from "../bedrock" import { MAX_ITEM_TOKENS, INITIAL_RETRY_DELAY_MS } from "../../constants" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mock the AWS SDK vitest.mock("@aws-sdk/client-bedrock-runtime", () => { return { @@ -68,7 +70,7 @@ describe("BedrockEmbedder", () => { let mockSend: MockedFunction beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() consoleMocks.error.mockClear() consoleMocks.warn.mockClear() @@ -86,7 +88,7 @@ describe("BedrockEmbedder", () => { }) afterEach(() => { - vitest.clearAllMocks() + clearAllMocks() }) describe("constructor", () => { diff --git a/src/services/code-index/embedders/__tests__/gemini.spec.ts b/src/services/code-index/embedders/__tests__/gemini.spec.ts index d84dcd8abc..202a44197d 100644 --- a/src/services/code-index/embedders/__tests__/gemini.spec.ts +++ b/src/services/code-index/embedders/__tests__/gemini.spec.ts @@ -3,6 +3,8 @@ import type { MockedClass } from "vitest" import { GeminiEmbedder } from "../gemini" import { OpenAICompatibleEmbedder } from "../openai-compatible" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mock the OpenAICompatibleEmbedder vitest.mock("../openai-compatible") @@ -21,7 +23,7 @@ describe("GeminiEmbedder", () => { let embedder: GeminiEmbedder beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() }) describe("constructor", () => { diff --git a/src/services/code-index/embedders/__tests__/mistral.spec.ts b/src/services/code-index/embedders/__tests__/mistral.spec.ts index 82c7410274..a0d52fbac9 100644 --- a/src/services/code-index/embedders/__tests__/mistral.spec.ts +++ b/src/services/code-index/embedders/__tests__/mistral.spec.ts @@ -3,6 +3,8 @@ import type { MockedClass } from "vitest" import { MistralEmbedder } from "../mistral" import { OpenAICompatibleEmbedder } from "../openai-compatible" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mock the OpenAICompatibleEmbedder vitest.mock("../openai-compatible") @@ -21,7 +23,7 @@ describe("MistralEmbedder", () => { let embedder: MistralEmbedder beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() }) describe("constructor", () => { diff --git a/src/services/code-index/embedders/__tests__/ollama.spec.ts b/src/services/code-index/embedders/__tests__/ollama.spec.ts index 744816d11a..40f745b70a 100644 --- a/src/services/code-index/embedders/__tests__/ollama.spec.ts +++ b/src/services/code-index/embedders/__tests__/ollama.spec.ts @@ -2,6 +2,8 @@ import type { MockedFunction } from "vitest" import { CodeIndexOllamaEmbedder } from "../ollama" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mock fetch global.fetch = vitest.fn() as MockedFunction @@ -56,7 +58,7 @@ describe("CodeIndexOllamaEmbedder", () => { let mockFetch: MockedFunction beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() consoleMocks.error.mockClear() mockFetch = global.fetch as MockedFunction @@ -68,7 +70,7 @@ describe("CodeIndexOllamaEmbedder", () => { }) afterEach(() => { - vitest.clearAllMocks() + clearAllMocks() }) describe("constructor", () => { diff --git a/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts index 7ec4324138..49086d675b 100644 --- a/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai-compatible-rate-limit.spec.ts @@ -2,6 +2,7 @@ import type { MockedClass, MockedFunction } from "vitest" import { OpenAI } from "openai" import { OpenAICompatibleEmbedder } from "../openai-compatible" +import { clearAllMocks, restoreGlobals } from "../../../../test-utils/reset" // Mock the OpenAI SDK vi.mock("openai") @@ -39,7 +40,7 @@ describe("OpenAICompatibleEmbedder - Global Rate Limiting", () => { const testModelId = "text-embedding-3-small" beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() vi.useFakeTimers() vi.spyOn(console, "warn").mockImplementation(function () {}) vi.spyOn(console, "error").mockImplementation(function () {}) @@ -69,7 +70,7 @@ describe("OpenAICompatibleEmbedder - Global Rate Limiting", () => { afterEach(() => { vi.useRealTimers() - vi.restoreAllMocks() + restoreGlobals() }) it("should apply global rate limiting across multiple batch requests", async () => { diff --git a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts index c36ae48d0a..627c642432 100644 --- a/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai-compatible.spec.ts @@ -2,6 +2,7 @@ import type { MockedClass, MockedFunction } from "vitest" import { OpenAI } from "openai" import { OpenAICompatibleEmbedder } from "../openai-compatible" import { MAX_ITEM_TOKENS, INITIAL_RETRY_DELAY_MS } from "../../constants" +import { clearAllMocks, restoreGlobals } from "../../../../test-utils/reset" // Mock the OpenAI SDK vitest.mock("openai") @@ -62,7 +63,7 @@ describe("OpenAICompatibleEmbedder", () => { const testModelId = "text-embedding-3-small" beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() vitest.spyOn(console, "warn").mockImplementation(function () {}) vitest.spyOn(console, "error").mockImplementation(function () {}) @@ -90,7 +91,7 @@ describe("OpenAICompatibleEmbedder", () => { }) afterEach(() => { - vitest.restoreAllMocks() + restoreGlobals() }) describe("constructor", () => { @@ -706,7 +707,7 @@ describe("OpenAICompatibleEmbedder", () => { } beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() ;(global.fetch as MockedFunction).mockReset() }) @@ -802,7 +803,7 @@ describe("OpenAICompatibleEmbedder", () => { expectEmbeddingValues(azureResult.embeddings[0], [0.1, 0.2, 0.3]) // Reset and test base URL (SDK) - vitest.clearAllMocks() + clearAllMocks() const baseEmbedder = new OpenAICompatibleEmbedder(baseUrl, testApiKey, testModelId) mockEmbeddingsCreate.mockResolvedValue({ data: [{ embedding: [0.4, 0.5, 0.6] }], @@ -961,7 +962,7 @@ describe("OpenAICompatibleEmbedder", () => { let mockFetch: MockedFunction beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() // Reset and re-assign the global fetch mock global.fetch = vitest.fn() mockFetch = global.fetch as MockedFunction diff --git a/src/services/code-index/embedders/__tests__/openai.spec.ts b/src/services/code-index/embedders/__tests__/openai.spec.ts index be37efb03b..af402f0766 100644 --- a/src/services/code-index/embedders/__tests__/openai.spec.ts +++ b/src/services/code-index/embedders/__tests__/openai.spec.ts @@ -3,6 +3,7 @@ import { OpenAI } from "openai" import { OpenAiEmbedder } from "../openai" import { MAX_ITEM_TOKENS, INITIAL_RETRY_DELAY_MS } from "../../constants" +import { clearAllMocks } from "../../../../test-utils/reset" // Mock the OpenAI SDK vitest.mock("openai") @@ -44,7 +45,7 @@ describe("OpenAiEmbedder", () => { let MockedOpenAI: MockedClass beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() consoleMocks.error.mockClear() consoleMocks.warn.mockClear() @@ -62,7 +63,7 @@ describe("OpenAiEmbedder", () => { }) afterEach(() => { - vitest.clearAllMocks() + clearAllMocks() }) describe("constructor", () => { diff --git a/src/services/code-index/embedders/__tests__/openrouter.spec.ts b/src/services/code-index/embedders/__tests__/openrouter.spec.ts index fc44002fbf..088e9c7185 100644 --- a/src/services/code-index/embedders/__tests__/openrouter.spec.ts +++ b/src/services/code-index/embedders/__tests__/openrouter.spec.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, vi } from "vitest" import { OpenAI } from "openai" import { OpenRouterEmbedder, OPENROUTER_DEFAULT_PROVIDER_NAME } from "../openrouter" import { getModelDimension, getDefaultModelId } from "../../../../shared/embeddingModels" +import { clearAllMocks, restoreGlobals } from "../../../../test-utils/reset" // Mock the OpenAI SDK vi.mock("openai") @@ -42,7 +43,7 @@ describe("OpenRouterEmbedder", () => { let mockOpenAIInstance: any beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() vi.spyOn(console, "warn").mockImplementation(function () {}) vi.spyOn(console, "error").mockImplementation(function () {}) @@ -60,7 +61,7 @@ describe("OpenRouterEmbedder", () => { }) afterEach(() => { - vi.restoreAllMocks() + restoreGlobals() }) describe("constructor", () => { diff --git a/src/services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts b/src/services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts index 1cab590f0b..52cadf3eef 100644 --- a/src/services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts +++ b/src/services/code-index/embedders/__tests__/vercel-ai-gateway.spec.ts @@ -3,6 +3,8 @@ import { VercelAiGatewayEmbedder } from "../vercel-ai-gateway" import { OpenAICompatibleEmbedder } from "../openai-compatible" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mock the OpenAICompatibleEmbedder vi.mock("../openai-compatible", () => ({ OpenAICompatibleEmbedder: vi.fn(), @@ -24,7 +26,7 @@ describe("VercelAiGatewayEmbedder", () => { let mockOpenAICompatibleEmbedder: any beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() mockOpenAICompatibleEmbedder = { createEmbeddings: vi.fn(), validateConfiguration: vi.fn(), diff --git a/src/services/code-index/processors/__tests__/file-watcher.spec.ts b/src/services/code-index/processors/__tests__/file-watcher.spec.ts index 8c85790921..fc61e687bd 100644 --- a/src/services/code-index/processors/__tests__/file-watcher.spec.ts +++ b/src/services/code-index/processors/__tests__/file-watcher.spec.ts @@ -4,6 +4,8 @@ import * as vscode from "vscode" import { FileWatcher } from "../file-watcher" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mock TelemetryService vi.mock("../../../../../packages/telemetry/src/TelemetryService", () => ({ TelemetryService: { @@ -111,7 +113,7 @@ describe("FileWatcher", () => { beforeEach(() => { // Reset all mocks - vi.clearAllMocks() + clearAllMocks() vi.useFakeTimers() // Create mock event handlers diff --git a/src/services/code-index/processors/__tests__/parser.spec.ts b/src/services/code-index/processors/__tests__/parser.spec.ts index 1c8154e03f..7c640c0224 100644 --- a/src/services/code-index/processors/__tests__/parser.spec.ts +++ b/src/services/code-index/processors/__tests__/parser.spec.ts @@ -6,6 +6,8 @@ import { parseMarkdown } from "../../../tree-sitter/markdownParser" import { readFile } from "fs/promises" import { Node } from "web-tree-sitter" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mock TelemetryService vi.mock("../../../../../packages/telemetry/src/TelemetryService", () => ({ TelemetryService: { @@ -58,7 +60,7 @@ describe("CodeParser", () => { let parser: CodeParser beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() parser = new CodeParser() ;(loadRequiredLanguageParsers as any).mockResolvedValue(mockLanguageParser as any) // Set up default fs.readFile mock return value @@ -325,7 +327,7 @@ describe("CodeParser", () => { describe("Markdown Support", () => { beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() }) it("should generate unique segment hashes for each markdown block", async () => { @@ -917,7 +919,7 @@ This content verifies that processing continues after multiple oversized lines.` describe("Edge case: Single oversized line in markdown", () => { beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() }) it("should properly chunk a markdown file with a single very long line", async () => { diff --git a/src/services/code-index/semble/__tests__/provider.spec.ts b/src/services/code-index/semble/__tests__/provider.spec.ts index ce09035a28..ecf5fea520 100644 --- a/src/services/code-index/semble/__tests__/provider.spec.ts +++ b/src/services/code-index/semble/__tests__/provider.spec.ts @@ -4,6 +4,8 @@ import { SembleProvider } from "../provider" import { SembleCLI } from "../semble-cli" import { SEMBLE_DEFAULTS } from "../types" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mock SembleCLI - use a shared mock instance const sharedMockCli = { checkInstalled: vi.fn(), @@ -72,7 +74,7 @@ describe("SembleProvider", () => { let mockContext: any beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() ;(isSembleSupportedPlatform as any).mockReturnValue(true) ;(downloadSemble as any).mockResolvedValue("/mock/storage/semble/semble") diff --git a/src/services/code-index/semble/__tests__/semble-cli.spec.ts b/src/services/code-index/semble/__tests__/semble-cli.spec.ts index e9b7fd4594..6d9dd054cd 100644 --- a/src/services/code-index/semble/__tests__/semble-cli.spec.ts +++ b/src/services/code-index/semble/__tests__/semble-cli.spec.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { EventEmitter } from "events" import { SembleCLI } from "../semble-cli" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mock spawn const mockSpawn = vi.fn() @@ -46,7 +48,7 @@ describe("SembleCLI", () => { let cli: SembleCLI beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() cli = new SembleCLI("semble") }) diff --git a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts index e487cffbbc..29fa8c1859 100644 --- a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts +++ b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts @@ -3,6 +3,8 @@ import * as fs from "fs/promises" import * as path from "path" import { EventEmitter } from "events" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mock crypto — verifyChecksum reads the archive file (mocked via createReadStream) // and computes a SHA-256. We make digest() dynamically return the expected checksum // for the current process.platform/arch so verification always passes in unit tests. @@ -111,7 +113,7 @@ describe("SEMBLE_SHA256 checksum fixture", () => { describe("semble-downloader", () => { beforeEach(() => { - vi.clearAllMocks() + clearAllMocks() closeHandler = undefined mockWriteStream.on = vi.fn(onWriteStreamEvent) mockWriteStream.close = vi.fn(() => closeHandler?.()) diff --git a/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts b/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts index c20f438b6d..80a1fca835 100644 --- a/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts +++ b/src/services/code-index/vector-store/__tests__/qdrant-client.spec.ts @@ -5,6 +5,8 @@ import { QdrantVectorStore } from "../qdrant-client" import { getWorkspacePath } from "../../../../utils/path" import { DEFAULT_MAX_SEARCH_RESULTS, DEFAULT_SEARCH_MIN_SCORE } from "../../constants" +import { clearAllMocks } from "../../../../test-utils/reset" + // Mocks vitest.mock("@qdrant/js-client-rest") vitest.mock("crypto") @@ -55,7 +57,7 @@ describe("QdrantVectorStore", () => { const expectedCollectionName = `ws-${mockHashedPath.substring(0, 16)}` beforeEach(() => { - vitest.clearAllMocks() + clearAllMocks() // Mock QdrantClient constructor ;(QdrantClient as any).mockImplementation(function () { diff --git a/src/shared/tools.ts b/src/shared/tools.ts index d2dd9907b1..90b6b5fae9 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -94,7 +94,7 @@ export type NativeToolArgs = { read_file: import("@roo-code/types").ReadFileToolParams read_command_output: { artifact_id: string; search?: string; offset?: number; limit?: number } attempt_completion: { result: string } - execute_command: { command: string; cwd?: string; timeout?: number | null } + execute_command: { command: string; cwd?: string; timeout?: number } apply_diff: { path: string; diff: string } edit: { file_path: string; old_string: string; new_string: string; replace_all?: boolean } search_and_replace: { file_path: string; old_string: string; new_string: string; replace_all?: boolean } @@ -290,6 +290,7 @@ export const TOOL_DISPLAY_NAMES: Record = { skill: "load skill", generate_image: "generate images", custom_tool: "use custom tools", + invalid_tool_call: "invalid tool call", } as const // Define available tool groups. diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.command-denied.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.command-denied.spec.tsx index f23dce77ab..014f3a04c0 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.command-denied.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.command-denied.spec.tsx @@ -1,8 +1,6 @@ import React from "react" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { render, screen } from "@/utils/test-utils" -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { renderWithExtensionState, screen } from "@/utils/test-utils" import { ChatRowContent } from "../ChatRow" @@ -26,30 +24,24 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ })) const renderCommand = (autoApprovalDecision?: "approve" | "deny") => { - const queryClient = new QueryClient() - - return render( - - - - - , + return renderWithExtensionState( + , ) } diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx index 7876420959..cbddb45417 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.diff-actions.spec.tsx @@ -1,8 +1,6 @@ import React from "react" -import { fireEvent, render, screen } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { fireEvent, renderWithExtensionState, screen } from "@/utils/test-utils" import type { ClineMessage } from "@roo-code/types" -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" import { ChatRowContent } from "../ChatRow" const mockPostMessage = vi.fn() @@ -35,8 +33,6 @@ vi.mock("@src/components/common/CodeBlock", () => ({ default: () => null, })) -const queryClient = new QueryClient() - function createToolAskMessage(toolPayload: Record): ClineMessage { return { type: "ask", @@ -48,22 +44,18 @@ function createToolAskMessage(toolPayload: Record): ClineMessag } function renderChatRow(message: ClineMessage, isExpanded = false) { - return render( - - - {}} - onSuggestionClick={() => {}} - onBatchFileResponse={() => {}} - onFollowUpUnmount={() => {}} - isFollowUpAnswered={false} - /> - - , + return renderWithExtensionState( + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + />, ) } diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx index 5ff857e1bd..54193bd223 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.rate-limit-wait.spec.tsx @@ -1,8 +1,6 @@ import React from "react" -import { render, screen } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { renderWithExtensionState, screen } from "@/utils/test-utils" import { ChatRowContent } from "../ChatRow" // Mock i18n @@ -26,25 +24,19 @@ vi.mock("react-i18next", () => ({ initReactI18next: { type: "3rdParty", init: () => {} }, })) -const queryClient = new QueryClient() - function renderChatRow(message: any) { - return render( - - - {}} - onSuggestionClick={() => {}} - onBatchFileResponse={() => {}} - onFollowUpUnmount={() => {}} - isFollowUpAnswered={false} - /> - - , + return renderWithExtensionState( + {}} + onSuggestionClick={() => {}} + onBatchFileResponse={() => {}} + onFollowUpUnmount={() => {}} + isFollowUpAnswered={false} + />, ) } diff --git a/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx index 3f54bec115..a346e1b56c 100644 --- a/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatRow.run-slash-command.spec.tsx @@ -1,8 +1,6 @@ import React from "react" -import { render } from "@/utils/test-utils" +import { renderWithExtensionState } from "@/utils/test-utils" import { describe, it, expect, beforeEach, vi } from "vitest" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" import { ChatRowContent } from "../ChatRow" // Mock i18n @@ -30,25 +28,19 @@ vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeBadge: ({ children, ...props }: { children: React.ReactNode }) => {children}, })) -const queryClient = new QueryClient() - const renderChatRowWithProviders = (message: any, isExpanded = false) => { - return render( - - - - - , + return renderWithExtensionState( + , ) } diff --git a/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx index 43450f6302..14ccce9751 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx @@ -1,10 +1,8 @@ // pnpm --filter @roo-code/vscode-webview test src/components/chat/__tests__/ChatView.clear-approval-buttons.spec.tsx import React from "react" -import { render, waitFor, act, fireEvent } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderWithExtensionState, waitFor, act, fireEvent } from "@/utils/test-utils" -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" import ChatView, { ChatViewProps } from "../ChatView" @@ -138,16 +136,8 @@ const defaultProps: ChatViewProps = { hideAnnouncement: () => {}, } -const queryClient = new QueryClient() - const renderChatView = (props: Partial = {}) => - render( - - - - - , - ) + renderWithExtensionState() const commandAsk = (): ClineMessage[] => [ { type: "say", say: "task", ts: 1, text: "Initial task" }, diff --git a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx index 78dcce08ae..8f2a2c8fb2 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx @@ -1,10 +1,8 @@ // npx vitest run src/components/chat/__tests__/ChatView.keyboard-fix.spec.tsx import React from "react" -import { render, fireEvent } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderWithExtensionState, fireEvent } from "@/utils/test-utils" -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" import { vscode } from "@src/utils/vscode" import ChatView, { ChatViewProps } from "../ChatView" @@ -120,16 +118,8 @@ const defaultProps: ChatViewProps = { hideAnnouncement: () => {}, } -const queryClient = new QueryClient() - const renderChatView = (props: Partial = {}) => { - return render( - - - - - , - ) + return renderWithExtensionState() } describe("ChatView - Keyboard Shortcut Fix for Dvorak", () => { diff --git a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx index 581fb95041..162fc601d8 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.notification-sound.spec.tsx @@ -1,10 +1,7 @@ // npx vitest run src/components/chat/__tests__/ChatView.notification-sound.spec.tsx import React from "react" -import { render, waitFor } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" - -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { renderWithExtensionState, waitFor } from "@/utils/test-utils" import ChatView, { ChatViewProps } from "../ChatView" @@ -257,16 +254,8 @@ const defaultProps: ChatViewProps = { hideAnnouncement: () => {}, } -const queryClient = new QueryClient() - const renderChatView = (props: Partial = {}) => { - return render( - - - - - , - ) + return renderWithExtensionState() } describe("ChatView - Notification Sound with Queued Messages", () => { diff --git a/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx index 23ed8dd35a..99eedaf229 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.preserve-images.spec.tsx @@ -1,10 +1,7 @@ // npx vitest run src/components/chat/__tests__/ChatView.preserve-images.spec.tsx import React from "react" -import { render, waitFor, act } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" - -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" +import { renderWithExtensionState, waitFor, act } from "@/utils/test-utils" import ChatView, { ChatViewProps } from "../ChatView" @@ -244,16 +241,8 @@ const defaultProps: ChatViewProps = { hideAnnouncement: () => {}, } -const queryClient = new QueryClient() - const renderChatView = (props: Partial = {}) => { - return render( - - - - - , - ) + return renderWithExtensionState() } describe("ChatView - Preserve Images During Chat Activity", () => { diff --git a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx index 12f98898f4..774e219193 100644 --- a/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ChatView.scroll-debug-repro.spec.tsx @@ -1,11 +1,8 @@ import React, { useEffect, useImperativeHandle, useRef } from "react" -import { act, fireEvent, render } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { act, fireEvent, renderWithExtensionState } from "@/utils/test-utils" import type { ClineMessage } from "@roo-code/types" -import { ExtensionStateContextProvider } from "@src/context/ExtensionStateContext" - import ChatView, { type ChatViewProps } from "../ChatView" type FollowOutput = ((isAtBottom: boolean) => "auto" | false) | "auto" | false @@ -258,14 +255,7 @@ const postState = (clineMessages: ClineMessage[]) => { ) } -const renderView = () => - render( - - - - - , - ) +const renderView = () => renderWithExtensionState() const flushEffects = async () => { await act(async () => { diff --git a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx index 9d23ca6886..2a302e6b18 100644 --- a/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/TaskHeader.spec.tsx @@ -1,8 +1,7 @@ // npx vitest src/components/chat/__tests__/TaskHeader.spec.tsx import React from "react" -import { render, screen, fireEvent } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderWithExtensionState, screen, fireEvent } from "@/utils/test-utils" import type { ProviderSettings } from "@roo-code/types" @@ -54,6 +53,7 @@ const mockExtensionState: { // Mock the ExtensionStateContext vi.mock("@src/context/ExtensionStateContext", () => ({ + ExtensionStateContextProvider: ({ children }: any) => children, useExtensionState: () => mockExtensionState, })) @@ -100,14 +100,8 @@ describe("TaskHeader", () => { handleCondenseContext: vi.fn(), } - const queryClient = new QueryClient() - const renderTaskHeader = (props: Partial = {}) => { - return render( - - - , - ) + return renderWithExtensionState() } it("should display cost when totalCost is greater than 0", () => { diff --git a/webview-ui/src/components/common/MermaidBlock.tsx b/webview-ui/src/components/common/MermaidBlock.tsx index 95c795fdc5..111919c649 100644 --- a/webview-ui/src/components/common/MermaidBlock.tsx +++ b/webview-ui/src/components/common/MermaidBlock.tsx @@ -44,7 +44,8 @@ const MERMAID_THEME = { mermaid.initialize({ startOnLoad: false, - securityLevel: "loose", + // "strict" is required: mermaid renders LLM-generated source, and looser modes allow HTML injection through diagram labels. + securityLevel: "strict", theme: "dark", suppressErrorRendering: true, themeVariables: { diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index c5e69978ff..0cc61052db 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -8,6 +8,7 @@ import { type ProviderName, type ProviderSettings, isRetiredProvider, + providerIdentifiers, DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, } from "@roo-code/types" @@ -207,7 +208,7 @@ const ApiOptions = ({ // stops typing. useDebounce( () => { - if (selectedProvider === "openai") { + if (selectedProvider === providerIdentifiers.openai) { // Use our custom headers state to build the headers object. const headerObject = convertHeadersToObject(customHeaders) @@ -220,7 +221,7 @@ const ApiOptions = ({ openAiHeaders: headerObject, }, }) - } else if (selectedProvider === "ollama") { + } else if (selectedProvider === providerIdentifiers.ollama) { vscode.postMessage({ type: "requestOllamaModels", values: { @@ -228,11 +229,11 @@ const ApiOptions = ({ apiKey: apiConfiguration?.ollamaApiKey, }, }) - } else if (selectedProvider === "lmstudio") { + } else if (selectedProvider === providerIdentifiers.lmstudio) { requestLmStudioModels(apiConfiguration?.lmStudioBaseUrl) - } else if (selectedProvider === "vscode-lm") { + } else if (selectedProvider === providerIdentifiers.vscodeLm) { vscode.postMessage({ type: "requestVsCodeLmModels" }) - } else if (selectedProvider === "litellm") { + } else if (selectedProvider === providerIdentifiers.litellm) { vscode.postMessage({ type: "requestRouterModels", values: { @@ -240,7 +241,7 @@ const ApiOptions = ({ litellmBaseUrl: apiConfiguration?.litellmBaseUrl, }, }) - } else if (selectedProvider === "poe") { + } else if (selectedProvider === providerIdentifiers.poe) { vscode.postMessage({ type: "requestRouterModels" }) } }, @@ -270,7 +271,7 @@ const ApiOptions = ({ // Zoo Gateway renders its own auth-state error inline (sign-in card in // ZooGateway.tsx) so it can react to zooCodeIsAuthenticated changes // without re-running this effect or threading auth state through validation. - if (apiConfiguration.apiProvider === "zoo-gateway") { + if (apiConfiguration.apiProvider === providerIdentifiers.zooGateway) { setErrorMessage(undefined) return } @@ -322,7 +323,7 @@ const ApiOptions = ({ } // Bedrock has a special “custom-arn” pseudo-model that isn't part of MODELS_BY_PROVIDER. - if (provider === "bedrock" && modelId === "custom-arn") { + if (provider === providerIdentifiers.bedrock && modelId === "custom-arn") { return } @@ -397,7 +398,7 @@ const ApiOptions = ({ })) if (fromWelcomeView) { - const openRouterIndex = options.findIndex((opt) => opt.value === "openrouter") + const openRouterIndex = options.findIndex((opt) => opt.value === providerIdentifiers.openrouter) if (openRouterIndex > 0) { const [openRouterOption] = options.splice(openRouterIndex, 1) options.unshift(openRouterOption) @@ -441,7 +442,7 @@ const ApiOptions = ({ ) : ( <> - {selectedProvider === "openrouter" && ( + {selectedProvider === providerIdentifiers.openrouter && ( )} - {selectedProvider === "requesty" && ( + {selectedProvider === providerIdentifiers.requesty && ( )} - {selectedProvider === "unbound" && ( + {selectedProvider === providerIdentifiers.unbound && ( )} - {selectedProvider === "anthropic" && ( + {selectedProvider === providerIdentifiers.anthropic && ( )} - {selectedProvider === "openai-codex" && ( + {selectedProvider === providerIdentifiers.openaiCodex && ( )} - {selectedProvider === "openai-native" && ( + {selectedProvider === providerIdentifiers.openaiNative && ( )} - {selectedProvider === "mistral" && ( + {selectedProvider === providerIdentifiers.mistral && ( )} - {selectedProvider === "baseten" && ( + {selectedProvider === providerIdentifiers.baseten && ( )} - {selectedProvider === "bedrock" && ( + {selectedProvider === providerIdentifiers.bedrock && ( )} - {selectedProvider === "vertex" && ( + {selectedProvider === providerIdentifiers.vertex && ( )} - {selectedProvider === "gemini" && ( + {selectedProvider === providerIdentifiers.gemini && ( )} - {selectedProvider === "openai" && ( + {selectedProvider === providerIdentifiers.openai && ( )} - {selectedProvider === "lmstudio" && ( + {selectedProvider === providerIdentifiers.lmstudio && ( )} - {selectedProvider === "deepseek" && ( + {selectedProvider === providerIdentifiers.deepseek && ( )} - {selectedProvider === "qwen-code" && ( + {selectedProvider === providerIdentifiers.qwenCode && ( )} - {selectedProvider === "moonshot" && ( + {selectedProvider === providerIdentifiers.moonshot && ( )} - {selectedProvider === "kimi-code" && ( + {selectedProvider === providerIdentifiers.kimiCode && ( )} - {selectedProvider === "minimax" && ( + {selectedProvider === providerIdentifiers.minimax && ( )} - {selectedProvider === "mimo" && ( + {selectedProvider === providerIdentifiers.mimo && ( )} - {selectedProvider === "vscode-lm" && ( + {selectedProvider === providerIdentifiers.vscodeLm && ( )} - {selectedProvider === "ollama" && ( + {selectedProvider === providerIdentifiers.ollama && ( )} - {selectedProvider === "xai" && ( + {selectedProvider === providerIdentifiers.xai && ( )} - {selectedProvider === "litellm" && ( + {selectedProvider === providerIdentifiers.litellm && ( )} - {selectedProvider === "sambanova" && ( + {selectedProvider === providerIdentifiers.sambanova && ( )} - {selectedProvider === "zai" && ( + {selectedProvider === providerIdentifiers.zai && ( )} - {selectedProvider === "vercel-ai-gateway" && ( + {selectedProvider === providerIdentifiers.vercelAiGateway && ( )} - {selectedProvider === "opencode-go" && ( + {selectedProvider === providerIdentifiers.opencodeGo && ( )} - {selectedProvider === "kenari" && ( + {selectedProvider === providerIdentifiers.kenari && ( )} - {selectedProvider === "zoo-gateway" && ( + {selectedProvider === providerIdentifiers.zooGateway && ( )} - {selectedProvider === "fireworks" && ( + {selectedProvider === providerIdentifiers.fireworks && ( )} - {selectedProvider === "friendli" && ( + {selectedProvider === providerIdentifiers.friendli && ( )} - {selectedProvider === "poe" && ( + {selectedProvider === providerIdentifiers.poe && ( - {selectedProvider === "bedrock" && selectedModelId === "custom-arn" && ( + {selectedProvider === providerIdentifiers.bedrock && selectedModelId === "custom-arn" && ( setApiConfigurationField("consecutiveMistakeLimit", value)} /> - {selectedProvider === "poe" && ( + {selectedProvider === providerIdentifiers.poe && ( )} - {selectedProvider === "openrouter" && + {selectedProvider === providerIdentifiers.openrouter && openRouterModelProviders && Object.keys(openRouterModelProviders).length > 0 && (
diff --git a/webview-ui/src/components/settings/ModelInfoView.tsx b/webview-ui/src/components/settings/ModelInfoView.tsx index fff55eda55..34feecb2bb 100644 --- a/webview-ui/src/components/settings/ModelInfoView.tsx +++ b/webview-ui/src/components/settings/ModelInfoView.tsx @@ -79,7 +79,7 @@ export const ModelInfoView = ({ supportsLabel={t("settings:modelInfo.supportsPromptCache")} doesNotSupportLabel={t("settings:modelInfo.noPromptCache")} />, - apiProvider === "gemini" && ( + apiProvider === providerIdentifiers.gemini && ( {selectedModelId.includes("pro-preview") ? t("settings:modelInfo.gemini.billingEstimate") diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index e32806343d..64707f6313 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -3,7 +3,13 @@ import { VSCodeLink } from "@vscode/webview-ui-toolkit/react" import { Trans } from "react-i18next" import { ChevronsUpDown, Check, X, Info } from "lucide-react" -import { type ProviderSettings, type ModelInfo, type OrganizationAllowList, isRetiredProvider } from "@roo-code/types" +import { + type ProviderSettings, + type ModelInfo, + type OrganizationAllowList, + isRetiredProvider, + providerIdentifiers, +} from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" import { useSelectedModel } from "@/components/ui/hooks/useSelectedModel" @@ -305,7 +311,7 @@ export const ModelPicker = ({ hidePricing={hidePricing} /> )} - {!hidePricing && apiConfiguration.apiProvider !== "mimo" && ( + {!hidePricing && apiConfiguration.apiProvider !== providerIdentifiers.mimo && (
, "data"> + +type ChildrenProps = { children?: ReactNode } + +type VSCodeTextFieldMockProps = ChildrenProps & + Pick, "value" | "placeholder"> & { + onInput?: ChangeEventHandler + } + +type SearchableSelectMockProps = { + value?: string + onValueChange: (value: string) => void + options: Array<{ value: string; label: string }> + "data-testid"?: string +} + +type SelectMockProps = ChildrenProps & { + value?: string + onValueChange?: (value: string) => void +} + +type UseSelectedModelReturn = { provider?: string; id?: string; info: Record } + +const { useOpenRouterModelProvidersMock, useSelectedModelMock } = vi.hoisted(() => ({ + useOpenRouterModelProvidersMock: vi.fn<() => OpenRouterModelProvidersQueryResult>(() => ({ data: undefined })), + useSelectedModelMock: vi.fn( + (configuration: ProviderSettings): UseSelectedModelReturn => ({ + provider: configuration.apiProvider, + id: configuration.apiModelId, + info: {}, + }), + ), +})) + +vi.mock("@src/context/ExtensionStateContext", () => ({ + useExtensionState: () => ({ + organizationAllowList: { allowAll: true, providers: {} }, + openAiCodexIsAuthenticated: false, + kimiCodeIsAuthenticated: false, + kimiCodeOAuthState: undefined, + }), +})) + +vi.mock("@src/components/ui/hooks/useRouterModels", () => ({ + useRouterModels: () => ({ data: {}, refetch: vi.fn() }), +})) + +vi.mock("@src/components/ui/hooks/useZooGatewayRouterModelsSync", () => ({ + useZooGatewayRouterModelsSync: vi.fn(), +})) + +vi.mock("@src/components/ui/hooks/useOpenRouterModelProviders", () => ({ + useOpenRouterModelProviders: useOpenRouterModelProvidersMock, + OPENROUTER_DEFAULT_PROVIDER_NAME: "Auto", +})) + +vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ + useSelectedModel: useSelectedModelMock, +})) + +vi.mock("@src/components/ui/hooks/useLmStudioModels", () => ({ + requestLmStudioModels: vi.fn(), +})) + +vi.mock("../providers", () => { + const provider = (testId: string) => () =>
+ return { + Anthropic: provider("provider-anthropic"), + Baseten: provider("provider-baseten"), + Bedrock: provider("provider-bedrock"), + DeepSeek: provider("provider-deepseek"), + Gemini: provider("provider-gemini"), + LMStudio: provider("provider-lmstudio"), + LiteLLM: provider("provider-litellm"), + Mistral: provider("provider-mistral"), + Moonshot: provider("provider-moonshot"), + KimiCode: provider("provider-kimi-code"), + Ollama: provider("provider-ollama"), + OpenAI: provider("provider-openai-native"), + OpenAICompatible: provider("provider-openai"), + OpenAICodex: provider("provider-openai-codex"), + OpenRouter: provider("provider-openrouter"), + Poe: provider("provider-poe"), + QwenCode: provider("provider-qwen-code"), + Requesty: provider("provider-requesty"), + SambaNova: provider("provider-sambanova"), + Unbound: provider("provider-unbound"), + Vertex: provider("provider-vertex"), + VSCodeLM: provider("provider-vscode-lm"), + XAI: provider("provider-xai"), + ZAi: provider("provider-zai"), + Fireworks: provider("provider-fireworks"), + Friendli: provider("provider-friendli"), + VercelAiGateway: provider("provider-vercel-ai-gateway"), + OpenCodeGo: provider("provider-opencode-go"), + Kenari: provider("provider-kenari"), + ZooGateway: provider("provider-zoo-gateway"), + MiniMax: provider("provider-minimax"), + Mimo: provider("provider-mimo"), + } +}) + +vi.mock("../providers/BedrockCustomArn", () => ({ + BedrockCustomArn: () =>
, +})) +vi.mock("../ModelPicker", () => ({ ModelPicker: () => null })) +vi.mock("../ApiErrorMessage", () => ({ + ApiErrorMessage: ({ errorMessage }: { errorMessage: string }) =>
{String(errorMessage)}
, +})) +vi.mock("../ThinkingBudget", () => ({ ThinkingBudget: () => null })) +vi.mock("../Verbosity", () => ({ Verbosity: () => null })) +vi.mock("../TodoListSettingsControl", () => ({ TodoListSettingsControl: () => null })) +vi.mock("../TemperatureControl", () => ({ TemperatureControl: () => null })) +vi.mock("../RateLimitSecondsControl", () => ({ RateLimitSecondsControl: () => null })) +vi.mock("../ConsecutiveMistakeLimitControl", () => ({ + ConsecutiveMistakeLimitControl: ({ value, onChange }: { value: number; onChange: (value: number) => void }) => ( +
+ onChange(Number(event.target.value))} /> +
+ ), +})) + +vi.mock("@vscode/webview-ui-toolkit/react", () => ({ + VSCodeTextField: ({ children, value, onInput, placeholder }: VSCodeTextFieldMockProps) => ( + + ), + VSCodeLink: ({ children }: ChildrenProps) => {children}, +})) + +vi.mock("@/components/ui", () => ({ + SearchableSelect: ({ value, onValueChange, options, "data-testid": testId }: SearchableSelectMockProps) => ( +
+ +
+ ), + Collapsible: ({ children }: ChildrenProps) =>
{children}
, + CollapsibleTrigger: ({ children }: ChildrenProps) =>
{children}
, + CollapsibleContent: ({ children }: ChildrenProps) =>
{children}
, + Select: ({ value, onValueChange, children }: SelectMockProps) => ( + + ), + SelectTrigger: ({ children }: ChildrenProps) => <>{children}, + SelectValue: () => null, + SelectContent: ({ children }: ChildrenProps) => <>{children}, + SelectItem: ({ value, children }: { value?: string; children?: ReactNode }) => ( + + ), +})) + +const renderApiOptions = (props: Partial = {}) => + render( + undefined} + uriScheme={undefined} + apiConfiguration={{}} + setApiConfigurationField={() => undefined} + {...props} + />, + ) + +describe("ApiOptions interactions", () => { + beforeEach(() => { + useSelectedModelMock.mockImplementation((configuration: ProviderSettings) => ({ + provider: configuration.apiProvider, + id: configuration.apiModelId, + info: {}, + })) + useOpenRouterModelProvidersMock.mockImplementation(() => ({ data: undefined })) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + describe("debounced provider model refresh", () => { + it.each([ + { + provider: providerIdentifiers.openai, + configuration: { + openAiBaseUrl: "https://openai.example/v1", + openAiApiKey: "openai-key", + openAiHeaders: { "X-Custom": "header-value" }, + }, + expectedMessage: { + type: "requestOpenAiModels", + values: { + baseUrl: "https://openai.example/v1", + apiKey: "openai-key", + customHeaders: {}, + openAiHeaders: { "X-Custom": "header-value" }, + }, + }, + }, + { + provider: providerIdentifiers.ollama, + configuration: { ollamaBaseUrl: "http://ollama:11434", ollamaApiKey: "ollama-key" }, + expectedMessage: { + type: "requestOllamaModels", + values: { baseUrl: "http://ollama:11434", apiKey: "ollama-key" }, + }, + }, + { + provider: providerIdentifiers.vscodeLm, + configuration: {}, + expectedMessage: { type: "requestVsCodeLmModels" }, + }, + { + provider: providerIdentifiers.litellm, + configuration: { litellmBaseUrl: "http://litellm:4000", litellmApiKey: "litellm-key" }, + expectedMessage: { + type: "requestRouterModels", + values: { litellmApiKey: "litellm-key", litellmBaseUrl: "http://litellm:4000" }, + }, + }, + { + provider: providerIdentifiers.poe, + configuration: { poeApiKey: "poe-key", poeBaseUrl: "https://api.poe.example/v1" }, + expectedMessage: { type: "requestRouterModels" }, + }, + ])("requests models for $provider", ({ provider, configuration, expectedMessage }) => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + + renderApiOptions({ apiConfiguration: { apiProvider: provider, ...configuration } }) + act(() => vi.advanceTimersByTime(249)) + expect(postMessage).not.toHaveBeenCalledWith(expectedMessage) + + act(() => vi.advanceTimersByTime(1)) + expect(postMessage).toHaveBeenCalledTimes(1) + expect(postMessage).toHaveBeenCalledWith(expectedMessage) + }) + + it("applies the header transform when requesting OpenAI models", () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.openai, + openAiBaseUrl: "https://openai.example/v1", + openAiApiKey: "openai-key", + openAiHeaders: { "": "ignored", "X-Keep": " kept" }, + }, + }) + act(() => vi.advanceTimersByTime(250)) + + expect(postMessage).toHaveBeenCalledWith({ + type: "requestOpenAiModels", + values: { + baseUrl: "https://openai.example/v1", + apiKey: "openai-key", + customHeaders: {}, + openAiHeaders: { "X-Keep": "kept" }, + }, + }) + }) + + it("syncs processed custom headers into the configuration", () => { + vi.useFakeTimers() + const setApiConfigurationField = vi.fn() + + // The empty header key is dropped by convertHeadersToObject, so the + // processed object differs from the stored one and the sync fires. + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.openai, openAiHeaders: { "": "ignored" } }, + setApiConfigurationField, + }) + act(() => vi.advanceTimersByTime(300)) + + expect(setApiConfigurationField).toHaveBeenCalledWith("openAiHeaders", {}, false) + }) + + it("requests LM Studio models using its configured base URL", () => { + vi.useFakeTimers() + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.lmstudio, + lmStudioBaseUrl: "http://lmstudio:1234", + }, + }) + + act(() => vi.advanceTimersByTime(249)) + expect(requestLmStudioModels).not.toHaveBeenCalledWith("http://lmstudio:1234") + + act(() => vi.advanceTimersByTime(1)) + expect(requestLmStudioModels).toHaveBeenCalledTimes(1) + expect(requestLmStudioModels).toHaveBeenCalledWith("http://lmstudio:1234") + }) + + it("does not request dynamic models for a static provider", () => { + vi.useFakeTimers() + const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => undefined) + + renderApiOptions({ apiConfiguration: { apiProvider: providerIdentifiers.anthropic } }) + act(() => vi.advanceTimersByTime(250)) + + expect(postMessage).not.toHaveBeenCalled() + }) + }) + + it.each([ + providerIdentifiers.openrouter, + providerIdentifiers.requesty, + providerIdentifiers.unbound, + providerIdentifiers.anthropic, + providerIdentifiers.openaiCodex, + providerIdentifiers.openaiNative, + providerIdentifiers.mistral, + providerIdentifiers.baseten, + providerIdentifiers.bedrock, + providerIdentifiers.vertex, + providerIdentifiers.gemini, + providerIdentifiers.openai, + providerIdentifiers.lmstudio, + providerIdentifiers.deepseek, + providerIdentifiers.qwenCode, + providerIdentifiers.moonshot, + providerIdentifiers.kimiCode, + providerIdentifiers.minimax, + providerIdentifiers.mimo, + providerIdentifiers.vscodeLm, + providerIdentifiers.ollama, + providerIdentifiers.xai, + providerIdentifiers.litellm, + providerIdentifiers.sambanova, + providerIdentifiers.zai, + providerIdentifiers.vercelAiGateway, + providerIdentifiers.opencodeGo, + providerIdentifiers.kenari, + providerIdentifiers.zooGateway, + providerIdentifiers.fireworks, + providerIdentifiers.friendli, + providerIdentifiers.poe, + ])("renders the %s provider branch when selected", (apiProvider) => { + renderApiOptions({ apiConfiguration: { apiProvider } }) + + expect(screen.getByTestId(`provider-${apiProvider}`)).toBeInTheDocument() + }) + + it("clears parent validation errors for Zoo Gateway", () => { + const setErrorMessage = vi.fn() + renderApiOptions({ apiConfiguration: { apiProvider: providerIdentifiers.zooGateway }, setErrorMessage }) + + expect(setErrorMessage).toHaveBeenCalledWith(undefined) + }) + + it("reports a validation error for a non-gateway provider with missing credentials", () => { + const setErrorMessage = vi.fn() + renderApiOptions({ apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, setErrorMessage }) + + expect(setErrorMessage).toHaveBeenCalled() + expect(setErrorMessage.mock.calls[0][0]).toBeTruthy() + }) + + it("renders the current validation error message", () => { + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic }, + errorMessage: "settings:validation.apiKey", + }) + + expect(screen.getByText("settings:validation.apiKey")).toBeInTheDocument() + }) + + it("renders OpenRouter provider routing when provider metadata is available", () => { + useOpenRouterModelProvidersMock.mockReturnValue({ + data: { preferred: { label: "Preferred", contextWindow: 1, supportsPromptCache: false } }, + }) + + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "anthropic/claude-sonnet-4.5", + }, + }) + + expect(screen.getByText("settings:providers.openRouter.providerRouting.title")).toBeInTheDocument() + }) + + it("updates the OpenRouter specific provider from the routing control", () => { + useOpenRouterModelProvidersMock.mockReturnValue({ + data: { preferred: { label: "Preferred", contextWindow: 1, supportsPromptCache: false } }, + }) + const setApiConfigurationField = vi.fn() + + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "anthropic/claude-sonnet-4.5", + }, + setApiConfigurationField, + }) + + fireEvent.change(screen.getByTestId("routing-select"), { target: { value: "preferred" } }) + expect(setApiConfigurationField).toHaveBeenCalledWith("openRouterSpecificProvider", "preferred") + }) + + it("hides OpenRouter provider routing when no provider metadata is available", () => { + useOpenRouterModelProvidersMock.mockReturnValue({ data: {} }) + + renderApiOptions({ + apiConfiguration: { + apiProvider: providerIdentifiers.openrouter, + openRouterModelId: "anthropic/claude-sonnet-4.5", + }, + }) + + expect(screen.queryByTestId("routing-select")).not.toBeInTheDocument() + }) + + it("preserves the Bedrock custom ARN pseudo-model when switching to Bedrock", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "custom-arn" }, + setApiConfigurationField, + }) + + const providerSelect = screen.getByTestId("provider-select").querySelector("select") as HTMLSelectElement + fireEvent.change(providerSelect, { target: { value: providerIdentifiers.bedrock } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("apiProvider", providerIdentifiers.bedrock) + expect(setApiConfigurationField.mock.calls.filter(([field]) => field === "apiModelId")).toEqual([]) + }) + + it("resets an invalid ordinary model to the Bedrock default when switching providers", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "not-a-bedrock-model" }, + setApiConfigurationField, + }) + + const providerSelect = screen.getByTestId("provider-select").querySelector("select") as HTMLSelectElement + fireEvent.change(providerSelect, { target: { value: providerIdentifiers.bedrock } }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("apiProvider", providerIdentifiers.bedrock) + expect(setApiConfigurationField).toHaveBeenCalledWith("apiModelId", bedrockDefaultModelId, false) + }) + + it("renders the custom ARN settings only for Bedrock's custom ARN pseudo-model", () => { + const { rerender } = render( + undefined} + uriScheme={undefined} + apiConfiguration={{ apiProvider: providerIdentifiers.bedrock, apiModelId: "custom-arn" }} + setApiConfigurationField={() => undefined} + />, + ) + + expect(screen.getByTestId("bedrock-custom-arn")).toBeInTheDocument() + + rerender( + undefined} + uriScheme={undefined} + apiConfiguration={{ apiProvider: providerIdentifiers.bedrock, apiModelId: bedrockDefaultModelId }} + setApiConfigurationField={() => undefined} + />, + ) + + expect(screen.queryByTestId("bedrock-custom-arn")).not.toBeInTheDocument() + }) + + it("syncs the selected model into the config when the model id differs", () => { + useSelectedModelMock.mockReturnValue({ provider: providerIdentifiers.anthropic, id: "claude-sonnet", info: {} }) + const setApiConfigurationField = vi.fn() + + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.anthropic, apiModelId: "old-model" }, + setApiConfigurationField, + }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("apiModelId", "claude-sonnet", false) + }) + + it("updates the consecutive mistake limit from advanced settings", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ apiConfiguration: {}, setApiConfigurationField }) + + fireEvent.change(within(screen.getByTestId("consecutive-mistake-limit-control")).getByRole("slider"), { + target: { value: "7" }, + }) + + expect(setApiConfigurationField).toHaveBeenCalledWith("consecutiveMistakeLimit", 7) + }) + + it("renders and updates the Poe base URL in advanced settings", () => { + const setApiConfigurationField = vi.fn() + renderApiOptions({ + apiConfiguration: { apiProvider: providerIdentifiers.poe, poeBaseUrl: "https://api.poe.example/v1" }, + setApiConfigurationField, + }) + + const poeBaseUrl = screen.getByPlaceholderText("https://api.poe.com/v1") + expect(poeBaseUrl).toHaveValue("https://api.poe.example/v1") + + fireEvent.change(poeBaseUrl, { target: { value: "https://new.poe.example/v1" } }) + expect(setApiConfigurationField).toHaveBeenCalledWith("poeBaseUrl", "https://new.poe.example/v1") + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx index f650424fd0..c9fb64272b 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.provider-filtering.spec.tsx @@ -1,5 +1,6 @@ -import { render, screen } from "@testing-library/react" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { screen } from "@testing-library/react" + +import { renderWithExtensionState } from "@/utils/test-utils" import type { ProviderSettings, OrganizationAllowList } from "@roo-code/types" @@ -11,6 +12,7 @@ import { MODELS_BY_PROVIDER, PROVIDERS } from "../constants" // Mock the extension state context vi.mock("@src/context/ExtensionStateContext", () => ({ + ExtensionStateContextProvider: ({ children }: any) => children, useExtensionState: vi.fn(() => ({ organizationAllowList: undefined, cloudIsAuthenticated: false, @@ -94,12 +96,6 @@ vi.mock("@src/components/ui", () => ({ })) describe("ApiOptions Provider Filtering", () => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }) - const defaultProps = { uriScheme: "vscode", apiConfiguration: { @@ -113,11 +109,7 @@ describe("ApiOptions Provider Filtering", () => { } const renderWithProviders = (props = defaultProps) => { - return render( - - - , - ) + return renderWithExtensionState() } it("should show all providers when no organization allow list is provided", () => { diff --git a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx index 58fefc77e6..86e7273d45 100644 --- a/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ApiOptions.spec.tsx @@ -1,14 +1,10 @@ // npx vitest src/components/settings/__tests__/ApiOptions.spec.tsx -import { render, screen, fireEvent, within } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderWithExtensionState, screen, fireEvent, within } from "@/utils/test-utils" import { type ModelInfo, type ProviderSettings, openAiModelInfoSaneDefaults } from "@roo-code/types" import { openAiCodexDefaultModelId, zooGatewayDefaultModelId } from "@roo-code/types" -import * as ExtensionStateContext from "@src/context/ExtensionStateContext" -const { ExtensionStateContextProvider } = ExtensionStateContext - import ApiOptions, { ApiOptionsProps } from "../ApiOptions" // Mock VSCode components @@ -278,21 +274,15 @@ vi.mock("@src/components/ui/hooks/useSelectedModel", () => ({ })) const renderApiOptions = (props: Partial = {}) => { - const queryClient = new QueryClient() - - render( - - - {}} - uriScheme={undefined} - apiConfiguration={{}} - setApiConfigurationField={() => {}} - {...props} - /> - - , + renderWithExtensionState( + {}} + uriScheme={undefined} + apiConfiguration={{}} + setApiConfigurationField={() => {}} + {...props} + />, ) } diff --git a/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx index 938bc8bff7..1e66b03255 100644 --- a/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelInfoView.spec.tsx @@ -43,6 +43,50 @@ const getPricingRowValues = (tier: string) => { } describe("ModelInfoView service tier pricing", () => { + it("uses the canonical gemini provider identifier", () => { + expect(providerIdentifiers.gemini).toBe("gemini") + }) + + it("shows Gemini billing guidance for the canonical Gemini provider", () => { + render( + , + ) + + expect(screen.getByText("settings:modelInfo.gemini.billingEstimate")).toBeInTheDocument() + }) + + it("shows Gemini free-request guidance for non-pro-preview Gemini models", () => { + render( + , + ) + + expect(screen.getByText("settings:modelInfo.gemini.freeRequests")).toBeInTheDocument() + }) + + it("does not show Gemini billing guidance for non-Gemini providers", () => { + render( + , + ) + + expect(screen.queryByText("settings:modelInfo.gemini.billingEstimate")).not.toBeInTheDocument() + expect(screen.queryByText("settings:modelInfo.gemini.freeRequests")).not.toBeInTheDocument() + }) + it("shows OpenAI Native tier prices with per-field fallback to Standard pricing", () => { const modelInfo: ModelInfo = { ...baseModelInfo, diff --git a/webview-ui/src/components/settings/__tests__/ModelPicker.deprecated.spec.tsx b/webview-ui/src/components/settings/__tests__/ModelPicker.deprecated.spec.tsx index a90da6b815..ebc3239792 100644 --- a/webview-ui/src/components/settings/__tests__/ModelPicker.deprecated.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelPicker.deprecated.spec.tsx @@ -1,8 +1,10 @@ // npx vitest src/components/settings/__tests__/ModelPicker.deprecated.spec.tsx -import { render, screen } from "@testing-library/react" +import { screen } from "@testing-library/react" + +import { renderWithExtensionState } from "@/utils/test-utils" import userEvent from "@testing-library/user-event" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryClient } from "@tanstack/react-query" import { describe, it, expect, vi, beforeEach } from "vitest" import { ModelPicker } from "../ModelPicker" @@ -88,19 +90,18 @@ describe("ModelPicker - Deprecated Models", () => { it("should filter out deprecated models from the dropdown", async () => { const user = userEvent.setup() - render( - - - , + renderWithExtensionState( + , + { queryClient }, ) // Open the dropdown @@ -116,22 +117,21 @@ describe("ModelPicker - Deprecated Models", () => { }) it("should show error when a deprecated model is currently selected", () => { - render( - - - , + renderWithExtensionState( + , + { queryClient }, ) // Check that the error message is displayed @@ -143,19 +143,18 @@ describe("ModelPicker - Deprecated Models", () => { it("should allow selecting non-deprecated models", async () => { const user = userEvent.setup() - render( - - - , + renderWithExtensionState( + , + { queryClient }, ) // Open the dropdown @@ -171,22 +170,21 @@ describe("ModelPicker - Deprecated Models", () => { }) it("should not display model info for deprecated models", () => { - render( - - - , + renderWithExtensionState( + , + { queryClient }, ) // Model info should not be displayed for deprecated models diff --git a/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx b/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx index 3f8dc4dcff..06d149b20c 100644 --- a/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/ModelPicker.spec.tsx @@ -1,14 +1,15 @@ // npx vitest src/components/settings/__tests__/ModelPicker.spec.tsx -import { screen, fireEvent, render } from "@/utils/test-utils" +import { screen, fireEvent, renderWithExtensionState } from "@/utils/test-utils" import { act } from "react" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryClient } from "@tanstack/react-query" -import { ModelInfo } from "@roo-code/types" +import { ModelInfo, providerIdentifiers } from "@roo-code/types" import { ModelPicker } from "../ModelPicker" vi.mock("@src/context/ExtensionStateContext", () => ({ + ExtensionStateContextProvider: ({ children }: any) => children, useExtensionState: vi.fn(), })) @@ -48,11 +49,7 @@ describe("ModelPicker", () => { const queryClient = new QueryClient() const renderModelPicker = () => { - return render( - - - , - ) + return renderWithExtensionState(, { queryClient }) } beforeEach(() => { @@ -154,11 +151,7 @@ describe("ModelPicker", () => { } await act(async () => { - render( - - - , - ) + renderWithExtensionState(, { queryClient }) }) // Check that the error message is displayed @@ -181,11 +174,7 @@ describe("ModelPicker", () => { } await act(async () => { - render( - - - , - ) + renderWithExtensionState(, { queryClient }) }) // Check that both the model selector and error message are present @@ -203,10 +192,9 @@ describe("ModelPicker", () => { const initialError = "Initial error" const updatedError = "Updated error" - const { rerender } = render( - - - , + const { rerender } = renderWithExtensionState( + , + { queryClient }, ) // Check initial error is displayed @@ -214,11 +202,7 @@ describe("ModelPicker", () => { expect(screen.getByText(initialError)).toBeInTheDocument() // Update the error message - rerender( - - - , - ) + rerender() // Check that the error message has been updated expect(screen.getByTestId("api-error-message")).toBeInTheDocument() @@ -229,10 +213,9 @@ describe("ModelPicker", () => { it("removes error message when errorMessage prop becomes undefined", async () => { const errorMessage = "Temporary error" - const { rerender } = render( - - - , + const { rerender } = renderWithExtensionState( + , + { queryClient }, ) // Check error is initially displayed @@ -240,11 +223,7 @@ describe("ModelPicker", () => { expect(screen.getByText(errorMessage)).toBeInTheDocument() // Remove the error message - rerender( - - - , - ) + rerender() // Check that the error message has been removed expect(screen.queryByTestId("api-error-message")).not.toBeInTheDocument() @@ -255,10 +234,9 @@ describe("ModelPicker", () => { describe("automaticFetch hint", () => { it("hides the automatic fetch hint for MiMo provider", async () => { await act(async () => { - render( - - - , + renderWithExtensionState( + , + { queryClient }, ) }) @@ -267,10 +245,9 @@ describe("ModelPicker", () => { it("shows the automatic fetch hint for non-MiMo providers", async () => { await act(async () => { - render( - - - , + renderWithExtensionState( + , + { queryClient }, ) }) diff --git a/webview-ui/src/components/settings/__tests__/RulesSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/RulesSettings.spec.tsx index 569a355b61..52ee44a5c8 100644 --- a/webview-ui/src/components/settings/__tests__/RulesSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/RulesSettings.spec.tsx @@ -1,9 +1,7 @@ -import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderWithExtensionState, screen, fireEvent, waitFor } from "@/utils/test-utils" import type { RuleMetadata } from "@roo-code/types" -import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" import { vscode } from "@/utils/vscode" import { RulesSettings } from "../RulesSettings" @@ -108,25 +106,12 @@ vi.mock("@/context/ExtensionStateContext", () => ({ })) const renderRulesSettings = (rules: RuleMetadata[] = mockRules, cwd?: string) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }) - mockExtensionState = { rules, cwd: cwd !== undefined ? cwd : "/workspace", } - return render( - - - - - , - ) + return renderWithExtensionState() } describe("RulesSettings", () => { diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx index 68a6efb106..385b4320f1 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.change-detection.spec.tsx @@ -1,6 +1,8 @@ -import { act, render, screen, fireEvent, waitFor, configure } from "@testing-library/react" +import { act, screen, fireEvent, waitFor, configure } from "@testing-library/react" + +import { renderWithExtensionState } from "@/utils/test-utils" import { vi, describe, it, expect, beforeEach, beforeAll } from "vitest" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryClient } from "@tanstack/react-query" import React from "react" // Increase timeout for slow CI environments @@ -23,6 +25,7 @@ import { useExtensionState } from "@src/context/ExtensionStateContext" // Mock the extension state context vi.mock("@src/context/ExtensionStateContext", () => ({ + ExtensionStateContextProvider: ({ children }: any) => children, useExtensionState: vi.fn(), })) @@ -465,11 +468,7 @@ describe("SettingsView - Change Detection Fix", () => { const onDone = vi.fn() ;(useExtensionState as any).mockReturnValue(createExtensionState()) - render( - - - , - ) + renderWithExtensionState(, { queryClient }) // Wait for initial render await waitFor(() => { @@ -524,11 +523,7 @@ describe("SettingsView - Change Detection Fix", () => { ;(useExtensionState as any).mockImplementation(() => extensionState) - const { rerender } = render( - - - , - ) + const { rerender } = renderWithExtensionState(, { queryClient }) await waitFor(() => { expect(screen.getByTestId("provider-value")).toHaveTextContent("openai") @@ -564,11 +559,7 @@ describe("SettingsView - Change Detection Fix", () => { }) ;(useExtensionState as any).mockImplementation(() => extensionState) - rerender( - - - , - ) + rerender() }) // Let the import cache-busting effect run. With the old implementation, @@ -603,11 +594,7 @@ describe("SettingsView - Change Detection Fix", () => { ;(useExtensionState as any).mockImplementation(() => extensionState) - const { rerender } = render( - - - , - ) + const { rerender } = renderWithExtensionState(, { queryClient }) await waitFor(() => { expect(screen.getByTestId("provider-value")).toHaveTextContent("openai") @@ -627,11 +614,7 @@ describe("SettingsView - Change Detection Fix", () => { }) ;(useExtensionState as any).mockImplementation(() => extensionState) - rerender( - - - , - ) + rerender() }) await waitFor(() => { @@ -656,11 +639,7 @@ describe("SettingsView - Change Detection Fix", () => { ;(useExtensionState as any).mockImplementation(() => extensionState) - const { rerender } = render( - - - , - ) + const { rerender } = renderWithExtensionState(, { queryClient }) await waitFor(() => { expect(screen.getByTestId("provider-value")).toHaveTextContent("openai") @@ -684,11 +663,7 @@ describe("SettingsView - Change Detection Fix", () => { apiModelId: "claude-3.5-sonnet", } - rerender( - - - , - ) + rerender() }) // Let the mode sync effect run @@ -724,11 +699,7 @@ describe("SettingsView - Change Detection Fix", () => { }) ;(useExtensionState as any).mockImplementation(() => extensionState) - rerender( - - - , - ) + rerender() }) await act(async () => { @@ -751,11 +722,7 @@ describe("SettingsView - Change Detection Fix", () => { ;(useExtensionState as any).mockImplementation(() => extensionState) - const { rerender } = render( - - - , - ) + const { rerender } = renderWithExtensionState(, { queryClient }) await waitFor(() => { expect(screen.getByTestId("provider-value")).toHaveTextContent("openai") @@ -778,11 +745,7 @@ describe("SettingsView - Change Detection Fix", () => { }) ;(useExtensionState as any).mockImplementation(() => extensionState) - rerender( - - - , - ) + rerender() }) // Provider value should remain unchanged from the dirty state diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index f4defb87dd..a3aa131902 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -1,11 +1,9 @@ // pnpm --filter @roo-code/vscode-webview test src/components/settings/__tests__/SettingsView.spec.tsx -import { render, screen, fireEvent, within, waitFor } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderWithExtensionState, screen, fireEvent, within, waitFor } from "@/utils/test-utils" import { act } from "@testing-library/react" import { vscode } from "@/utils/vscode" -import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" import { DEFAULT_CHECKPOINT_TIMEOUT_SECONDS } from "@roo-code/types" import SettingsView from "../SettingsView" @@ -291,15 +289,8 @@ const mockPostMessage = (state: any) => { const renderSettingsView = (initialState: any = {}) => { const onDone = vi.fn() - const queryClient = new QueryClient() - - const result = render( - - - - - , - ) + + const result = renderWithExtensionState() // Hydrate initial state. act(() => { @@ -310,13 +301,7 @@ const renderSettingsView = (initialState: any = {}) => { const activateTab = (tabId: string) => { // Skip trying to find and click the tab, just directly render with the target section // This bypasses the actual tab clicking mechanism but ensures the content is shown - result.rerender( - - - - - , - ) + result.rerender() } // Helper to get elements within the settings content (not the indexing container) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx index 88428a077d..20eb7543eb 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.unsaved-changes.spec.tsx @@ -1,6 +1,8 @@ -import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { screen, fireEvent, waitFor } from "@testing-library/react" + +import { renderWithExtensionState } from "@/utils/test-utils" import { vi, describe, it, expect, beforeEach } from "vitest" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { QueryClient } from "@tanstack/react-query" import React from "react" import SettingsView from "../SettingsView" @@ -10,6 +12,7 @@ const postMessage = vi.spyOn(vscode, "postMessage").mockImplementation(() => {}) // Mock the extension state context vi.mock("@src/context/ExtensionStateContext", () => ({ + ExtensionStateContextProvider: ({ children }: any) => children, useExtensionState: vi.fn(), })) @@ -331,11 +334,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { it("should not show unsaved changes when settings are automatically initialized", async () => { const onDone = vi.fn() - render( - - - , - ) + renderWithExtensionState(, { queryClient }) // Wait for the component to render await waitFor(() => { @@ -380,11 +379,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { return
ApiOptions with Init
}) - render( - - - , - ) + renderWithExtensionState(, { queryClient }) // Wait for the component to render and effects to run await waitFor(() => { @@ -431,11 +426,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { // Override the mock for this specific test vi.mocked(ApiOptions).mockImplementation(ApiOptionsWithButton) - render( - - - , - ) + renderWithExtensionState(, { queryClient }) // Wait for the component to render await waitFor(() => { @@ -472,11 +463,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { } ;(useExtensionState as any).mockReturnValue(stateWithUndefined) - render( - - - , - ) + renderWithExtensionState(, { queryClient }) // Wait for initialization await waitFor(() => { @@ -515,11 +502,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { } ;(useExtensionState as any).mockReturnValue(stateWithNull) - render( - - - , - ) + renderWithExtensionState(, { queryClient }) // Wait for initialization await waitFor(() => { @@ -569,11 +552,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { return
ApiOptions
}) - render( - - - , - ) + renderWithExtensionState(, { queryClient }) // Wait for component to fully mount and ApiOptions effect to run await waitFor(() => { @@ -601,11 +580,7 @@ describe("SettingsView - Unsaved Changes Detection", () => { }) it("buffers MCP enablement until Save", async () => { - render( - - - , - ) + renderWithExtensionState(, { queryClient }) const toggle = await screen.findByTestId("mcp-enabled-toggle") fireEvent.click(toggle) diff --git a/webview-ui/src/components/settings/__tests__/SkillsSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/SkillsSettings.spec.tsx index 5c42a2dc51..67c820d8ce 100644 --- a/webview-ui/src/components/settings/__tests__/SkillsSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SkillsSettings.spec.tsx @@ -1,9 +1,7 @@ -import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderWithExtensionState, screen, fireEvent, waitFor } from "@/utils/test-utils" import type { SkillMetadata } from "@roo-code/types" -import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" import { vscode } from "@/utils/vscode" import { SkillsSettings } from "../SkillsSettings" @@ -167,13 +165,6 @@ vi.mock("@/context/ExtensionStateContext", () => ({ })) const renderSkillsSettings = (skills: SkillMetadata[] = mockSkills, cwd?: string) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }) - // Update the mock state before rendering mockExtensionState = { skills, @@ -181,13 +172,7 @@ const renderSkillsSettings = (skills: SkillMetadata[] = mockSkills, cwd?: string customModes: [], } - return render( - - - - - , - ) + return renderWithExtensionState() } describe("SkillsSettings", () => { diff --git a/webview-ui/src/components/settings/__tests__/SlashCommandsSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/SlashCommandsSettings.spec.tsx index 17cbd22682..4533e54a5f 100644 --- a/webview-ui/src/components/settings/__tests__/SlashCommandsSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SlashCommandsSettings.spec.tsx @@ -1,9 +1,7 @@ -import { render, screen, fireEvent, waitFor } from "@/utils/test-utils" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query" +import { renderWithExtensionState, screen, fireEvent, waitFor } from "@/utils/test-utils" import type { Command } from "@roo-code/types" -import { ExtensionStateContextProvider } from "@/context/ExtensionStateContext" import { vscode } from "@/utils/vscode" import { SlashCommandsSettings } from "../SlashCommandsSettings" @@ -154,26 +152,13 @@ vi.mock("@/context/ExtensionStateContext", () => ({ })) const renderSlashCommandsSettings = (commands: Command[] = mockCommands, cwd?: string) => { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - mutations: { retry: false }, - }, - }) - // Update the mock state before rendering mockExtensionState = { commands, cwd: cwd !== undefined ? cwd : "/workspace", } - return render( - - - - - , - ) + return renderWithExtensionState() } describe("SlashCommandsSettings", () => { diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index 15061e333d..7e5e10db6b 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -1,6 +1,7 @@ import { type ProviderName, type ModelInfo, + providerIdentifiers, anthropicModels, bedrockModels, deepSeekModels, @@ -22,57 +23,57 @@ import { } from "@roo-code/types" export const MODELS_BY_PROVIDER: Partial>> = { - anthropic: anthropicModels, - bedrock: bedrockModels, - deepseek: deepSeekModels, - moonshot: moonshotModels, - gemini: geminiModels, - mistral: mistralModels, - "openai-native": openAiNativeModels, - "openai-codex": openAiCodexModels, - "qwen-code": qwenCodeModels, - vertex: vertexModels, - xai: xaiModels, - sambanova: sambaNovaModels, - zai: internationalZAiModels, - fireworks: fireworksModels, - friendli: friendliModels, - minimax: minimaxModels, - mimo: mimoModels, - baseten: basetenModels, + [providerIdentifiers.anthropic]: anthropicModels, + [providerIdentifiers.bedrock]: bedrockModels, + [providerIdentifiers.deepseek]: deepSeekModels, + [providerIdentifiers.moonshot]: moonshotModels, + [providerIdentifiers.gemini]: geminiModels, + [providerIdentifiers.mistral]: mistralModels, + [providerIdentifiers.openaiNative]: openAiNativeModels, + [providerIdentifiers.openaiCodex]: openAiCodexModels, + [providerIdentifiers.qwenCode]: qwenCodeModels, + [providerIdentifiers.vertex]: vertexModels, + [providerIdentifiers.xai]: xaiModels, + [providerIdentifiers.sambanova]: sambaNovaModels, + [providerIdentifiers.zai]: internationalZAiModels, + [providerIdentifiers.fireworks]: fireworksModels, + [providerIdentifiers.friendli]: friendliModels, + [providerIdentifiers.minimax]: minimaxModels, + [providerIdentifiers.mimo]: mimoModels, + [providerIdentifiers.baseten]: basetenModels, } -export const PROVIDERS = [ - { value: "openrouter", label: "OpenRouter", proxy: false }, - { value: "anthropic", label: "Anthropic", proxy: false }, - { value: "gemini", label: "Google Gemini", proxy: false }, - { value: "deepseek", label: "DeepSeek", proxy: false }, - { value: "moonshot", label: "Moonshot", proxy: false }, - { value: "kimi-code", label: "Kimi Code", proxy: false }, - { value: "openai-native", label: "OpenAI", proxy: false }, - { value: "openai-codex", label: "OpenAI - ChatGPT Plus/Pro", proxy: false }, - { value: "openai", label: "OpenAI Compatible", proxy: true }, - { value: "qwen-code", label: "Qwen Code", proxy: false }, - { value: "vertex", label: "GCP Vertex AI", proxy: false }, - { value: "bedrock", label: "Amazon Bedrock", proxy: false }, - { value: "vscode-lm", label: "VS Code LM API", proxy: false }, - { value: "mistral", label: "Mistral", proxy: false }, - { value: "lmstudio", label: "LM Studio", proxy: true }, - { value: "ollama", label: "Ollama", proxy: true }, - { value: "requesty", label: "Requesty", proxy: false }, - { value: "xai", label: "xAI (Grok)", proxy: false }, - { value: "litellm", label: "LiteLLM", proxy: true }, - { value: "sambanova", label: "SambaNova", proxy: false }, - { value: "zai", label: "Z.ai", proxy: false }, - { value: "fireworks", label: "Fireworks AI", proxy: false }, - { value: "friendli", label: "Friendli", proxy: false }, - { value: "vercel-ai-gateway", label: "Vercel AI Gateway", proxy: false }, - { value: "opencode-go", label: "Opencode Go", proxy: false }, - { value: "kenari", label: "Kenari", proxy: false }, - { value: "zoo-gateway", label: "Zoo Gateway", proxy: false }, - { value: "minimax", label: "MiniMax", proxy: false }, - { value: "mimo", label: "Xiaomi MiMo", proxy: false }, - { value: "baseten", label: "Baseten", proxy: false }, - { value: "unbound", label: "Unbound", proxy: false }, - { value: "poe", label: "Poe", proxy: false }, +export const PROVIDERS: Array<{ value: string; label: string; proxy: boolean }> = [ + { value: providerIdentifiers.openrouter, label: "OpenRouter", proxy: false }, + { value: providerIdentifiers.anthropic, label: "Anthropic", proxy: false }, + { value: providerIdentifiers.gemini, label: "Google Gemini", proxy: false }, + { value: providerIdentifiers.deepseek, label: "DeepSeek", proxy: false }, + { value: providerIdentifiers.moonshot, label: "Moonshot", proxy: false }, + { value: providerIdentifiers.kimiCode, label: "Kimi Code", proxy: false }, + { value: providerIdentifiers.openaiNative, label: "OpenAI", proxy: false }, + { value: providerIdentifiers.openaiCodex, label: "OpenAI - ChatGPT Plus/Pro", proxy: false }, + { value: providerIdentifiers.openai, label: "OpenAI Compatible", proxy: true }, + { value: providerIdentifiers.qwenCode, label: "Qwen Code", proxy: false }, + { value: providerIdentifiers.vertex, label: "GCP Vertex AI", proxy: false }, + { value: providerIdentifiers.bedrock, label: "Amazon Bedrock", proxy: false }, + { value: providerIdentifiers.vscodeLm, label: "VS Code LM API", proxy: false }, + { value: providerIdentifiers.mistral, label: "Mistral", proxy: false }, + { value: providerIdentifiers.lmstudio, label: "LM Studio", proxy: true }, + { value: providerIdentifiers.ollama, label: "Ollama", proxy: true }, + { value: providerIdentifiers.requesty, label: "Requesty", proxy: false }, + { value: providerIdentifiers.xai, label: "xAI (Grok)", proxy: false }, + { value: providerIdentifiers.litellm, label: "LiteLLM", proxy: true }, + { value: providerIdentifiers.sambanova, label: "SambaNova", proxy: false }, + { value: providerIdentifiers.zai, label: "Z.ai", proxy: false }, + { value: providerIdentifiers.fireworks, label: "Fireworks AI", proxy: false }, + { value: providerIdentifiers.friendli, label: "Friendli", proxy: false }, + { value: providerIdentifiers.vercelAiGateway, label: "Vercel AI Gateway", proxy: false }, + { value: providerIdentifiers.opencodeGo, label: "Opencode Go", proxy: false }, + { value: providerIdentifiers.kenari, label: "Kenari", proxy: false }, + { value: providerIdentifiers.zooGateway, label: "Zoo Gateway", proxy: false }, + { value: providerIdentifiers.minimax, label: "MiniMax", proxy: false }, + { value: providerIdentifiers.mimo, label: "Xiaomi MiMo", proxy: false }, + { value: providerIdentifiers.baseten, label: "Baseten", proxy: false }, + { value: providerIdentifiers.unbound, label: "Unbound", proxy: false }, + { value: providerIdentifiers.poe, label: "Poe", proxy: false }, ].sort((a, b) => a.label.localeCompare(b.label))