diff --git a/codecov.yml b/codecov.yml index 7dd22dfdc2..0fcf372ffe 100644 --- a/codecov.yml +++ b/codecov.yml @@ -1,59 +1,57 @@ -coverage: - precision: 2 - round: down - status: - project: - default: - target: auto # never regress below current baseline - threshold: 1% - webview: - target: auto # webview project ratchet: never drop below current baseline - threshold: 0.5% - flags: - - webview-ui - - webview-ui-ct - patch: - default: - target: 80% # new lines must be 80% covered - threshold: 0% - webview-patch: - target: 70% # new lines in webview must be 70% covered - threshold: 0% - flags: - - webview-ui - - webview-ui-ct - -flag_management: - individual_flags: - - name: webview-ui - paths: - - webview-ui/src/ - carryforward: true - - name: webview-ui-ct - paths: - - webview-ui/src/ - carryforward: true - - name: core-unit - paths: - - packages/core/src/ - carryforward: true - - name: core-integration - paths: - - packages/core/src/ - carryforward: true - -component_management: - individual_components: - - component_id: webview_components - name: "Webview UI Components" - paths: - - webview-ui/src/components/ - - component_id: webview_state - name: "Webview State & Context" - paths: - - webview-ui/src/context/ - - webview-ui/src/state/ - -comment: - layout: "diff, flags, components" - behavior: default +coverage: + precision: 2 + round: down + status: + project: + default: + target: auto # never regress below current baseline + threshold: 1% + webview: + target: auto # webview project ratchet: never drop below current baseline + threshold: 0.5% + flags: + - webview-ui + - webview-ui-ct + patch: + default: + informational: true # patch coverage is advisory, not blocking + webview-patch: + informational: true # patch coverage is advisory, not blocking + flags: + - webview-ui + - webview-ui-ct + +flag_management: + individual_flags: + - name: webview-ui + paths: + - webview-ui/src/ + carryforward: true + - name: webview-ui-ct + paths: + - webview-ui/src/ + carryforward: true + - name: core-unit + paths: + - packages/core/src/ + carryforward: true + - name: core-integration + paths: + - packages/core/src/ + carryforward: true + +component_management: + individual_components: + - component_id: webview_components + name: "Webview UI Components" + paths: + - webview-ui/src/components/ + - component_id: webview_state + name: "Webview State & Context" + paths: + - webview-ui/src/context/ + - webview-ui/src/state/ + +comment: + layout: "diff, flags, components" + behavior: default diff --git a/packages/telemetry/src/TelemetryService.ts b/packages/telemetry/src/TelemetryService.ts index fdf0942bdb..a327799fc5 100644 --- a/packages/telemetry/src/TelemetryService.ts +++ b/packages/telemetry/src/TelemetryService.ts @@ -5,7 +5,6 @@ import { type TelemetryPropertiesProvider, TelemetryEventName, type TelemetrySetting, - type ToolUsage, } from "@roo-code/types" /** @@ -183,31 +182,8 @@ export class TelemetryService { this.captureEvent(TelemetryEventName.TASK_RESTARTED, { taskId }) } - /** - * Captures task completion, summarizing per-task tool and message counts that - * were previously reported as separate per-turn events to reduce event volume. - * - * A single task may emit this more than once (e.g. an "idle" or "shutdown" - * installment followed by a final "attempt_completion" one). toolsUsed and - * messageCount are always deltas since the previous emission for that task, - * not running totals -- summing installments for a taskId reconstructs the - * full-task counts without double-counting. - * - * Note: "attempt_completion" means the model called that tool, not that the - * user accepted the result. - */ - public captureTaskCompleted( - taskId: string, - toolsUsed?: ToolUsage, - messageCount?: { user: number; assistant: number }, - completionReason: "attempt_completion" | "idle" | "shutdown" = "attempt_completion", - ): void { - this.captureEvent(TelemetryEventName.TASK_COMPLETED, { - taskId, - completionReason, - ...(toolsUsed !== undefined && { toolsUsed }), - ...(messageCount !== undefined && { messageCount }), - }) + public captureTaskCompleted(taskId: string): void { + this.captureEvent(TelemetryEventName.TASK_COMPLETED, { taskId }) } public captureConversationMessage(taskId: string, source: "user" | "assistant"): void { diff --git a/packages/telemetry/src/__tests__/TelemetryService.task-completed.test.ts b/packages/telemetry/src/__tests__/TelemetryService.task-completed.test.ts deleted file mode 100644 index 270fc8f83a..0000000000 --- a/packages/telemetry/src/__tests__/TelemetryService.task-completed.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -// pnpm --filter @roo-code/telemetry test src/__tests__/TelemetryService.task-completed.test.ts - -import { TelemetryEventName, type TelemetryClient } from "@roo-code/types" - -import { TelemetryService } from "../TelemetryService" - -describe("TelemetryService.captureTaskCompleted", () => { - let mockClient: TelemetryClient - - beforeEach(() => { - mockClient = { - setProvider: vi.fn(), - capture: vi.fn().mockResolvedValue(undefined), - captureException: vi.fn().mockResolvedValue(undefined), - updateTelemetryState: vi.fn(), - isTelemetryEnabled: vi.fn().mockReturnValue(true), - shutdown: vi.fn().mockResolvedValue(undefined), - } - }) - - it("captures Task Completed with the taskId and a default 'attempt_completion' completionReason when no summary is provided", () => { - const service = new TelemetryService([mockClient]) - - service.captureTaskCompleted("task_1") - - expect(mockClient.capture).toHaveBeenCalledWith({ - event: TelemetryEventName.TASK_COMPLETED, - properties: { taskId: "task_1", completionReason: "attempt_completion" }, - }) - }) - - it("includes toolsUsed and messageCount summaries when provided", () => { - const service = new TelemetryService([mockClient]) - - service.captureTaskCompleted( - "task_1", - { read_file: { attempts: 3, failures: 0 }, apply_diff: { attempts: 1, failures: 1 } }, - { user: 4, assistant: 5 }, - ) - - expect(mockClient.capture).toHaveBeenCalledWith({ - event: TelemetryEventName.TASK_COMPLETED, - properties: { - taskId: "task_1", - completionReason: "attempt_completion", - toolsUsed: { read_file: { attempts: 3, failures: 0 }, apply_diff: { attempts: 1, failures: 1 } }, - messageCount: { user: 4, assistant: 5 }, - }, - }) - }) - - it("includes the given completionReason for idle/shutdown installments", () => { - const service = new TelemetryService([mockClient]) - - service.captureTaskCompleted("task_1", { read_file: { attempts: 1, failures: 0 } }, undefined, "idle") - - expect(mockClient.capture).toHaveBeenCalledWith({ - event: TelemetryEventName.TASK_COMPLETED, - properties: { - taskId: "task_1", - completionReason: "idle", - toolsUsed: { read_file: { attempts: 1, failures: 0 } }, - }, - }) - }) -}) diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index cd786a6529..77e7527b40 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -166,3 +166,75 @@ describe("getApiProtocol", () => { }) }) }) + +describe("openAiToolStrictMode", () => { + it("should be optional and absent by default", () => { + const result = providerSettingsSchemaDiscriminated.parse({ + apiProvider: "openai", + openAiModelId: "test-model", + }) + expect(result.apiProvider).toBe("openai") + if (result.apiProvider === "openai") { + expect(result.openAiToolStrictMode).toBeUndefined() + } + }) + + it("should accept true when provided", () => { + const result = providerSettingsSchemaDiscriminated.parse({ + apiProvider: "openai", + openAiModelId: "test-model", + openAiToolStrictMode: true, + }) + expect(result.apiProvider).toBe("openai") + if (result.apiProvider === "openai") { + expect(result.openAiToolStrictMode).toBe(true) + } + }) + + it("should accept false when provided", () => { + const result = providerSettingsSchemaDiscriminated.parse({ + apiProvider: "openai", + openAiModelId: "test-model", + openAiToolStrictMode: false, + }) + expect(result.apiProvider).toBe("openai") + if (result.apiProvider === "openai") { + expect(result.openAiToolStrictMode).toBe(false) + } + }) + + it("should not break existing profile deserialization when absent", () => { + const existingProfile = { + apiProvider: "openai" as const, + openAiBaseUrl: "https://api.example.com/v1", + openAiApiKey: "sk-test", + openAiModelId: "gpt-4", + openAiStreamingEnabled: true, + } + const result = providerSettingsSchemaDiscriminated.parse(existingProfile) + expect(result.apiProvider).toBe("openai") + if (result.apiProvider === "openai") { + expect(result.openAiModelId).toBe("gpt-4") + expect(result.openAiToolStrictMode).toBeUndefined() + } + }) + + it("should only exist on the openai (OpenAI Compatible) provider profile", () => { + const openAiResult = providerSettingsSchemaDiscriminated.parse({ + apiProvider: "openai", + openAiToolStrictMode: true, + }) + expect(openAiResult.apiProvider).toBe("openai") + if (openAiResult.apiProvider === "openai") { + expect(openAiResult.openAiToolStrictMode).toBe(true) + } + + // Anthropic provider should not have this field + const anthropicResult = providerSettingsSchemaDiscriminated.parse({ + apiProvider: "anthropic", + apiKey: "sk-test", + }) + expect(anthropicResult.apiProvider).toBe("anthropic") + expect((anthropicResult as Record).openAiToolStrictMode).toBeUndefined() + }) +}) diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index e17cd5ddbc..8cd868f297 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -248,6 +248,7 @@ const openAiSchema = baseProviderSettingsSchema.extend({ openAiStreamingEnabled: z.boolean().optional(), openAiHostHeader: z.string().optional(), // Keep temporarily for backward compatibility during migration. openAiHeaders: z.record(z.string(), z.string()).optional(), + openAiToolStrictMode: z.boolean().optional(), // Profile-scoped strict function-tool schema toggle for OpenAI Compatible provider. Absent = false (backward compatible). }) const ollamaSchema = baseProviderSettingsSchema.extend({ diff --git a/packages/types/src/providers/deepseek.ts b/packages/types/src/providers/deepseek.ts index 9387d6a4ae..0e40c2be13 100644 --- a/packages/types/src/providers/deepseek.ts +++ b/packages/types/src/providers/deepseek.ts @@ -14,14 +14,13 @@ export const deepSeekModels = { contextWindow: 1_000_000, supportsImages: true, supportsPromptCache: true, - supportsReasoningEffort: ["disable", "low", "high", "max"], // Updated 2026-08-01 + supportsReasoningEffort: ["disable", "low", "medium", "high", "xhigh"], preserveReasoning: true, reasoningEffort: "high", - inputPrice: 0, // the inputs are priced as cache read/write, so `inputPrice` should be 0 - // the peak/off-peak pricing policy has not been implemented yet - Updated 2026-08-01 - outputPrice: 0.28, // $0.28 per million tokens - Updated 2026-08-01 - cacheWritesPrice: 0.14, // $0.14 per million tokens (cache miss) - Updated 2026-08-01 - cacheReadsPrice: 0.0028, // $0.0028 per million tokens (cache hit) - Updated 2026-08-01 + inputPrice: 0.14, // $0.14 per million tokens (cache miss) - Updated Apr 29, 2026 + outputPrice: 0.28, // $0.28 per million tokens - Updated Apr 29, 2026 + cacheWritesPrice: 0.14, // $0.14 per million tokens (cache miss) - Updated Apr 29, 2026 + cacheReadsPrice: 0.0028, // $0.0028 per million tokens (cache hit) - Updated Apr 29, 2026 description: `DeepSeek-V4-Flash is DeepSeek's fast, cost-efficient V4 model. It supports thinking and non-thinking modes, JSON output, tool calls, chat prefix completion (beta), and FIM completion (beta) in non-thinking mode.`, }, "deepseek-v4-pro": { @@ -29,17 +28,42 @@ export const deepSeekModels = { contextWindow: 1_000_000, supportsImages: true, supportsPromptCache: true, - supportsReasoningEffort: ["disable", "high", "max"], // Updated 2026-08-01 + supportsReasoningEffort: ["disable", "low", "medium", "high", "xhigh"], preserveReasoning: true, reasoningEffort: "high", - inputPrice: 0, // the inputs are priced as cache read/write, so `inputPrice` should be 0 - // the peak/off-peak pricing policy has not been implemented yet - Updated 2026-08-01 - outputPrice: 0.87, // $0.87 per million tokens - Updated 2026-08-01 - cacheWritesPrice: 0.435, // $0.435 per million tokens (cache miss) - Updated 2026-08-01 - cacheReadsPrice: 0.003625, // $0.003625 per million tokens (cache hit) - Updated 2026-08-01 + // TODO(deepseek): Re-check V4 Pro discounted prices after DeepSeek's 2026-05-31 discount end date. + inputPrice: 0.435, // $0.435 per million tokens (cache miss, discounted) - Updated Apr 29, 2026 + outputPrice: 0.87, // $0.87 per million tokens (discounted) - Updated Apr 29, 2026 + cacheWritesPrice: 0.435, // $0.435 per million tokens (cache miss, discounted) - Updated Apr 29, 2026 + cacheReadsPrice: 0.003625, // $0.003625 per million tokens (cache hit, discounted) - Updated Apr 29, 2026 description: `DeepSeek-V4-Pro is DeepSeek's strongest V4 model for reasoning, coding, long-context, and agentic workloads. It supports thinking and non-thinking modes, JSON output, tool calls, chat prefix completion (beta), and FIM completion (beta) in non-thinking mode.`, }, + // TODO(deepseek): Remove this compatibility alias after DeepSeek's 2026-07-24 retirement date. + "deepseek-chat": { + maxTokens: 8192, // 8K max output + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: true, + inputPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025 + outputPrice: 0.42, // $0.42 per million tokens - Updated Dec 9, 2025 + cacheWritesPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025 + cacheReadsPrice: 0.028, // $0.028 per million tokens (cache hit) - Updated Dec 9, 2025 + description: `Legacy compatibility alias for the non-thinking mode of deepseek-v4-flash. DeepSeek plans to deprecate this model name on 2026-07-24.`, + }, + // TODO(deepseek): Remove this compatibility alias after DeepSeek's 2026-07-24 retirement date. + "deepseek-reasoner": { + maxTokens: 8192, // 8K max output + contextWindow: 128_000, + supportsImages: false, + supportsPromptCache: true, + preserveReasoning: true, + inputPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025 + outputPrice: 0.42, // $0.42 per million tokens - Updated Dec 9, 2025 + cacheWritesPrice: 0.28, // $0.28 per million tokens (cache miss) - Updated Dec 9, 2025 + cacheReadsPrice: 0.028, // $0.028 per million tokens (cache hit) - Updated Dec 9, 2025 + description: `Legacy compatibility alias for the thinking mode of deepseek-v4-flash. DeepSeek plans to deprecate this model name on 2026-07-24.`, + }, } as const satisfies Record // https://api-docs.deepseek.com/quick_start/parameter_settings -export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.0 +export const DEEP_SEEK_DEFAULT_TEMPERATURE = 0.3 diff --git a/src/__tests__/history-resume-delegation.spec.ts b/src/__tests__/history-resume-delegation.spec.ts index 24ea04dd8e..fc496d0c84 100644 --- a/src/__tests__/history-resume-delegation.spec.ts +++ b/src/__tests__/history-resume-delegation.spec.ts @@ -26,7 +26,7 @@ vi.mock("vscode", () => { return { window, workspace, env, Uri, commands, ExtensionMode, version } }) -// Mock TelemetryService (needed by attemptCompletionTool's emitPublicTaskCompleted) +// Mock TelemetryService (needed by attemptCompletionTool's emitTaskCompleted) vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { instance: { @@ -1255,7 +1255,6 @@ describe("History resume delegation - parent metadata transitions", () => { userMessageContent: [], consecutiveMistakeCount: 0, emitFinalTokenUsageUpdate: vi.fn(), - flushTelemetryInstallment: vi.fn(), } as unknown as import("../core/task/Task").Task const block = { diff --git a/src/__tests__/nested-delegation-resume.spec.ts b/src/__tests__/nested-delegation-resume.spec.ts index 8464f81b12..35341f935c 100644 --- a/src/__tests__/nested-delegation-resume.spec.ts +++ b/src/__tests__/nested-delegation-resume.spec.ts @@ -203,7 +203,6 @@ describe("Nested delegation resume (A → B → C)", () => { userMessageContent: [], consecutiveMistakeCount: 0, emitFinalTokenUsageUpdate: vi.fn(), - flushTelemetryInstallment: vi.fn(), } as unknown as Task const blockC = { @@ -251,7 +250,6 @@ describe("Nested delegation resume (A → B → C)", () => { userMessageContent: [], consecutiveMistakeCount: 0, emitFinalTokenUsageUpdate: vi.fn(), - flushTelemetryInstallment: vi.fn(), } as unknown as Task const blockB = { diff --git a/src/__tests__/task-run-dispatch.spec.ts b/src/__tests__/task-run-dispatch.spec.ts index 90af5a519d..283cec3338 100644 --- a/src/__tests__/task-run-dispatch.spec.ts +++ b/src/__tests__/task-run-dispatch.spec.ts @@ -17,7 +17,6 @@ type Runnable = { metadata: { task?: string | null; images?: string[] | null } resumeTaskFromHistory: () => Promise startTask: (task?: string, images?: string[]) => Promise - startIdleTelemetryCheck: () => void } function makeRunnable(overrides: Partial = {}): Runnable & { run(): Promise } { @@ -28,7 +27,6 @@ function makeRunnable(overrides: Partial = {}): Runnable & { run(): Pr metadata: { task: undefined, images: undefined }, resumeTaskFromHistory: vi.fn().mockResolvedValue(undefined), startTask: vi.fn().mockResolvedValue(undefined), - startIdleTelemetryCheck: vi.fn(), ...overrides, } // Bind the real run() implementation from Task.prototype to our stand-in. diff --git a/src/api/providers/__tests__/base-provider.spec.ts b/src/api/providers/__tests__/base-provider.spec.ts index ced452f5a5..66109e7cf3 100644 --- a/src/api/providers/__tests__/base-provider.spec.ts +++ b/src/api/providers/__tests__/base-provider.spec.ts @@ -28,8 +28,8 @@ class TestProvider extends BaseProvider { } // Expose protected method for testing - public testConvertToolsForOpenAI(tools: any[] | undefined): any[] | undefined { - return this.convertToolsForOpenAI(tools) + public testConvertToolsForOpenAI(tools: any[] | undefined, strictMode: boolean = false): any[] | undefined { + return this.convertToolsForOpenAI(tools, strictMode) } } @@ -176,6 +176,16 @@ describe("BaseProvider", () => { expect(result.additionalProperties).toBe(false) expect(result.required).toEqual([]) }) + + it("should add empty properties and required arrays to zero-argument object schemas", () => { + const result = provider.testConvertToolSchemaForOpenAI({ type: "object" }) + + expect(result).toMatchObject({ + additionalProperties: false, + properties: {}, + required: [], + }) + }) }) describe("convertToolsForOpenAI", () => { @@ -184,100 +194,230 @@ describe("BaseProvider", () => { expect(result).toBeUndefined() }) - it("should set strict: true for non-MCP tools", () => { + it("should preserve non-function tools unchanged", () => { const tools = [ { - type: "function", - function: { - name: "read_file", - description: "Read a file", - parameters: { type: "object", properties: {} }, - }, + type: "other_type", + data: "some data", }, ] const result = provider.testConvertToolsForOpenAI(tools) - expect(result?.[0].function.strict).toBe(true) + expect(result?.[0]).toEqual(tools[0]) }) - it("should set strict: false for MCP tools (mcp-- prefix)", () => { - const tools = [ - { - type: "function", - function: { - name: "mcp--github--get_me", - description: "Get current user", - parameters: { type: "object", properties: {} }, + describe("strictMode = false (default)", () => { + it("should set strict: false for non-MCP tools", () => { + const tools = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: {} }, + }, }, - }, - ] + ] - const result = provider.testConvertToolsForOpenAI(tools) + const result = provider.testConvertToolsForOpenAI(tools) - expect(result?.[0].function.strict).toBe(false) - }) + expect(result?.[0].function.strict).toBe(false) + }) - it("should apply schema conversion to non-MCP tools", () => { - const tools = [ - { - type: "function", - function: { - name: "read_file", - description: "Read a file", - parameters: { - type: "object", - properties: { - path: { type: "string" }, + it("should preserve original best-effort schema for non-MCP tools (no hardening)", () => { + const tools = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + encoding: { type: ["string", "null"] }, + }, + // Note: no required array, no additionalProperties }, }, }, - }, - ] + ] + + const result = provider.testConvertToolsForOpenAI(tools) + + // Schema should NOT be hardened when strict is false + expect(result?.[0].function.parameters.additionalProperties).toBeUndefined() + expect(result?.[0].function.parameters.required).toBeUndefined() + // Nullable type should be preserved as-is + expect(result?.[0].function.parameters.properties.encoding.type).toEqual(["string", "null"]) + }) + + it("should set strict: false for MCP tools", () => { + const tools = [ + { + type: "function", + function: { + name: "mcp--github--get_me", + description: "Get current user", + parameters: { type: "object", properties: {} }, + }, + }, + ] - const result = provider.testConvertToolsForOpenAI(tools) + const result = provider.testConvertToolsForOpenAI(tools) - expect(result?.[0].function.parameters.additionalProperties).toBe(false) - expect(result?.[0].function.parameters.required).toEqual(["path"]) - }) + expect(result?.[0].function.strict).toBe(false) + }) - it("should not apply schema conversion to MCP tools in base-provider", () => { - // Note: In base-provider, MCP tools are passed through unchanged - // The openai-native provider has its own handling for MCP tools - const tools = [ - { - type: "function", - function: { - name: "mcp--github--get_me", - description: "Get current user", - parameters: { - type: "object", - properties: { - token: { type: "string" }, + it("should preserve original schema for MCP tools (no hardening)", () => { + const tools = [ + { + type: "function", + function: { + name: "mcp--github--get_me", + description: "Get current user", + parameters: { + type: "object", + properties: { + token: { type: "string" }, + }, + required: ["token"], }, - required: ["token"], }, }, - }, - ] + ] - const result = provider.testConvertToolsForOpenAI(tools) + const result = provider.testConvertToolsForOpenAI(tools) - // MCP tools pass through original parameters in base-provider - expect(result?.[0].function.parameters.additionalProperties).toBeUndefined() + expect(result?.[0].function.parameters.additionalProperties).toBeUndefined() + expect(result?.[0].function.parameters.required).toEqual(["token"]) + }) }) - it("should preserve non-function tools unchanged", () => { - const tools = [ - { - type: "other_type", - data: "some data", - }, - ] + describe("strictMode = true", () => { + it("should set strict: true for non-MCP tools", () => { + const tools = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { type: "object", properties: {} }, + }, + }, + ] - const result = provider.testConvertToolsForOpenAI(tools) + const result = provider.testConvertToolsForOpenAI(tools, true) - expect(result?.[0]).toEqual(tools[0]) + expect(result?.[0].function.strict).toBe(true) + }) + + it("should apply schema hardening to non-MCP tools", () => { + const tools = [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { + path: { type: "string" }, + }, + }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools, true) + + expect(result?.[0].function.parameters.additionalProperties).toBe(false) + expect(result?.[0].function.parameters.required).toEqual(["path"]) + }) + + it("should harden nested objects and arrays in non-MCP tools", () => { + const tools = [ + { + type: "function", + function: { + name: "create_user", + description: "Create a user", + parameters: { + type: "object", + properties: { + user: { + type: "object", + properties: { + name: { type: "string" }, + }, + }, + tags: { + type: "array", + items: { + type: "object", + properties: { + label: { type: "string" }, + }, + }, + }, + }, + }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools, true) + + expect(result?.[0].function.parameters.additionalProperties).toBe(false) + expect(result?.[0].function.parameters.properties.user.additionalProperties).toBe(false) + expect(result?.[0].function.parameters.properties.tags.items.additionalProperties).toBe(false) + }) + + it("should ALWAYS set strict: false for MCP tools even when strictMode is true", () => { + const tools = [ + { + type: "function", + function: { + name: "mcp--github--get_me", + description: "Get current user", + parameters: { type: "object", properties: {} }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools, true) + + expect(result?.[0].function.strict).toBe(false) + }) + + it("should preserve original schema for MCP tools even when strictMode is true", () => { + const tools = [ + { + type: "function", + function: { + name: "mcp--github--get_me", + description: "Get current user", + parameters: { + type: "object", + properties: { + token: { type: "string" }, + optional_param: { type: ["string", "null"] }, + }, + required: ["token"], + }, + }, + }, + ] + + const result = provider.testConvertToolsForOpenAI(tools, true) + + // MCP schema should NOT be hardened + expect(result?.[0].function.parameters.additionalProperties).toBeUndefined() + expect(result?.[0].function.parameters.required).toEqual(["token"]) + // Nullable type preserved + expect(result?.[0].function.parameters.properties.optional_param.type).toEqual(["string", "null"]) + }) }) }) }) diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index 02f0ad4a6d..cc80e8769c 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -124,11 +124,11 @@ vi.mock("openai", () => { import OpenAI from "openai" import type { Anthropic } from "@anthropic-ai/sdk" -import { deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo, DeepSeekModelId } from "@roo-code/types" +import { deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" -import { DeepSeekHandler, normalizeDeepSeekReasoningEffort } from "../deepseek" +import { DeepSeekHandler } from "../deepseek" describe("DeepSeekHandler", () => { let handler: DeepSeekHandler @@ -137,7 +137,7 @@ describe("DeepSeekHandler", () => { beforeEach(() => { mockOptions = { deepSeekApiKey: "test-api-key", - apiModelId: "deepseek-v4-flash", + apiModelId: "deepseek-chat", deepSeekBaseUrl: "https://api.deepseek.com", } handler = new DeepSeekHandler(mockOptions) @@ -208,11 +208,11 @@ describe("DeepSeekHandler", () => { const model = handler.getModel() expect(model.id).toBe(mockOptions.apiModelId) expect(model.info).toBeDefined() - expect(model.info.maxTokens).toBe(384_000) - expect(model.info.contextWindow).toBe(1_000_000) - expect(model.info.supportsImages).toBe(true) + expect(model.info.maxTokens).toBe(8192) // deepseek-chat legacy alias has 8K max + expect(model.info.contextWindow).toBe(128_000) + expect(model.info.supportsImages).toBe(false) expect(model.info.supportsPromptCache).toBe(true) // Should be true now - expect((model.info as ModelInfo).preserveReasoning).toBe(true) + expect((model.info as ModelInfo).preserveReasoning).toBeUndefined() }) it("should use deepseek-v4-flash as the default model ID for new configs", () => { @@ -226,7 +226,21 @@ describe("DeepSeekHandler", () => { expect(model.info.maxTokens).toBe(384_000) expect(model.info.contextWindow).toBe(1_000_000) expect(model.info.supportsImages).toBe(true) - expect((model.info as ModelInfo).supportsReasoningEffort).toContain("max") + expect((model.info as ModelInfo).supportsReasoningEffort).toContain("xhigh") + }) + + it("should return correct model info for deepseek-reasoner", () => { + const handlerWithReasoner = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + }) + const model = handlerWithReasoner.getModel() + expect(model.id).toBe("deepseek-reasoner") + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) // deepseek-reasoner has 8K max + expect(model.info.contextWindow).toBe(128_000) + expect(model.info.supportsImages).toBe(false) + expect(model.info.supportsPromptCache).toBe(true) }) it("should return correct model info for deepseek-v4-pro", () => { @@ -245,6 +259,31 @@ describe("DeepSeekHandler", () => { expect((model.info as ModelInfo).reasoningEffort).toBe("high") }) + it("should have preserveReasoning enabled for deepseek-reasoner to support interleaved thinking", () => { + // This is critical for DeepSeek's interleaved thinking mode with tool calls. + // See: https://api-docs.deepseek.com/guides/thinking_mode + // The reasoning_content needs to be passed back during tool call continuation + // within the same turn for the model to continue reasoning properly. + const handlerWithReasoner = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-reasoner", + }) + const model = handlerWithReasoner.getModel() + // Cast to ModelInfo to access preserveReasoning which is an optional property + expect((model.info as ModelInfo).preserveReasoning).toBe(true) + }) + + it("should NOT have preserveReasoning enabled for deepseek-chat", () => { + // deepseek-chat doesn't use thinking mode, so no need to preserve reasoning + const chatHandler = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-chat", + }) + const model = chatHandler.getModel() + // Cast to ModelInfo to access preserveReasoning which is an optional property + expect((model.info as ModelInfo).preserveReasoning).toBeUndefined() + }) + it("should return provided model ID with default model info if model does not exist", () => { const handlerWithInvalidModel = new DeepSeekHandler({ ...mockOptions, @@ -501,10 +540,10 @@ describe("DeepSeekHandler", () => { }, ] - it("should handle reasoning_content in streaming responses for deepseek-v4-pro", async () => { + it("should handle reasoning_content in streaming responses for deepseek-reasoner", async () => { const reasonerHandler = new DeepSeekHandler({ ...mockOptions, - apiModelId: "deepseek-v4-pro", + apiModelId: "deepseek-reasoner", }) const stream = reasonerHandler.createMessage(systemPrompt, messages) @@ -520,10 +559,10 @@ describe("DeepSeekHandler", () => { expect(reasoningChunks[1].text).toBe(" I'll analyze step by step.") }) - it("should pass thinking parameter for deepseek-v4-pro model", async () => { + it("should pass thinking parameter for deepseek-reasoner model", async () => { const reasonerHandler = new DeepSeekHandler({ ...mockOptions, - apiModelId: "deepseek-v4-pro", + apiModelId: "deepseek-reasoner", }) const stream = reasonerHandler.createMessage(systemPrompt, messages) @@ -540,7 +579,7 @@ describe("DeepSeekHandler", () => { {}, // Empty path options for non-Azure URLs ) const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.reasoning_effort).toBe("high") + expect(callArgs.reasoning_effort).toBeUndefined() }) it("should enable thinking by default for deepseek-v4-flash", async () => { @@ -580,6 +619,27 @@ describe("DeepSeekHandler", () => { expect(callArgs.max_completion_tokens).toBe(32_000) }) + it("should map xhigh reasoning effort to DeepSeek max effort", async () => { + const v4Handler = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-v4-pro", + reasoningEffort: "xhigh", + }) + + const stream = v4Handler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // Consume the stream + } + + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + thinking: { type: "enabled" }, + reasoning_effort: "max", + }), + {}, + ) + }) + it("should disable thinking for deepseek-v4 models when reasoning is disabled", async () => { const v4Handler = new DeepSeekHandler({ ...mockOptions, @@ -614,10 +674,26 @@ describe("DeepSeekHandler", () => { expect(callArgs.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) }) + it("should NOT pass thinking parameter for deepseek-chat model", async () => { + const chatHandler = new DeepSeekHandler({ + ...mockOptions, + apiModelId: "deepseek-chat", + }) + + const stream = chatHandler.createMessage(systemPrompt, messages) + for await (const _chunk of stream) { + // Consume the stream + } + + // Verify that the thinking parameter was NOT passed to the API + const callArgs = mockCreate.mock.calls[0][0] + expect(callArgs.thinking).toBeUndefined() + }) + it("should handle tool calls with reasoning_content", async () => { const reasonerHandler = new DeepSeekHandler({ ...mockOptions, - apiModelId: "deepseek-v4-pro", + apiModelId: "deepseek-reasoner", }) const tools: any[] = [ @@ -647,71 +723,4 @@ describe("DeepSeekHandler", () => { expect(toolCallChunks[0].name).toBe("get_weather") }) }) - - describe("normalizeDeepSeekReasoningEffort", () => { - // https://api-docs.deepseek.com/guides/thinking_mode/ - it("should map acceptable reasoning efforts the same way as stated by the official documentation", async () => { - const mappings: { - modelId: DeepSeekModelId - rawReasoningEffort: string - mappedReasoningEffort: string | undefined - }[] = [ - { - modelId: "deepseek-v4-flash", - rawReasoningEffort: "disable", - mappedReasoningEffort: undefined, - }, - { - modelId: "deepseek-v4-flash", - rawReasoningEffort: "low", - mappedReasoningEffort: "low", - }, - { - modelId: "deepseek-v4-flash", - rawReasoningEffort: "high", - mappedReasoningEffort: "high", - }, - { - modelId: "deepseek-v4-flash", - rawReasoningEffort: "xhigh", - mappedReasoningEffort: "high", - }, - { - modelId: "deepseek-v4-flash", - rawReasoningEffort: "max", - mappedReasoningEffort: "max", - }, - { - modelId: "deepseek-v4-pro", - rawReasoningEffort: "disable", - mappedReasoningEffort: undefined, - }, - { - modelId: "deepseek-v4-pro", - rawReasoningEffort: "low", - mappedReasoningEffort: "high", - }, - { - modelId: "deepseek-v4-pro", - rawReasoningEffort: "high", - mappedReasoningEffort: "high", - }, - { - modelId: "deepseek-v4-pro", - rawReasoningEffort: "xhigh", - mappedReasoningEffort: "max", - }, - { - modelId: "deepseek-v4-pro", - rawReasoningEffort: "max", - mappedReasoningEffort: "max", - }, - ] - - for (const { modelId, rawReasoningEffort, mappedReasoningEffort } of mappings) { - const result = normalizeDeepSeekReasoningEffort(modelId, rawReasoningEffort) - expect(result).toBe(mappedReasoningEffort) - } - }) - }) }) diff --git a/src/api/providers/__tests__/fireworks.spec.ts b/src/api/providers/__tests__/fireworks.spec.ts index a066b139cf..33d50ab7b2 100644 --- a/src/api/providers/__tests__/fireworks.spec.ts +++ b/src/api/providers/__tests__/fireworks.spec.ts @@ -6,7 +6,6 @@ import OpenAI from "openai" import { type FireworksModelId, fireworksDefaultModelId, fireworksModels } from "@roo-code/types" import { FireworksHandler } from "../fireworks" -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" // Create mock functions const mockCreate = vi.fn() @@ -30,9 +29,9 @@ describe("FireworksHandler", () => { beforeEach(() => { vi.clearAllMocks() // Set up default mock implementation - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { content: "Test response" }, @@ -40,8 +39,8 @@ describe("FireworksHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [ { delta: {}, @@ -53,9 +52,9 @@ describe("FireworksHandler", () => { completion_tokens: 5, total_tokens: 15, }, - }, - ]), - ) + } + }, + })) handler = new FireworksHandler({ fireworksApiKey: "test-key" }) }) @@ -437,7 +436,19 @@ describe("FireworksHandler", () => { it("createMessage should yield text content from stream", async () => { const testContent = "This is test content from Fireworks stream" - mockCreate.mockImplementationOnce(() => asyncStreamFrom([{ choices: [{ delta: { content: testContent } }] }])) + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { choices: [{ delta: { content: testContent } }] }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() @@ -447,9 +458,19 @@ describe("FireworksHandler", () => { }) it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }]), - ) + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() @@ -466,7 +487,15 @@ describe("FireworksHandler", () => { fireworksApiKey: "test-fireworks-api-key", }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) const systemPrompt = "Test system prompt for Fireworks" const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Fireworks" }] @@ -494,7 +523,13 @@ describe("FireworksHandler", () => { fireworksApiKey: "test-fireworks-api-key", }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) const messageGenerator = handlerWithModel.createMessage("system", []) await messageGenerator.next() @@ -514,7 +549,13 @@ describe("FireworksHandler", () => { fireworksApiKey: "test-fireworks-api-key", }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) const messageGenerator = handlerWithModel.createMessage("system", []) await messageGenerator.next() @@ -536,7 +577,13 @@ describe("FireworksHandler", () => { modelTemperature: 0.7, }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) const messageGenerator = handlerWithModel.createMessage("system", []) await messageGenerator.next() @@ -563,9 +610,9 @@ describe("FireworksHandler", () => { }) it("createMessage should handle stream with multiple chunks", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { content: "Hello" }, @@ -573,8 +620,8 @@ describe("FireworksHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [ { delta: { content: " world" }, @@ -582,8 +629,8 @@ describe("FireworksHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [ { delta: {}, @@ -595,15 +642,18 @@ describe("FireworksHandler", () => { completion_tokens: 10, total_tokens: 15, }, - }, - ]), - ) + } + }, + })) const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] const stream = handler.createMessage(systemPrompt, messages) - const chunks = await collectStream(stream) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } expect(chunks[0]).toEqual({ type: "text", text: "Hello" }) expect(chunks[1]).toEqual({ type: "text", text: " world" }) diff --git a/src/api/providers/__tests__/friendli.spec.ts b/src/api/providers/__tests__/friendli.spec.ts index 7c31c754e7..c8fd81ad19 100644 --- a/src/api/providers/__tests__/friendli.spec.ts +++ b/src/api/providers/__tests__/friendli.spec.ts @@ -8,7 +8,6 @@ import { friendliDefaultModelId, friendliModels } from "@roo-code/types" import { buildApiHandler } from "../../index" import { getModelMaxOutputTokens } from "../../../shared/api" import { FriendliHandler } from "../friendli" -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" // Create mock functions const mockCreate = vi.fn() @@ -32,9 +31,9 @@ describe("FriendliHandler", () => { beforeEach(() => { vi.clearAllMocks() // Set up default mock implementation - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { content: "Test response" }, @@ -42,8 +41,8 @@ describe("FriendliHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [ { delta: {}, @@ -55,9 +54,9 @@ describe("FriendliHandler", () => { completion_tokens: 5, total_tokens: 15, }, - }, - ]), - ) + } + }, + })) handler = new FriendliHandler({ friendliApiKey: "test-key" }) }) @@ -190,7 +189,19 @@ describe("FriendliHandler", () => { it("createMessage should yield text content from stream", async () => { const testContent = "This is test content from Friendli stream" - mockCreate.mockImplementationOnce(() => asyncStreamFrom([{ choices: [{ delta: { content: testContent } }] }])) + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { choices: [{ delta: { content: testContent } }] }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() @@ -200,9 +211,19 @@ describe("FriendliHandler", () => { }) it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }]), - ) + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + next: vi + .fn() + .mockResolvedValueOnce({ + done: false, + value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, + }) + .mockResolvedValueOnce({ done: true }), + }), + } + }) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() @@ -219,7 +240,15 @@ describe("FriendliHandler", () => { friendliApiKey: "test-friendli-api-key", }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => { + return { + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + } + }) const systemPrompt = "Test system prompt for Friendli" const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Friendli" }] @@ -247,7 +276,13 @@ describe("FriendliHandler", () => { modelTemperature: 0.3, }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) const messageGenerator = handlerWithModel.createMessage("system", []) await messageGenerator.next() @@ -273,9 +308,9 @@ describe("FriendliHandler", () => { }) it("createMessage should handle stream with multiple chunks", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { content: "Hello" }, @@ -283,8 +318,8 @@ describe("FriendliHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [ { delta: { content: " world" }, @@ -292,8 +327,8 @@ describe("FriendliHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [ { delta: {}, @@ -305,15 +340,18 @@ describe("FriendliHandler", () => { completion_tokens: 10, total_tokens: 15, }, - }, - ]), - ) + } + }, + })) const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] const stream = handler.createMessage(systemPrompt, messages) - const chunks = await collectStream(stream) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } expect(chunks[0]).toEqual({ type: "text", text: "Hello" }) expect(chunks[1]).toEqual({ type: "text", text: " world" }) @@ -379,7 +417,13 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "high", }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) await handler.createMessage("system", []).next() @@ -402,7 +446,13 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { enableReasoningEffort: false, }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) await handler.createMessage("system", []).next() @@ -421,7 +471,13 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "none", }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) await handler.createMessage("system", []).next() @@ -440,7 +496,13 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "disable", }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) await handler.createMessage("system", []).next() @@ -458,7 +520,13 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { // No enableReasoningEffort or reasoningEffort — model default "high" kicks in }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) await handler.createMessage("system", []).next() @@ -477,7 +545,13 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "high", }) - mockCreate.mockImplementationOnce(() => asyncStreamFrom([])) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + })) await handler.createMessage("system", []).next() @@ -495,25 +569,28 @@ describe("FriendliHandler — Friendli-specific reasoning params", () => { reasoningEffort: "high", }) - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: { reasoning_content: "Let me think..." } }], usage: null, - }, - { + } + yield { choices: [{ delta: { content: "The answer is 42" } }], usage: null, - }, - { + } + yield { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, - }, - ]), - ) + } + }, + })) const stream = handler.createMessage("system", []) - const chunks = await collectStream(stream) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } expect(chunks).toContainEqual({ type: "reasoning", text: "Let me think..." }) expect(chunks).toContainEqual({ type: "text", text: "The answer is 42" }) diff --git a/src/api/providers/__tests__/kenari.spec.ts b/src/api/providers/__tests__/kenari.spec.ts index d6b95ce0b1..28691437a2 100644 --- a/src/api/providers/__tests__/kenari.spec.ts +++ b/src/api/providers/__tests__/kenari.spec.ts @@ -17,7 +17,6 @@ import { kenariDefaultModelId } from "@roo-code/types" import { KenariHandler } from "../kenari" import { getModels } from "../fetchers/modelCache" import { ApiHandlerOptions } from "../../../shared/api" -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" vitest.mock("openai") vitest.mock("delay", () => ({ default: vitest.fn(() => Promise.resolve()) })) @@ -85,9 +84,9 @@ describe("KenariHandler", () => { describe("createMessage", () => { beforeEach(() => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -105,8 +104,8 @@ describe("KenariHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 12, @@ -114,16 +113,19 @@ describe("KenariHandler", () => { total_tokens: 19, prompt_tokens_details: { cached_tokens: 4 }, }, - }, - ]), - ) + } + }, + })) }) it("streams text, reasoning, tool-call and usage chunks", async () => { const handler = new KenariHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = await collectStream(handler.createMessage("You are helpful.", messages)) + const chunks = [] + for await (const chunk of handler.createMessage("You are helpful.", messages)) { + chunks.push(chunk) + } expect(chunks).toContainEqual({ type: "text", text: "Hello" }) expect(chunks).toContainEqual({ type: "reasoning", text: "thinking…" }) @@ -143,31 +145,37 @@ describe("KenariHandler", () => { }) it("yields nothing for a chunk whose delta has no content, reasoning or tool calls", async () => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { choices: [{ delta: {}, index: 0 }], usage: null }, - { choices: [], usage: null }, - ]), - ) + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: {}, index: 0 }], usage: null } + yield { choices: [], usage: null } + }, + })) const handler = new KenariHandler(mockOptions) - const chunks = await collectStream(handler.createMessage("sys", [{ role: "user", content: "Hi" }])) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } expect(chunks).toEqual([]) }) it("streams tool call chunks even when the function name and arguments are missing", async () => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: { tool_calls: [{ index: 1 }] }, index: 0 }], usage: null, - }, - ]), - ) + } + }, + })) const handler = new KenariHandler(mockOptions) - const chunks = await collectStream(handler.createMessage("sys", [{ role: "user", content: "Hi" }])) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } expect(chunks).toEqual([ { @@ -181,17 +189,20 @@ describe("KenariHandler", () => { }) it("reports undefined cache reads when usage has no prompt_tokens_details", async () => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, - }, - ]), - ) + } + }, + })) const handler = new KenariHandler(mockOptions) - const chunks = await collectStream(handler.createMessage("sys", [{ role: "user", content: "Hi" }])) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } expect(chunks).toEqual([ { @@ -204,33 +215,39 @@ describe("KenariHandler", () => { }) it("skips the reasoning chunk when reasoning_content is an empty string", async () => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: { content: "Hi", reasoning_content: "" }, index: 0 }], usage: null, - }, - ]), - ) + } + }, + })) const handler = new KenariHandler(mockOptions) - const chunks = await collectStream(handler.createMessage("sys", [{ role: "user", content: "Hi" }])) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } expect(chunks).toEqual([{ type: "text", text: "Hi" }]) }) it("emits reasoning from the OpenRouter-style `reasoning` field when reasoning_content is absent", async () => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: { content: "Hi", reasoning: "thinking…" }, index: 0 }], usage: null, - }, - ]), - ) + } + }, + })) const handler = new KenariHandler(mockOptions) - const chunks = await collectStream(handler.createMessage("sys", [{ role: "user", content: "Hi" }])) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } expect(chunks).toContainEqual({ type: "reasoning", text: "thinking…" }) }) @@ -247,7 +264,9 @@ describe("KenariHandler", () => { }) const handler = new KenariHandler({ kenariApiKey: "test-key", kenariModelId: "openai/o3-mini" }) - await collectStream(handler.createMessage("sys", [{ role: "user", content: "Hi" }])) + for await (const _chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + void _chunk // drain + } expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ @@ -259,35 +278,40 @@ describe("KenariHandler", () => { it("sends an explicitly configured model temperature", async () => { const handler = new KenariHandler({ ...mockOptions, modelTemperature: 0.7 }) - await collectStream(handler.createMessage("sys", [{ role: "user", content: "Hi" }])) + for await (const _chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + void _chunk // drain + } expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ temperature: 0.7 })) }) it("honors metadata.parallelToolCalls false", async () => { const handler = new KenariHandler(mockOptions) - await collectStream( - handler.createMessage("sys", [{ role: "user", content: "Hi" }], { - taskId: "task-1", - parallelToolCalls: false, - }), - ) + for await (const _chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }], { + taskId: "task-1", + parallelToolCalls: false, + })) { + void _chunk // drain + } expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ parallel_tool_calls: false })) }) it("reports zero usage when the upstream counts are zero", async () => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: { content: "x" }, index: 0 }], usage: { prompt_tokens: 0, completion_tokens: 0 }, - }, - ]), - ) + } + }, + })) const handler = new KenariHandler(mockOptions) - const chunks = await collectStream(handler.createMessage("sys", [{ role: "user", content: "Hi" }])) + const chunks = [] + for await (const chunk of handler.createMessage("sys", [{ role: "user", content: "Hi" }])) { + chunks.push(chunk) + } expect(chunks).toContainEqual({ type: "usage", @@ -300,7 +324,9 @@ describe("KenariHandler", () => { it("requests a streaming completion with usage included", async () => { const handler = new KenariHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk // drain + } expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts index 2e04399f98..c6870e80ef 100644 --- a/src/api/providers/__tests__/lmstudio-native-tools.spec.ts +++ b/src/api/providers/__tests__/lmstudio-native-tools.spec.ts @@ -2,7 +2,6 @@ // Mock OpenAI client - must come before other imports const mockCreate = vi.fn() -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" vi.mock("openai", () => { return { __esModule: true, @@ -59,9 +58,13 @@ describe("LmStudioHandler Native Tools", () => { describe("Native Tool Calling Support", () => { it("should include tools in request when model supports native tools and tools are provided", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: { content: "Test response" } }] }]), - ) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -87,9 +90,13 @@ describe("LmStudioHandler Native Tools", () => { }) it("should include tool_choice when provided", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: { content: "Test response" } }] }]), - ) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -106,9 +113,13 @@ describe("LmStudioHandler Native Tools", () => { }) it("should always include tools and tool_choice in request (tools are always present after PR #10841)", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: { content: "Test response" } }] }]), - ) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -124,9 +135,9 @@ describe("LmStudioHandler Native Tools", () => { }) it("should yield tool_call_partial chunks during streaming", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -143,8 +154,8 @@ describe("LmStudioHandler Native Tools", () => { }, }, ], - }, - { + } + yield { choices: [ { delta: { @@ -159,16 +170,19 @@ describe("LmStudioHandler Native Tools", () => { }, }, ], - }, - ]), - ) + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", tools: testTools, }) - const chunks = await collectStream(stream) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } expect(chunks).toContainEqual({ type: "tool_call_partial", @@ -188,9 +202,13 @@ describe("LmStudioHandler Native Tools", () => { }) it("should set parallel_tool_calls based on metadata", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: { content: "Test response" } }] }]), - ) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -207,9 +225,9 @@ describe("LmStudioHandler Native Tools", () => { }) it("should yield tool_call_end events when finish_reason is tool_calls", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -226,17 +244,17 @@ describe("LmStudioHandler Native Tools", () => { }, }, ], - }, - { + } + yield { choices: [ { delta: {}, finish_reason: "tool_calls", }, ], - }, - ]), - ) + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -268,9 +286,13 @@ describe("LmStudioHandler Native Tools", () => { }) it("should work with parallel tool calls disabled (sends false)", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: { content: "Response" } }] }]), - ) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Response" } }], + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -285,9 +307,9 @@ describe("LmStudioHandler Native Tools", () => { }) it("should handle reasoning content alongside tool calls", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -295,8 +317,8 @@ describe("LmStudioHandler Native Tools", () => { }, }, ], - }, - { + } + yield { choices: [ { delta: { @@ -313,17 +335,17 @@ describe("LmStudioHandler Native Tools", () => { }, }, ], - }, - { + } + yield { choices: [ { delta: {}, finish_reason: "tool_calls", }, ], - }, - ]), - ) + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", diff --git a/src/api/providers/__tests__/mimo.spec.ts b/src/api/providers/__tests__/mimo.spec.ts index 357bbf6861..7da1c84463 100644 --- a/src/api/providers/__tests__/mimo.spec.ts +++ b/src/api/providers/__tests__/mimo.spec.ts @@ -1,5 +1,4 @@ const mockCreate = vi.fn() -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" vi.mock("openai", () => { return { __esModule: true, @@ -7,23 +6,25 @@ vi.mock("openai", () => { 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 }, - }, + create: mockCreate.mockImplementation(async (options) => { + 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 }, + }, + } }, - ]), - ), + } + }), }, }, } @@ -367,7 +368,9 @@ describe("MimoHandler", () => { const stream = handler.createMessage("System prompt", messages) // Consume the stream - await collectStream(stream) + for await (const _chunk of stream) { + // drain + } expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ @@ -382,7 +385,9 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("System prompt", messages) - await collectStream(stream) + for await (const _chunk of stream) { + // drain + } const params = mockCreate.mock.calls[0][0] expect(params.parallel_tool_calls).toBeUndefined() @@ -395,7 +400,9 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("System prompt", messages) - await collectStream(stream) + for await (const _chunk of stream) { + // drain + } const params = mockCreate.mock.calls[0][0] expect(params.stream_options).toEqual({ include_usage: true }) @@ -421,7 +428,9 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("System prompt", messages, { tools } as any) - await collectStream(stream) + for await (const _chunk of stream) { + // drain + } const params = mockCreate.mock.calls[0][0] expect(params.tools).toHaveLength(1) @@ -433,7 +442,11 @@ describe("MimoHandler", () => { { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks = await collectStream(handler.createMessage("System prompt", messages)) + const chunks: any[] = [] + 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) @@ -445,7 +458,11 @@ describe("MimoHandler", () => { { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks = await collectStream(handler.createMessage("System prompt", messages)) + const chunks: any[] = [] + 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) @@ -454,50 +471,56 @@ describe("MimoHandler", () => { }) 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 }] }, - { + 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 = await collectStream(handler.createMessage("System prompt", messages)) + const chunks: any[] = [] + 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 () => - asyncStreamFrom([ - { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }, - { + 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 = await collectStream(handler.createMessage("System prompt", messages)) + const chunks: any[] = [] + 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 () => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -507,28 +530,31 @@ describe("MimoHandler", () => { 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 = await collectStream(handler.createMessage("System prompt", messages)) + const chunks: any[] = [] + 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 () => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -544,8 +570,8 @@ describe("MimoHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [ { delta: { @@ -560,19 +586,23 @@ describe("MimoHandler", () => { }, ], 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 = await collectStream(handler.createMessage("System prompt", messages)) + const chunks: any[] = [] + const stream = handler.createMessage("System prompt", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") expect(toolChunks).toHaveLength(2) @@ -583,13 +613,13 @@ describe("MimoHandler", () => { }) it("should yield usage with cache tokens", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { + 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, @@ -600,15 +630,19 @@ describe("MimoHandler", () => { 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 chunks: any[] = [] + 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) @@ -627,7 +661,10 @@ describe("MimoHandler", () => { ] await expect(async () => { - await collectStream(handler.createMessage("System prompt", messages)) + const stream = handler.createMessage("System prompt", messages) + for await (const _chunk of stream) { + // drain + } }).rejects.toThrow() }) @@ -662,7 +699,9 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("System prompt", messages) - await collectStream(stream) + for await (const _chunk of stream) { + // drain + } const params = mockCreate.mock.calls[0][0] expect(params.messages).toHaveLength(4) // system + user + assistant + tool @@ -682,38 +721,44 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("System prompt", messages) - await collectStream(stream) + 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 () => - asyncStreamFrom([ - { choices: [{}], usage: null }, - { choices: [{ delta: {} }], usage: null }, - { + 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 = await collectStream(handler.createMessage("System prompt", messages)) + const chunks: any[] = [] + 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 handle multiple tool calls in single response", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -734,8 +779,8 @@ describe("MimoHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [ { delta: { @@ -748,13 +793,13 @@ describe("MimoHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [{ delta: {}, index: 0, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, - }, - ]), - ) + } + }, + })) const tools: any[] = [ { @@ -771,7 +816,11 @@ describe("MimoHandler", () => { { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks = await collectStream(handler.createMessage("System", messages, { taskId: "test", tools })) + const chunks: any[] = [] + const stream = handler.createMessage("System", messages, { taskId: "test", tools }) + for await (const chunk of stream) { + chunks.push(chunk) + } const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") const readChunks = toolChunks.filter((c) => c.name === "read_file") @@ -781,20 +830,25 @@ describe("MimoHandler", () => { }) it("should handle stream interruption gracefully", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { + 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 = await collectStream(handler.createMessage("System", messages)) + const chunks: any[] = [] + const stream = handler.createMessage("System", messages) + for await (const chunk of stream) { + chunks.push(chunk) + } const textChunks = chunks.filter((c) => c.type === "text") expect(textChunks).toHaveLength(1) @@ -805,9 +859,9 @@ describe("MimoHandler", () => { }) it("should sanitize tool call IDs with invalid characters", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -823,13 +877,13 @@ describe("MimoHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [{ delta: {}, index: 0, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]), - ) + } + }, + })) const tools: any[] = [ { @@ -842,7 +896,11 @@ describe("MimoHandler", () => { { role: "user", content: [{ type: "text", text: "Hello" }] }, ] - const chunks = await collectStream(handler.createMessage("System", messages, { taskId: "test", tools })) + const chunks: any[] = [] + const stream = handler.createMessage("System", messages, { taskId: "test", tools }) + for await (const chunk of stream) { + chunks.push(chunk) + } const toolChunks = chunks.filter((c) => c.type === "tool_call_partial") expect(toolChunks.length).toBeGreaterThan(0) @@ -856,7 +914,9 @@ describe("MimoHandler", () => { ] const stream = handler.createMessage("You are a helpful assistant", userMessages) - await collectStream(stream) + for await (const _chunk of stream) { + // drain + } const params = mockCreate.mock.calls[0][0] expect(params.messages[0].role).toBe("system") diff --git a/src/api/providers/__tests__/minimax.spec.ts b/src/api/providers/__tests__/minimax.spec.ts index 53dbd8740f..5f90d2b818 100644 --- a/src/api/providers/__tests__/minimax.spec.ts +++ b/src/api/providers/__tests__/minimax.spec.ts @@ -13,7 +13,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types" import { MiniMaxHandler } from "../minimax" -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" vitest.mock("@anthropic-ai/sdk", () => { const mockCreate = vitest.fn() @@ -241,15 +240,21 @@ describe("MiniMaxHandler", () => { it("createMessage should yield text content from stream", async () => { const testContent = "This is test content from MiniMax stream" - mockCreate.mockResolvedValueOnce( - asyncStreamFrom([ - { - type: "content_block_start", - index: 0, - content_block: { type: "text", text: testContent }, - }, - ]), - ) + mockCreate.mockResolvedValueOnce({ + [Symbol.asyncIterator]: () => ({ + next: vitest + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: testContent }, + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + }) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() @@ -259,19 +264,25 @@ describe("MiniMaxHandler", () => { }) it("createMessage should yield usage data from stream", async () => { - mockCreate.mockResolvedValueOnce( - asyncStreamFrom([ - { - type: "message_start", - message: { - usage: { - input_tokens: 10, - output_tokens: 20, + mockCreate.mockResolvedValueOnce({ + [Symbol.asyncIterator]: () => ({ + next: vitest + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + type: "message_start", + message: { + usage: { + input_tokens: 10, + output_tokens: 20, + }, + }, }, - }, - }, - ]), - ) + }) + .mockResolvedValueOnce({ done: true }), + }), + }) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() @@ -288,7 +299,13 @@ describe("MiniMaxHandler", () => { minimaxApiKey: "test-minimax-api-key", }) - mockCreate.mockResolvedValueOnce(asyncStreamFrom([])) + mockCreate.mockResolvedValueOnce({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + }) const systemPrompt = "Test system prompt for MiniMax" const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for MiniMax" }] @@ -309,7 +326,13 @@ describe("MiniMaxHandler", () => { }) it("should use temperature 1 by default", async () => { - mockCreate.mockResolvedValueOnce(asyncStreamFrom([])) + mockCreate.mockResolvedValueOnce({ + [Symbol.asyncIterator]: () => ({ + async next() { + return { done: true } + }, + }), + }) const messageGenerator = handler.createMessage("test", []) await messageGenerator.next() @@ -324,15 +347,21 @@ describe("MiniMaxHandler", () => { it("should handle thinking blocks in stream", async () => { const thinkingContent = "Let me think about this..." - mockCreate.mockResolvedValueOnce( - asyncStreamFrom([ - { - type: "content_block_start", - index: 0, - content_block: { type: "thinking", thinking: thinkingContent }, - }, - ]), - ) + mockCreate.mockResolvedValueOnce({ + [Symbol.asyncIterator]: () => ({ + next: vitest + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + type: "content_block_start", + index: 0, + content_block: { type: "thinking", thinking: thinkingContent }, + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + }) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() @@ -342,24 +371,33 @@ describe("MiniMaxHandler", () => { }) it("should handle tool calls in stream", async () => { - mockCreate.mockResolvedValueOnce( - asyncStreamFrom([ - { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: "tool-123", - name: "get_weather", - input: { city: "London" }, - }, - }, - { - type: "content_block_stop", - index: 0, - }, - ]), - ) + mockCreate.mockResolvedValueOnce({ + [Symbol.asyncIterator]: () => ({ + next: vitest + .fn() + .mockResolvedValueOnce({ + done: false, + value: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-123", + name: "get_weather", + input: { city: "London" }, + }, + }, + }) + .mockResolvedValueOnce({ + done: false, + value: { + type: "content_block_stop", + index: 0, + }, + }) + .mockResolvedValueOnce({ done: true }), + }), + }) const stream = handler.createMessage("system prompt", []) const firstChunk = await stream.next() diff --git a/src/api/providers/__tests__/mistral.spec.ts b/src/api/providers/__tests__/mistral.spec.ts index f2a7591bd8..96e42e356b 100644 --- a/src/api/providers/__tests__/mistral.spec.ts +++ b/src/api/providers/__tests__/mistral.spec.ts @@ -11,26 +11,28 @@ vi.mock("@roo-code/telemetry", () => ({ // Mock Mistral client - must come before other imports const mockCreate = vi.fn() const mockComplete = vi.fn() -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" vi.mock("@mistralai/mistralai", () => { return { Mistral: vi.fn().mockImplementation(function () { return { chat: { - stream: mockCreate.mockImplementation(async (_options) => - asyncStreamFrom([ - { - data: { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - }, + stream: mockCreate.mockImplementation(async (_options) => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + data: { + choices: [ + { + delta: { content: "Test response" }, + index: 0, + }, + ], + }, + } }, - ]), - ), + } + return stream + }), complete: mockComplete.mockImplementation(async (_options) => { return { choices: [ @@ -156,28 +158,31 @@ describe("MistralHandler", () => { it("should handle thinking content as reasoning chunks", async () => { // Mock stream with thinking content matching new SDK structure - mockCreate.mockImplementationOnce(async (_options) => - asyncStreamFrom([ - { - data: { - choices: [ - { - delta: { - content: [ - { - type: "thinking", - thinking: [{ type: "text", text: "Let me think about this..." }], - }, - { type: "text", text: "Here's the answer" }, - ], + mockCreate.mockImplementationOnce(async (_options) => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + data: { + choices: [ + { + delta: { + content: [ + { + type: "thinking", + thinking: [{ type: "text", text: "Let me think about this..." }], + }, + { type: "text", text: "Here's the answer" }, + ], + }, + index: 0, }, - index: 0, - }, - ], - }, + ], + }, + } }, - ]), - ) + } + return stream + }) const iterator = handler.createMessage(systemPrompt, messages) const results: (ApiStreamTextChunk | ApiStreamReasoningChunk)[] = [] @@ -195,29 +200,32 @@ describe("MistralHandler", () => { it("should handle mixed content arrays correctly", async () => { // Mock stream with mixed content matching new SDK structure - mockCreate.mockImplementationOnce(async (_options) => - asyncStreamFrom([ - { - data: { - choices: [ - { - delta: { - content: [ - { type: "text", text: "First text" }, - { - type: "thinking", - thinking: [{ type: "text", text: "Some reasoning" }], - }, - { type: "text", text: "Second text" }, - ], + mockCreate.mockImplementationOnce(async (_options) => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + data: { + choices: [ + { + delta: { + content: [ + { type: "text", text: "First text" }, + { + type: "thinking", + thinking: [{ type: "text", text: "Some reasoning" }], + }, + { type: "text", text: "Second text" }, + ], + }, + index: 0, }, - index: 0, - }, - ], - }, + ], + }, + } }, - ]), - ) + } + return stream + }) const iterator = handler.createMessage(systemPrompt, messages) const results: (ApiStreamTextChunk | ApiStreamReasoningChunk)[] = [] @@ -306,31 +314,34 @@ describe("MistralHandler", () => { it("should handle tool calls in streaming response", async () => { // Mock stream with tool calls - mockCreate.mockImplementationOnce(async (_options) => - asyncStreamFrom([ - { - data: { - choices: [ - { - delta: { - toolCalls: [ - { - id: "call_123", - type: "function", - function: { - name: "get_weather", - arguments: '{"location":"New York"}', + mockCreate.mockImplementationOnce(async (_options) => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + data: { + choices: [ + { + delta: { + toolCalls: [ + { + id: "call_123", + type: "function", + function: { + name: "get_weather", + arguments: '{"location":"New York"}', + }, }, - }, - ], + ], + }, + index: 0, }, - index: 0, - }, - ], - }, + ], + }, + } }, - ]), - ) + } + return stream + }) const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task", @@ -358,39 +369,42 @@ describe("MistralHandler", () => { it("should handle multiple tool calls in a single response", async () => { // Mock stream with multiple tool calls - mockCreate.mockImplementationOnce(async (_options) => - asyncStreamFrom([ - { - data: { - choices: [ - { - delta: { - toolCalls: [ - { - id: "call_1", - type: "function", - function: { - name: "get_weather", - arguments: '{"location":"NYC"}', + mockCreate.mockImplementationOnce(async (_options) => { + const stream = { + [Symbol.asyncIterator]: async function* () { + yield { + data: { + choices: [ + { + delta: { + toolCalls: [ + { + id: "call_1", + type: "function", + function: { + name: "get_weather", + arguments: '{"location":"NYC"}', + }, }, - }, - { - id: "call_2", - type: "function", - function: { - name: "get_weather", - arguments: '{"location":"LA"}', + { + id: "call_2", + type: "function", + function: { + name: "get_weather", + arguments: '{"location":"LA"}', + }, }, - }, - ], + ], + }, + index: 0, }, - index: 0, - }, - ], - }, + ], + }, + } }, - ]), - ) + } + return stream + }) const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task", diff --git a/src/api/providers/__tests__/openai.spec.ts b/src/api/providers/__tests__/openai.spec.ts index 3e18f03a4c..2e78405d12 100644 --- a/src/api/providers/__tests__/openai.spec.ts +++ b/src/api/providers/__tests__/openai.spec.ts @@ -884,7 +884,6 @@ describe("OpenAiHandler", () => { // No custom temperature set → `temperature` is omitted. tools: undefined, tool_choice: undefined, - parallel_tool_calls: true, }, { path: "/models/chat/completions" }, ) @@ -892,6 +891,7 @@ describe("OpenAiHandler", () => { // Verify max_tokens is NOT included when not explicitly set const callArgs = mockCreate.mock.calls[0][0] expect(callArgs).not.toHaveProperty("max_completion_tokens") + expect(callArgs).not.toHaveProperty("parallel_tool_calls") }) it("should handle non-streaming responses with Azure AI Inference Service", async () => { @@ -930,7 +930,6 @@ describe("OpenAiHandler", () => { ], tools: undefined, tool_choice: undefined, - parallel_tool_calls: true, }, { path: "/models/chat/completions" }, ) @@ -938,6 +937,7 @@ describe("OpenAiHandler", () => { // Verify max_tokens is NOT included when not explicitly set const callArgs = mockCreate.mock.calls[0][0] expect(callArgs).not.toHaveProperty("max_completion_tokens") + expect(callArgs).not.toHaveProperty("parallel_tool_calls") }) it("should handle completePrompt with Azure AI Inference Service", async () => { @@ -1013,6 +1013,7 @@ describe("OpenAiHandler", () => { it("should handle O3 model with streaming and include max_completion_tokens when includeMaxTokens is true", async () => { const o3Handler = new OpenAiHandler({ ...o3Options, + reasoningEffort: "high", includeMaxTokens: true, modelMaxTokens: 32000, modelTemperature: 0.5, @@ -1040,7 +1041,7 @@ describe("OpenAiHandler", () => { ], stream: true, stream_options: { include_usage: true }, - reasoning_effort: "medium", + reasoning_effort: "high", temperature: undefined, // O3 models do not support deprecated max_tokens but do support max_completion_tokens max_completion_tokens: 32000, @@ -1199,6 +1200,7 @@ describe("OpenAiHandler", () => { it("should handle O3 model non-streaming with reasoning_effort and max_completion_tokens when includeMaxTokens is true", async () => { const o3Handler = new OpenAiHandler({ ...o3Options, + reasoningEffort: "high", openAiStreamingEnabled: false, includeMaxTokens: true, modelTemperature: 0.3, @@ -1224,7 +1226,7 @@ describe("OpenAiHandler", () => { }, { role: "user", content: "Hello!" }, ], - reasoning_effort: "medium", + reasoning_effort: "high", temperature: undefined, // O3 models do not support deprecated max_tokens but do support max_completion_tokens max_completion_tokens: 65536, // Using default maxTokens from o3Options diff --git a/src/api/providers/__tests__/opencode-go.spec.ts b/src/api/providers/__tests__/opencode-go.spec.ts index 0c81cbc75c..38be399c9d 100644 --- a/src/api/providers/__tests__/opencode-go.spec.ts +++ b/src/api/providers/__tests__/opencode-go.spec.ts @@ -17,7 +17,6 @@ import { opencodeGoDefaultModelId, opencodeGoModels, isOpencodeGoAnthropicFormat import { OpencodeGoHandler } from "../opencode-go" import { getModels } from "../fetchers/modelCache" import { ApiHandlerOptions } from "../../../shared/api" -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" vitest.mock("openai") vitest.mock("delay", () => ({ @@ -115,9 +114,9 @@ describe("OpencodeGoHandler", () => { describe("createMessage", () => { beforeEach(() => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -135,8 +134,8 @@ describe("OpencodeGoHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 12, @@ -144,16 +143,19 @@ describe("OpencodeGoHandler", () => { total_tokens: 19, prompt_tokens_details: { cached_tokens: 4 }, }, - }, - ]), - ) + } + }, + })) }) it("streams text, reasoning, tool-call and usage chunks", async () => { const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = await collectStream(handler.createMessage("You are helpful.", messages)) + const chunks = [] + for await (const chunk of handler.createMessage("You are helpful.", messages)) { + chunks.push(chunk) + } expect(chunks).toContainEqual({ type: "text", text: "Hello" }) expect(chunks).toContainEqual({ type: "reasoning", text: "thinking…" }) @@ -175,7 +177,9 @@ describe("OpencodeGoHandler", () => { it("requests a streaming completion with usage included and native max tokens", async () => { const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk // drain + } expect(mockCreate).toHaveBeenCalledWith( expect.objectContaining({ @@ -193,7 +197,9 @@ describe("OpencodeGoHandler", () => { it("forwards the model's default reasoning_effort for reasoning-capable models", async () => { const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk // drain + } // glm-5.1 advertises supportsReasoningEffort with a default of "medium". expect(mockCreate).toHaveBeenCalledWith( @@ -207,7 +213,9 @@ describe("OpencodeGoHandler", () => { it("omits reasoning_effort when the user disables reasoning", async () => { const handler = new OpencodeGoHandler({ ...mockOptions, reasoningEffort: "disable" }) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk // drain + } const callArgs = mockCreate.mock.calls[0][0] as Record expect(callArgs.reasoning_effort).toBeUndefined() @@ -221,7 +229,9 @@ describe("OpencodeGoHandler", () => { content: [{ type: "text", text: "Hi" }], }, ] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk // drain + } const callArgs = mockCreate.mock.calls[0][0] as { messages: Array<{ role: string }> } // The system prompt is prepended, then the R1-converted user message. @@ -231,48 +241,54 @@ describe("OpencodeGoHandler", () => { }) 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 }] }, - { + 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 handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = await collectStream(handler.createMessage("sys", messages)) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("sys", 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 () => - asyncStreamFrom([ - { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }, - { + 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 handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = await collectStream(handler.createMessage("sys", messages)) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("sys", 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 () => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -282,18 +298,21 @@ describe("OpencodeGoHandler", () => { index: 0, }, ], - }, - { + } + yield { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]), - ) + } + }, + })) const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = await collectStream(handler.createMessage("sys", messages)) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("sys", messages)) { + chunks.push(chunk) + } const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }]) @@ -305,20 +324,22 @@ describe("OpencodeGoHandler", () => { vitest.mocked(getModels).mockImplementationOnce(async () => ({ "kimi-k2.6": { ...opencodeGoModels["kimi-k2.6"] }, })) - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { choices: [{ delta: { content: "Hi" }, index: 0 }] }, - { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: { content: "Hi" }, index: 0 }] } + yield { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]), - ) + } + }, + })) const handler = new OpencodeGoHandler({ ...mockOptions, opencodeGoModelId: "kimi-k2.6" }) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk + } const callArgs = mockCreate.mock.calls[0][0] as { messages: Array<{ role: string }> } expect(callArgs.messages[0]).toEqual({ role: "system", content: "sys" }) @@ -327,20 +348,23 @@ describe("OpencodeGoHandler", () => { }) it("emits a usage chunk with zeroed tokens when the stream reports no usage", async () => { - mockCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { choices: [{ delta: { content: "Hi" }, index: 0 }] }, - { + mockCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [{ delta: { content: "Hi" }, index: 0 }] } + yield { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 }, - }, - ]), - ) + } + }, + })) const handler = new OpencodeGoHandler(mockOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = await collectStream(handler.createMessage("sys", messages)) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("sys", messages)) { + chunks.push(chunk) + } expect(chunks).toContainEqual({ type: "usage", inputTokens: 0, outputTokens: 0 }) }) @@ -349,7 +373,9 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler({ ...mockOptions, includeMaxTokens: true, modelMaxTokens: 999 }) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk + } expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ max_completion_tokens: 999 })) }) @@ -407,9 +433,9 @@ describe("OpencodeGoHandler", () => { } beforeEach(() => { - mockAnthropicCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockAnthropicCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { type: "message_start", message: { usage: { @@ -419,35 +445,37 @@ describe("OpencodeGoHandler", () => { cache_read_input_tokens: 3, }, }, - }, - { + } + yield { type: "content_block_start", index: 0, content_block: { type: "text", text: "" }, - }, - { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } }, - { + } + yield { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "Hello" } } + yield { type: "content_block_start", index: 1, content_block: { type: "tool_use", id: "toolu_1", name: "read_file", input: {} }, - }, - { + } + yield { type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '{"path":' }, - }, - { type: "content_block_stop", index: 1 }, - { type: "message_delta", usage: { output_tokens: 5 } }, - { type: "message_stop" }, - ]), - ) + } + yield { type: "content_block_stop", index: 1 } + yield { type: "message_delta", usage: { output_tokens: 5 } } + yield { type: "message_stop" } + }, + })) }) it("routes the request through the Anthropic /v1/messages client, not chat completions", async () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk // drain + } expect(mockAnthropicCreate).toHaveBeenCalledWith( expect.objectContaining({ @@ -464,7 +492,10 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = await collectStream(handler.createMessage("sys", messages)) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("sys", messages)) { + chunks.push(chunk) + } expect(chunks).toContainEqual({ type: "text", text: "Hello" }) expect(chunks).toContainEqual({ @@ -508,7 +539,9 @@ describe("OpencodeGoHandler", () => { { role: "user", content: "second" }, ] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk // drain + } const callArgs = mockAnthropicCreate.mock.calls[0][0] as { system: Array<{ cache_control?: unknown }> @@ -570,7 +603,9 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk + } const callArgs = mockAnthropicCreate.mock.calls[0][0] as Record // Disable-tools path: with no tools, neither field is sent so the @@ -593,7 +628,9 @@ describe("OpencodeGoHandler", () => { }, ] - await collectStream(handler.createMessage("sys", messages, { taskId: "test-task", tools })) + for await (const _chunk of handler.createMessage("sys", messages, { taskId: "test-task", tools })) { + void _chunk + } const callArgs = mockAnthropicCreate.mock.calls[0][0] as Record expect(Array.isArray(callArgs.tools)).toBe(true) @@ -613,7 +650,9 @@ describe("OpencodeGoHandler", () => { { role: "user", content: "second" }, ] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk + } const callArgs = mockAnthropicCreate.mock.calls[0][0] as { system: Array<{ cache_control?: unknown }> @@ -637,7 +676,9 @@ describe("OpencodeGoHandler", () => { }, ] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk + } const callArgs = mockAnthropicCreate.mock.calls[0][0] as { messages: Array<{ content: any }> } const lastUserMsg = callArgs.messages[callArgs.messages.length - 1] @@ -651,7 +692,9 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "assistant", content: "only assistant" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk + } const callArgs = mockAnthropicCreate.mock.calls[0][0] as { messages: Array<{ cache_control?: unknown }> @@ -660,35 +703,41 @@ describe("OpencodeGoHandler", () => { }) it("streams thinking content blocks and thinking deltas", async () => { - mockAnthropicCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { type: "message_start", message: { usage: { input_tokens: 5, output_tokens: 0 } } }, - { + mockAnthropicCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { type: "message_start", message: { usage: { input_tokens: 5, output_tokens: 0 } } } + // index 0: thinking block (no leading newline at index 0). + yield { type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "initial thought" }, - }, - { + } + yield { type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: " more" }, - }, - { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } }, - { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "answer" } }, - { + } + // index 1: text block gets a leading newline separator. + yield { type: "content_block_start", index: 1, content_block: { type: "text", text: "" } } + yield { type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "answer" } } + // index 2: a second thinking block also gets a newline separator. + yield { type: "content_block_start", index: 2, content_block: { type: "thinking", thinking: "second thought" }, - }, - { type: "message_delta", usage: { output_tokens: 3 } }, - { type: "message_stop" }, - ]), - ) + } + yield { type: "message_delta", usage: { output_tokens: 3 } } + yield { type: "message_stop" } + }, + })) const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = await collectStream(handler.createMessage("sys", messages)) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("sys", messages)) { + chunks.push(chunk) + } // index 0 thinking block (no leading newline separator at index 0). expect(chunks).toContainEqual({ type: "reasoning", text: "initial thought" }) @@ -709,7 +758,9 @@ describe("OpencodeGoHandler", () => { }) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk + } expect(mockAnthropicCreate).toHaveBeenCalledWith(expect.objectContaining({ max_tokens: 8192 })) }) @@ -718,33 +769,36 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler({ ...anthropicOptions, includeMaxTokens: true }) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk + } // qwen3.7-max maxTokens (65_536) clamped to 20% of 1M context => 65_536. expect(mockAnthropicCreate).toHaveBeenCalledWith(expect.objectContaining({ max_tokens: 65_536 })) }) it("accumulates output tokens across message_delta events into the final cost", async () => { - mockAnthropicCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { type: "message_start", message: { usage: { input_tokens: 10, output_tokens: 0 } } }, - { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, - { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } }, - { type: "message_delta", usage: { output_tokens: 4 } }, - { type: "message_delta", usage: { output_tokens: 6 } }, - { type: "message_stop" }, - ]), - ) + mockAnthropicCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { type: "message_start", message: { usage: { input_tokens: 10, output_tokens: 0 } } } + yield { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } + yield { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } } + yield { type: "message_delta", usage: { output_tokens: 4 } } + yield { type: "message_delta", usage: { output_tokens: 6 } } + yield { type: "message_stop" } + }, + })) const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = await collectStream(handler.createMessage("sys", messages)) - - const costChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c && c.totalCost !== undefined) - if (!costChunk || costChunk.type !== "usage") { - throw new Error("Expected usage chunk with cost") + const chunks: any[] = [] + for await (const chunk of handler.createMessage("sys", messages)) { + chunks.push(chunk) } + + const costChunk = chunks.find((c) => c.type === "usage" && c.totalCost !== undefined) + expect(costChunk).toBeDefined() // qwen3.7-max: input $2.5/M, output $7.5/M. Accumulated output // tokens (4 + 6 = 10) must feed the cost calc — without the // accumulation fix this would only reflect the 10 input tokens @@ -753,20 +807,23 @@ describe("OpencodeGoHandler", () => { }) it("does not yield a cost chunk when the stream reports no token usage", async () => { - mockAnthropicCreate.mockImplementationOnce(async () => - asyncStreamFrom([ - { type: "message_start", message: { usage: { input_tokens: 0, output_tokens: 0 } } }, - { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } }, - { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } }, - { type: "message_delta", usage: { output_tokens: 0 } }, - { type: "message_stop" }, - ]), - ) + mockAnthropicCreate.mockImplementationOnce(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { type: "message_start", message: { usage: { input_tokens: 0, output_tokens: 0 } } } + yield { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } } + yield { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hi" } } + yield { type: "message_delta", usage: { output_tokens: 0 } } + yield { type: "message_stop" } + }, + })) const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - const chunks = await collectStream(handler.createMessage("sys", messages)) + const chunks: any[] = [] + for await (const chunk of handler.createMessage("sys", messages)) { + chunks.push(chunk) + } expect(chunks.some((c) => c.type === "usage" && c.totalCost !== undefined)).toBe(false) }) @@ -785,7 +842,9 @@ describe("OpencodeGoHandler", () => { const handler = new OpencodeGoHandler(anthropicOptions) const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] await expect(async () => { - await collectStream(handler.createMessage("sys", messages)) + for await (const _chunk of handler.createMessage("sys", messages)) { + void _chunk + } }).rejects.toThrow("Opencode Go completion error: rate limited") }) }) 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..3615c0f92d 100644 --- a/src/api/providers/__tests__/qwen-code-native-tools.spec.ts +++ b/src/api/providers/__tests__/qwen-code-native-tools.spec.ts @@ -9,7 +9,6 @@ vi.mock("node:fs", () => ({ })) const mockCreate = vi.fn() -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" vi.mock("openai", () => { return { __esModule: true, @@ -78,9 +77,13 @@ describe("QwenCodeHandler Native Tools", () => { describe("Native Tool Calling Support", () => { it("should include tools in request when model supports native tools and tools are provided", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: { content: "Test response" } }] }]), - ) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -104,9 +107,13 @@ describe("QwenCodeHandler Native Tools", () => { }) it("should include tool_choice when provided", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: { content: "Test response" } }] }]), - ) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -123,9 +130,13 @@ describe("QwenCodeHandler Native Tools", () => { }) it("should always include tools and tool_choice (tools are guaranteed to be present after ALWAYS_AVAILABLE_TOOLS)", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: { content: "Test response" } }] }]), - ) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -140,9 +151,9 @@ describe("QwenCodeHandler Native Tools", () => { }) it("should yield tool_call_partial chunks during streaming", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -159,8 +170,8 @@ describe("QwenCodeHandler Native Tools", () => { }, }, ], - }, - { + } + yield { choices: [ { delta: { @@ -175,16 +186,19 @@ describe("QwenCodeHandler Native Tools", () => { }, }, ], - }, - ]), - ) + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", tools: testTools, }) - const chunks = await collectStream(stream) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } expect(chunks).toContainEqual({ type: "tool_call_partial", @@ -204,9 +218,13 @@ describe("QwenCodeHandler Native Tools", () => { }) it("should set parallel_tool_calls based on metadata", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([{ choices: [{ delta: { content: "Test response" } }] }]), - ) + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { + choices: [{ delta: { content: "Test response" } }], + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -223,9 +241,9 @@ describe("QwenCodeHandler Native Tools", () => { }) it("should yield tool_call_end events when finish_reason is tool_calls", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -242,8 +260,8 @@ describe("QwenCodeHandler Native Tools", () => { }, }, ], - }, - { + } + yield { choices: [ { delta: {}, @@ -251,9 +269,9 @@ describe("QwenCodeHandler Native Tools", () => { }, ], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, - }, - ]), - ) + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", @@ -285,44 +303,50 @@ describe("QwenCodeHandler Native Tools", () => { }) it("streams reasoning chunks from delta.reasoning_content", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([ - { choices: [{ delta: { reasoning_content: "thinking..." }, index: 0 }] }, - { choices: [{ delta: { content: "answer" }, index: 0 }] }, - { + mockCreate.mockImplementationOnce(() => ({ + [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 stream = handler.createMessage("test prompt", []) - const chunks = await collectStream(stream) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } expect(chunks).toContainEqual({ type: "reasoning", text: "thinking..." }) }) it("falls back to delta.reasoning when reasoning_content is absent", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([ - { choices: [{ delta: { reasoning: "router-style thought" }, index: 0 }] }, - { + mockCreate.mockImplementationOnce(() => ({ + [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 stream = handler.createMessage("test prompt", []) - const chunks = await collectStream(stream) + const chunks: any[] = [] + for await (const chunk of stream) { + 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(() => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -332,25 +356,28 @@ describe("QwenCodeHandler Native Tools", () => { index: 0, }, ], - }, - { + } + yield { choices: [{ delta: {}, index: 0 }], usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - }, - ]), - ) + } + }, + })) const stream = handler.createMessage("test prompt", []) - const chunks = await collectStream(stream) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") expect(reasoningChunks).toEqual([{ type: "reasoning", text: "primary thought" }]) }) it("should preserve thinking block handling alongside tool calls", async () => { - mockCreate.mockImplementationOnce(() => - asyncStreamFrom([ - { + mockCreate.mockImplementationOnce(() => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -358,8 +385,8 @@ describe("QwenCodeHandler Native Tools", () => { }, }, ], - }, - { + } + yield { choices: [ { delta: { @@ -376,17 +403,17 @@ describe("QwenCodeHandler Native Tools", () => { }, }, ], - }, - { + } + yield { choices: [ { delta: {}, finish_reason: "tool_calls", }, ], - }, - ]), - ) + } + }, + })) const stream = handler.createMessage("test prompt", [], { taskId: "test-task-id", diff --git a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts index 92cc785951..ad14486262 100644 --- a/src/api/providers/__tests__/vercel-ai-gateway.spec.ts +++ b/src/api/providers/__tests__/vercel-ai-gateway.spec.ts @@ -14,7 +14,6 @@ import OpenAI from "openai" import { VercelAiGatewayHandler } from "../vercel-ai-gateway" import { ApiHandlerOptions } from "../../../shared/api" -import { asyncStreamFrom, collectStream } from "../../../test-utils/stream" import { vercelAiGatewayDefaultModelId, VERCEL_AI_GATEWAY_DEFAULT_TEMPERATURE } from "@roo-code/types" // Mock dependencies @@ -181,9 +180,9 @@ describe("VercelAiGatewayHandler", () => { describe("createMessage", () => { beforeEach(() => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { content: "Test response" }, @@ -191,8 +190,8 @@ describe("VercelAiGatewayHandler", () => { }, ], usage: null, - }, - { + } + yield { choices: [ { delta: {}, @@ -209,9 +208,9 @@ describe("VercelAiGatewayHandler", () => { }, cost: 0.005, }, - }, - ]), - ) + } + }, + })) }) it("streams text content correctly", async () => { @@ -220,7 +219,10 @@ describe("VercelAiGatewayHandler", () => { const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] const stream = handler.createMessage(systemPrompt, messages) - const chunks = await collectStream(stream) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } expect(chunks).toHaveLength(2) expect(chunks[0]).toEqual({ @@ -238,33 +240,41 @@ describe("VercelAiGatewayHandler", () => { }) it("throws the upstream reason when an in-stream error chunk is received", async () => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { error: { message: "Too many requests, please wait before trying again", code: 429, }, - }, - ]), - ) + } + }, + })) const handler = new VercelAiGatewayHandler(mockOptions) const stream = handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello" }]) await expect(async () => { - await collectStream(stream) + for await (const _chunk of stream) { + // drain + } }).rejects.toThrow("Too many requests, please wait before trying again") }) it("throws a default message when an in-stream error chunk has no message", async () => { - mockCreate.mockImplementation(async () => asyncStreamFrom([{ error: {} }])) + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { error: {} } + }, + })) const handler = new VercelAiGatewayHandler(mockOptions) const stream = handler.createMessage("You are a helpful assistant.", [{ role: "user", content: "Hello" }]) await expect(async () => { - await collectStream(stream) + for await (const _chunk of stream) { + // drain + } }).rejects.toThrow("Vercel AI Gateway stream error") }) @@ -391,7 +401,10 @@ describe("VercelAiGatewayHandler", () => { const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] const stream = handler.createMessage(systemPrompt, messages) - const chunks = await collectStream(stream) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } const usageChunk = chunks.find((chunk) => chunk.type === "usage") expect(usageChunk).toEqual({ @@ -423,18 +436,18 @@ describe("VercelAiGatewayHandler", () => { ] beforeEach(() => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: {}, index: 0, }, ], - }, - ]), - ) + } + }, + })) }) it("should include tools when provided", async () => { @@ -512,9 +525,9 @@ describe("VercelAiGatewayHandler", () => { }) it("should yield tool_call_partial chunks when streaming tool calls", async () => { - mockCreate.mockImplementation(async () => - asyncStreamFrom([ - { + mockCreate.mockImplementation(async () => ({ + [Symbol.asyncIterator]: async function* () { + yield { choices: [ { delta: { @@ -532,8 +545,8 @@ describe("VercelAiGatewayHandler", () => { index: 0, }, ], - }, - { + } + yield { choices: [ { delta: { @@ -549,8 +562,8 @@ describe("VercelAiGatewayHandler", () => { index: 0, }, ], - }, - { + } + yield { choices: [ { delta: {}, @@ -561,9 +574,9 @@ describe("VercelAiGatewayHandler", () => { prompt_tokens: 10, completion_tokens: 5, }, - }, - ]), - ) + } + }, + })) const handler = new VercelAiGatewayHandler(mockOptions) @@ -572,7 +585,10 @@ describe("VercelAiGatewayHandler", () => { tools: testTools, }) - const chunks = await collectStream(stream) + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } const toolCallChunks = chunks.filter((chunk) => chunk.type === "tool_call_partial") expect(toolCallChunks).toHaveLength(2) diff --git a/src/api/providers/base-openai-compatible-provider.ts b/src/api/providers/base-openai-compatible-provider.ts index f4928b0b0a..b9ddea3c8c 100644 --- a/src/api/providers/base-openai-compatible-provider.ts +++ b/src/api/providers/base-openai-compatible-provider.ts @@ -93,9 +93,14 @@ export abstract class BaseOpenAiCompatibleProvider messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], stream: true, stream_options: { include_usage: true }, - tools: this.convertToolsForOpenAI(metadata?.tools), + tools: this.convertToolsForOpenAI(metadata?.tools, this.options.openAiToolStrictMode ?? false), tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + // Only send parallel_tool_calls when tools are present; some + // OpenAI-compatible providers (e.g. Upstage solar-open2) reject + // this field when no tools are supplied. + ...(metadata?.tools && metadata.tools.length > 0 + ? { parallel_tool_calls: metadata?.parallelToolCalls ?? true } + : {}), } // Add thinking parameter if reasoning is enabled and model supports it diff --git a/src/api/providers/base-provider.ts b/src/api/providers/base-provider.ts index 89366fb619..de25ad3c8f 100644 --- a/src/api/providers/base-provider.ts +++ b/src/api/providers/base-provider.ts @@ -23,11 +23,23 @@ export abstract class BaseProvider implements ApiHandler { abstract getModel(): { id: string; info: ModelInfo } /** - * Converts an array of tools to be compatible with OpenAI's strict mode. - * Filters for function tools, applies schema conversion to their parameters, - * and ensures all tools have consistent strict: true values. + * Converts an array of tools for OpenAI-compatible providers. + * Filters for function tools and applies schema conversion to their parameters. + * + * When `strictMode` is true, non-MCP function tools get `strict: true` and + * their schemas are hardened via `convertToolSchemaForOpenAI()` (adds + * `additionalProperties: false`, marks all properties required, etc.). + * + * When `strictMode` is false (default), non-MCP function tools get + * `strict: false` and their original best-effort schemas are preserved + * without hardening. This is semantically consistent: `strict: false` + * should not imply strict-schema transformations. + * + * MCP tools are ALWAYS `strict: false` with original parameters preserved, + * regardless of the `strictMode` setting, because MCP schemas may contain + * optional properties that must remain optional. */ - protected convertToolsForOpenAI(tools: any[] | undefined): any[] | undefined { + protected convertToolsForOpenAI(tools: any[] | undefined, strictMode: boolean = false): any[] | undefined { if (!tools) { return undefined } @@ -37,18 +49,40 @@ export abstract class BaseProvider implements ApiHandler { return tool } - // MCP tools use the 'mcp--' prefix - disable strict mode for them + // MCP tools use the 'mcp--' prefix - always disable strict mode // to preserve optional parameters from the MCP server schema const isMcp = isMcpTool(tool.function.name) + if (isMcp) { + return { + ...tool, + function: { + ...tool.function, + strict: false, + parameters: tool.function.parameters, + }, + } + } + + // Non-MCP function tools respect the strictMode setting + if (strictMode) { + return { + ...tool, + function: { + ...tool.function, + strict: true, + parameters: this.convertToolSchemaForOpenAI(tool.function.parameters), + }, + } + } + + // strictMode false: preserve original best-effort schema return { ...tool, function: { ...tool.function, - strict: !isMcp, - parameters: isMcp - ? tool.function.parameters - : this.convertToolSchemaForOpenAI(tool.function.parameters), + strict: false, + parameters: tool.function.parameters, }, } }) @@ -76,6 +110,11 @@ export abstract class BaseProvider implements ApiHandler { result.additionalProperties = false } + if (result.properties === undefined) { + result.properties = {} + result.required = [] + } + if (result.properties) { const allKeys = Object.keys(result.properties) // OpenAI strict mode requires ALL properties to be in required array diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 2e85c016b0..bff88a797c 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -7,7 +7,6 @@ import { DEEP_SEEK_DEFAULT_TEMPERATURE, OPENAI_AZURE_AI_INFERENCE_PATH, type ModelInfo, - DeepSeekModelId, } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" @@ -24,7 +23,7 @@ import { handleOpenAIError } from "./utils/error-handler" // Custom interface for DeepSeek params to support thinking mode type DeepSeekChatCompletionParams = Omit & { thinking?: { type: "enabled" | "disabled" } - reasoning_effort?: "low" | "high" | "max" + reasoning_effort?: "high" | "max" } const deepSeekV4ThinkingModels = new Set(["deepseek-v4-flash", "deepseek-v4-pro"]) @@ -38,49 +37,16 @@ const isDeepSeekThinkingEnabled = (modelId: string, options: ApiHandlerOptions) return false } - return supportsDeepSeekThinkingToggle(modelId) + return modelId === "deepseek-reasoner" || supportsDeepSeekThinkingToggle(modelId) } -// https://api-docs.deepseek.com/guides/thinking_mode/ -export const normalizeDeepSeekReasoningEffort = ( - modelId: DeepSeekModelId, - reasoningEffort?: string, -): "low" | "high" | "max" | undefined => { - switch (modelId) { - case "deepseek-v4-flash": - switch (reasoningEffort) { - case "low": - return "low" - - case "high": - return "high" - - case "xhigh": - return "high" - - case "max": - return "max" - } - break - - case "deepseek-v4-pro": - switch (reasoningEffort) { - case "low": - return "high" - - case "high": - return "high" - - case "xhigh": - return "max" - - case "max": - return "max" - } - break +const normalizeDeepSeekReasoningEffort = (reasoningEffort?: string): "high" | "max" | undefined => { + if (!reasoningEffort || reasoningEffort === "disable") { + return undefined } - return undefined + // DeepSeek currently maps low/medium to high and xhigh to max in thinking mode. + return reasoningEffort === "xhigh" ? "max" : "high" } // Use the computed maxTokens from getModelParams rather than raw model metadata. @@ -126,7 +92,7 @@ export class DeepSeekHandler extends OpenAiHandler { messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const modelId = (this.options.apiModelId as DeepSeekModelId) ?? deepSeekDefaultModelId + const modelId = this.options.apiModelId ?? deepSeekDefaultModelId const { info: modelInfo, temperature, reasoningEffort, maxTokens } = this.getModel() const isThinkingModel = isDeepSeekThinkingEnabled(modelId, this.options) @@ -135,7 +101,7 @@ export class DeepSeekHandler extends OpenAiHandler { : isThinkingModel ? ({ type: "enabled" } as const) : undefined - const deepSeekReasoningEffort = normalizeDeepSeekReasoningEffort(modelId, reasoningEffort) + const deepSeekReasoningEffort = isThinkingModel ? normalizeDeepSeekReasoningEffort(reasoningEffort) : undefined // Convert messages to R1 format (merges consecutive same-role messages) // This is required for DeepSeek which does not support successive messages with the same role @@ -155,7 +121,7 @@ export class DeepSeekHandler extends OpenAiHandler { stream_options: { include_usage: true }, ...(thinking && { thinking }), ...(deepSeekReasoningEffort && { reasoning_effort: deepSeekReasoningEffort }), - tools: this.convertToolsForOpenAI(metadata?.tools), + tools: this.convertToolsForOpenAI(metadata?.tools, this.options.openAiToolStrictMode ?? false), tool_choice: metadata?.tool_choice, parallel_tool_calls: metadata?.parallelToolCalls ?? true, } diff --git a/src/api/providers/friendli.ts b/src/api/providers/friendli.ts index a5507e355a..8507c58ba7 100644 --- a/src/api/providers/friendli.ts +++ b/src/api/providers/friendli.ts @@ -169,7 +169,7 @@ export class FriendliHandler extends BaseOpenAiCompatibleProvider 0 + ? { parallel_tool_calls: metadata?.parallelToolCalls ?? true } + : {}), } // Add max_tokens if needed @@ -231,9 +236,14 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ? convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]) : [systemMessage, ...convertToOpenAiMessages(messages)], // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) - tools: this.convertToolsForOpenAI(metadata?.tools), + tools: this.convertToolsForOpenAI(metadata?.tools, this.options.openAiToolStrictMode ?? false), tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + // Only send parallel_tool_calls when tools are present; some + // OpenAI-compatible providers (e.g. Upstage solar-open2) reject + // this field when no tools are supplied. + ...(metadata?.tools && metadata.tools.length > 0 + ? { parallel_tool_calls: metadata?.parallelToolCalls ?? true } + : {}), } // Add max_tokens if needed @@ -342,7 +352,7 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - const modelInfo = this.getModel().info + const { info: modelInfo, reasoning } = this.getModel() const methodIsAzureAiInference = this._isAzureAiInference(this.options.openAiBaseUrl) if (this.options.openAiStreamingEnabled ?? true) { @@ -359,10 +369,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl ], stream: true, ...(isGrokXAI ? {} : { stream_options: { include_usage: true } }), - reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, + ...(reasoning && reasoning), temperature: undefined, // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) - tools: this.convertToolsForOpenAI(metadata?.tools), + tools: this.convertToolsForOpenAI(metadata?.tools, this.options.openAiToolStrictMode ?? false), tool_choice: metadata?.tool_choice, parallel_tool_calls: metadata?.parallelToolCalls ?? true, } @@ -393,10 +403,10 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl }, ...convertToOpenAiMessages(messages), ], - reasoning_effort: modelInfo.reasoningEffort as "low" | "medium" | "high" | undefined, + ...(reasoning && reasoning), temperature: undefined, // Tools are always present (minimum ALWAYS_AVAILABLE_TOOLS) - tools: this.convertToolsForOpenAI(metadata?.tools), + tools: this.convertToolsForOpenAI(metadata?.tools, this.options.openAiToolStrictMode ?? false), tool_choice: metadata?.tool_choice, parallel_tool_calls: metadata?.parallelToolCalls ?? true, } diff --git a/src/api/providers/opencode-go.ts b/src/api/providers/opencode-go.ts index be53dc1c02..3e490ee5ce 100644 --- a/src/api/providers/opencode-go.ts +++ b/src/api/providers/opencode-go.ts @@ -189,7 +189,7 @@ export class OpencodeGoHandler extends RouterProvider implements SingleCompletio this.options.includeMaxTokens === true ? this.options.modelMaxTokens || maxTokens : maxTokens, stream: true, stream_options: { include_usage: true }, - tools: this.convertToolsForOpenAI(metadata?.tools), + tools: this.convertToolsForOpenAI(metadata?.tools, this.options.openAiToolStrictMode ?? false), tool_choice: metadata?.tool_choice, parallel_tool_calls: metadata?.parallelToolCalls ?? true, ...(reasoningEffort && { diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 3e59b4360b..d74920adff 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -327,7 +327,7 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH }, }), ...(reasoning && { reasoning }), - tools: this.convertToolsForOpenAI(metadata?.tools), + tools: this.convertToolsForOpenAI(metadata?.tools, this.options.openAiToolStrictMode ?? false), tool_choice: metadata?.tool_choice, } 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..6675f18ce8 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,16 @@ vi.mock("@roo-code/core", () => ({ }, })) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureToolUsage: vi.fn(), + captureConsecutiveMistakeError: vi.fn(), + }, + }, +})) + +import { TelemetryService } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" describe("presentAssistantMessage - Custom Tool Recording", () => { @@ -108,6 +118,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { // Should record as "custom_tool", not "my_custom_tool" expect(mockTask.recordToolUsage).toHaveBeenCalledWith("custom_tool") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "custom_tool") }) }) @@ -160,6 +171,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { // Should record as "read_file", not "custom_tool" expect(mockTask.recordToolUsage).toHaveBeenCalledWith("read_file") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "read_file") }) it("should record MCP tool usage as 'use_mcp_tool' (not custom_tool)", async () => { @@ -201,6 +213,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { // Should record as "use_mcp_tool", not "custom_tool" expect(mockTask.recordToolUsage).toHaveBeenCalledWith("use_mcp_tool") + expect(TelemetryService.instance.captureToolUsage).toHaveBeenCalledWith(mockTask.taskId, "use_mcp_tool") }) }) @@ -342,6 +355,7 @@ describe("presentAssistantMessage - Custom Tool Recording", () => { // Should not record usage for partial blocks expect(mockTask.recordToolUsage).not.toHaveBeenCalled() + expect(TelemetryService.instance.captureToolUsage).not.toHaveBeenCalled() }) }) }) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index 12a5bfb4a2..f71b5cc1bd 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -235,6 +235,7 @@ export async function presentAssistantMessage(cline: Task) { if (!mcpBlock.partial) { cline.recordToolUsage("use_mcp_tool") // Record as use_mcp_tool for analytics + TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool") } // Resolve sanitized server name back to original server name @@ -557,6 +558,7 @@ export async function presentAssistantMessage(cline: Task) { const isCustomTool = stateExperiments?.customTools && customToolRegistry.has(block.name) const recordName = isCustomTool ? "custom_tool" : block.name 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) { diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 313183c795..9cbb364c92 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -2510,15 +2510,21 @@ describe("importExport", () => { { testCase: "supportsReasoningBudget is false", providerName: "deepseek-provider", - modelId: "deepseek-v4-flash", + modelId: "deepseek-chat", providerId: "deepseek-id", }, { testCase: "requiredReasoningBudget is false", providerName: "deepseek-provider-2", - modelId: "deepseek-v4-pro", + modelId: "deepseek-coder", providerId: "deepseek-id-2", }, + { + testCase: "both supportsReasoningBudget and requiredReasoningBudget are false", + providerName: "deepseek-provider-3", + modelId: "deepseek-reasoner", + providerId: "deepseek-id-3", + }, ])( "should exclude modelMaxTokens and modelMaxThinkingTokens when $testCase", async ({ providerName, modelId, providerId }) => { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 650a4a3c21..4ba2996c91 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -135,7 +135,6 @@ import { MessageManager } from "../message-manager" import { validateAndFixToolResultIds } from "./validateToolResultIds" import { mergeConsecutiveApiMessages } from "./mergeConsecutiveApiMessages" import { prepareApiConversationMessage } from "./apiConversationHistory" -import { shouldAddUserMessageToHistory } from "./messageCounting" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds @@ -323,25 +322,6 @@ export class Task extends EventEmitter implements TaskLike { consecutiveNoAssistantMessagesCount: number = 0 toolUsage: ToolUsage = {} - // Conversation message counts, summarized once per Task Completed - // installment instead of emitting a separate telemetry event per turn. - messageCounts: { user: number; assistant: number } = { user: 0, assistant: 0 } - - // Idle/shutdown telemetry flush: reports toolUsage/messageCounts for tasks that - // go quiet or get torn down without the model ever calling attempt_completion - // (or without the user accepting it), so long-running/abandoned tasks aren't - // invisible to telemetry. Each flush reports only what changed since the previous - // one, tracked via telemetryToolUsageBaseline/telemetryMessageCountsBaseline -- - // task.toolUsage/messageCounts themselves are never mutated by this, since they're - // also read as running totals by the public TaskCompleted API event and the UI. - // Checked on an interval rather than hooked into every say()/ask() call site. - private static readonly IDLE_TELEMETRY_CHECK_INTERVAL_MS = 5 * 60 * 1000 - private static readonly IDLE_TELEMETRY_THRESHOLD_MS = 30 * 60 * 1000 - private idleTelemetryCheckInterval?: NodeJS.Timeout - private lastTelemetryFlushAt: number = Date.now() - private telemetryToolUsageBaseline: ToolUsage = {} - private telemetryMessageCountsBaseline: { user: number; assistant: number } = { user: 0, assistant: 0 } - // Checkpoints enableCheckpoints: boolean checkpointTimeout: number @@ -621,7 +601,6 @@ export class Task extends EventEmitter implements TaskLike { if (startTask) { this._started = true - this.startIdleTelemetryCheck() if (task || images) { void this.startTask(task, images).catch((error) => { console.error("[Task#constructor] startTask failed:", error) @@ -902,8 +881,6 @@ export class Task extends EventEmitter implements TaskLike { const { images, task, historyItem } = options let promise - instance.startIdleTelemetryCheck() - if (images || task) { promise = instance.startTask(task, images) } else if (historyItem) { @@ -1901,7 +1878,6 @@ export class Task extends EventEmitter implements TaskLike { return } this._started = true - this.startIdleTelemetryCheck() const { task, images } = this.metadata @@ -1926,7 +1902,6 @@ export class Task extends EventEmitter implements TaskLike { return Promise.resolve() } this._started = true - this.startIdleTelemetryCheck() const { task, images } = this.metadata @@ -2295,17 +2270,6 @@ export class Task extends EventEmitter implements TaskLike { public dispose(): void { console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`) - // Stop the idle telemetry check and report any unflushed activity as a - // shutdown installment, so a task torn down mid-work (panel closed, task - // switched, extension deactivated) isn't invisible to telemetry. - try { - clearInterval(this.idleTelemetryCheckInterval) - this.idleTelemetryCheckInterval = undefined - this.flushTelemetryInstallment("shutdown") - } catch (error) { - console.error("Error flushing shutdown telemetry:", error) - } - // Cancel any in-progress HTTP request try { this.cancelCurrentRequest() @@ -2665,16 +2629,18 @@ export class Task extends EventEmitter implements TaskLike { // Add environment details as its own text block, separate from tool // results. const finalUserContent = [...contentWithoutEnvDetails, { type: "text" as const, text: environmentDetails }] - // See shouldAddUserMessageToHistory for the full add/skip rules (retry/empty/removed). + // Only add user message to conversation history if: + // 1. This is the first attempt (retryAttempt === 0), AND + // 2. The original userContent was not empty (empty signals delegation resume where + // the user message with tool_result and env details is already in history), OR + // 3. The message was removed in a previous iteration (userMessageWasRemoved === true) + // This prevents consecutive user messages while allowing re-add when needed const isEmptyUserContent = currentUserContent.length === 0 - const shouldAddUserMessage = shouldAddUserMessageToHistory({ - retryAttempt: currentItem.retryAttempt, - isEmptyUserContent, - userMessageWasRemoved: currentItem.userMessageWasRemoved, - }) + const shouldAddUserMessage = + ((currentItem.retryAttempt ?? 0) === 0 && !isEmptyUserContent) || currentItem.userMessageWasRemoved if (shouldAddUserMessage) { await this.addToApiConversationHistory({ role: "user", content: finalUserContent }) - this.messageCounts.user++ + TelemetryService.instance.captureConversationMessage(this.taskId, "user") } // Since we sent off a placeholder api_req_started message to update the @@ -3593,7 +3559,7 @@ export class Task extends EventEmitter implements TaskLike { ) this.assistantMessageSavedToHistory = true - this.messageCounts.assistant++ + TelemetryService.instance.captureConversationMessage(this.taskId, "assistant") } // Present any partial blocks that were just completed. @@ -3694,17 +3660,11 @@ export class Task extends EventEmitter implements TaskLike { // we need to remove that message before retrying to avoid having two consecutive // user messages (which would cause tool_result validation errors). const state = await this.providerRef.deref()?.getState() - // Only pop the user message that this iteration added. When - // shouldAddUserMessage is false (empty continuation, resumed history, - // or flushPendingToolResultsToHistory message) there is nothing to - // remove, and popping would corrupt history. - let removedCurrentUserMessage = false - if (shouldAddUserMessage && this.apiConversationHistory.length > 0) { + if (this.apiConversationHistory.length > 0) { const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1] if (lastMessage.role === "user") { + // Remove the last user message that we added earlier this.apiConversationHistory.pop() - this.messageCounts.user-- - removedCurrentUserMessage = true } } @@ -3727,14 +3687,13 @@ export class Task extends EventEmitter implements TaskLike { break } - // Push the same content back onto the stack to retry, incrementing the retry attempt counter. - // Only mark userMessageWasRemoved when we actually removed one -- the - // restore branch in shouldAddUserMessageToHistory must only fire once. + // Push the same content back onto the stack to retry, incrementing the retry attempt counter + // Mark that user message was removed so it gets re-added on retry stack.push({ userContent: currentUserContent, includeFileDetails: false, retryAttempt: (currentItem.retryAttempt ?? 0) + 1, - userMessageWasRemoved: removedCurrentUserMessage, + userMessageWasRemoved: true, }) // Continue to retry the request @@ -3749,41 +3708,32 @@ export class Task extends EventEmitter implements TaskLike { if (response === "yesButtonClicked") { await this.say("api_req_retried") - // Push the same content back to retry. Only mark userMessageWasRemoved - // when we actually removed one so the restore fires exactly once. + // Push the same content back to retry stack.push({ userContent: currentUserContent, includeFileDetails: false, retryAttempt: (currentItem.retryAttempt ?? 0) + 1, - userMessageWasRemoved: removedCurrentUserMessage, }) // Continue to retry the request continue } else { - // User declined to retry. Re-add the user message only if this - // iteration removed one, so the history and counter stay consistent. - if (removedCurrentUserMessage) { - await this.addToApiConversationHistory({ - role: "user", - content: currentUserContent, - }) - this.messageCounts.user++ - } + // User declined to retry + // Re-add the user message we removed. + await this.addToApiConversationHistory({ + role: "user", + content: currentUserContent, + }) await this.say( "error", "Unexpected API Response: The language model did not provide any assistant messages. This may indicate an issue with the API or the model's output.", ) - // Synthetic assistant message recording the failure -- increment - // messageCounts.assistant to match, same as the normal - // assistant-message-saved path. await this.addToApiConversationHistory({ role: "assistant", content: [{ type: "text", text: "Failure: I did not provide a response." }], }) - this.messageCounts.assistant++ } } } @@ -4749,76 +4699,6 @@ export class Task extends EventEmitter implements TaskLike { } } - /** - * Emits a Task Completed installment for whatever toolUsage/messageCounts have - * changed since the previous installment (from any reason), then advances the - * telemetry baseline so a later installment reports only its own delta. Does - * NOT touch task.toolUsage/messageCounts themselves -- those stay running totals - * for the public TaskCompleted API event and the UI. No-ops if nothing changed - * since the last installment, so idle/shutdown checks don't emit empty events - * for tasks that were already fully reported (e.g. right after attempt_completion). - */ - public flushTelemetryInstallment(reason: "attempt_completion" | "idle" | "shutdown"): void { - const toolUsageDelta: ToolUsage = {} - - for (const [toolName, usage] of Object.entries(this.toolUsage) as [ToolName, ToolUsage[ToolName]][]) { - if (!usage) { - continue - } - - const baseline = this.telemetryToolUsageBaseline[toolName] - const attempts = usage.attempts - (baseline?.attempts ?? 0) - const failures = usage.failures - (baseline?.failures ?? 0) - - if (attempts > 0 || failures > 0) { - toolUsageDelta[toolName] = { attempts, failures } - } - } - - const messageCountDelta = { - user: Math.max(0, this.messageCounts.user - this.telemetryMessageCountsBaseline.user), - assistant: Math.max(0, this.messageCounts.assistant - this.telemetryMessageCountsBaseline.assistant), - } - - const hasToolUsageDelta = Object.keys(toolUsageDelta).length > 0 - const hasMessageDelta = messageCountDelta.user > 0 || messageCountDelta.assistant > 0 - - if (!hasToolUsageDelta && !hasMessageDelta) { - return - } - - // Advance the baseline before emitting so a synchronous throw from an - // EventEmitter listener or TelemetryService client cannot leave the baseline - // behind the running totals, which would cause the same delta to re-appear - // on the next flush. The delta values are already captured in locals above. - this.telemetryToolUsageBaseline = JSON.parse(JSON.stringify(this.toolUsage)) - this.telemetryMessageCountsBaseline = { ...this.messageCounts } - this.lastTelemetryFlushAt = Date.now() - - this.emitFinalTokenUsageUpdate() - TelemetryService.instance.captureTaskCompleted(this.taskId, toolUsageDelta, messageCountDelta, reason) - } - - startIdleTelemetryCheck(): void { - if (this.idleTelemetryCheckInterval !== undefined) { - return - } - this.idleTelemetryCheckInterval = setInterval(() => { - // Measure idleness from the later of the last activity and the last flush. - // Using lastMessageTs alone would keep the condition true forever after the - // first idle flush, re-running the empty-delta check on every interval tick. - const lastEventAt = Math.max(this.lastMessageTs ?? 0, this.lastTelemetryFlushAt) - const idleForMs = Date.now() - lastEventAt - - if (idleForMs >= Task.IDLE_TELEMETRY_THRESHOLD_MS) { - this.flushTelemetryInstallment("idle") - } - }, Task.IDLE_TELEMETRY_CHECK_INTERVAL_MS) - - // Don't hold the process open just for this timer. - this.idleTelemetryCheckInterval?.unref?.() - } - // Getters public get taskStatus(): TaskStatus { diff --git a/src/core/task/__tests__/Task.spec.ts b/src/core/task/__tests__/Task.spec.ts index 3537292009..330241e221 100644 --- a/src/core/task/__tests__/Task.spec.ts +++ b/src/core/task/__tests__/Task.spec.ts @@ -5,7 +5,6 @@ import * as path from "path" import * as vscode from "vscode" import { Anthropic } from "@anthropic-ai/sdk" -import type { Mock } from "vitest" import { providerIdentifiers, @@ -37,17 +36,6 @@ type TaskTestAccess = { addToApiConversationHistory: (message: unknown, reasoning?: string) => Promise } -type TaskAskResult = Awaited> -type ProviderState = Awaited> -type MockedClineProvider = ClineProvider & { - getState: Mock & { - mockResolvedValue: (value: Partial) => void - } -} -type RateLimitedProviderSettings = ProviderSettings & { - rateLimitSeconds: number -} - function getTaskTestAccess(task: Task): TaskTestAccess { return task as unknown as TaskTestAccess } @@ -80,7 +68,7 @@ vi.mock("execa", () => ({ })) vi.mock("fs/promises", async (importOriginal) => { - const actual = await importOriginal() + const actual = (await importOriginal()) as Record const mockFunctions = { mkdir: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), @@ -167,7 +155,7 @@ vi.mock("vscode", () => { stat: vi.fn().mockResolvedValue({ type: 1 }), // FileType.File = 1 }, onDidSaveTextDocument: vi.fn(() => mockDisposable), - getConfiguration: vi.fn(() => ({ get: (_key: string, defaultValue: unknown) => defaultValue })), + getConfiguration: vi.fn(() => ({ get: (key: string, defaultValue: any) => defaultValue })), }, env: { uriScheme: "vscode", @@ -261,9 +249,9 @@ const mockMessages = [ ] describe("Cline", () => { - let mockProvider: ClineProvider + let mockProvider: any let mockApiConfig: ProviderSettings - let mockOutputChannel: vscode.OutputChannel + let mockOutputChannel: any let mockExtensionContext: vscode.ExtensionContext beforeEach(() => { @@ -323,10 +311,8 @@ describe("Cline", () => { // Setup mock output channel mockOutputChannel = { - name: "test-output", appendLine: vi.fn(), append: vi.fn(), - replace: vi.fn(), clear: vi.fn(), show: vi.fn(), hide: vi.fn(), @@ -381,74 +367,6 @@ describe("Cline", () => { })) }) - describe("empty-response retries", () => { - function stream(chunks: ApiStreamChunk[]): AsyncGenerator { - return (async function* () { - yield* chunks - })() - } - - async function createTaskWithManualRetries() { - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "test task", - startTask: false, - }) - const state = await mockProvider.getState() - vi.spyOn(mockProvider, "getState").mockResolvedValue({ - ...state, - apiConfiguration: mockApiConfig, - autoApprovalEnabled: false, - }) - vi.spyOn(task.diffViewProvider, "reset").mockResolvedValue(undefined) - return task - } - - it("restores the user message before a confirmed empty-response retry", async () => { - const task = await createTaskWithManualRetries() - let retryHistory: ApiMessage[] | undefined - let retryUserMessageCount: number | undefined - - vi.spyOn(task, "ask").mockResolvedValue({ response: "yesButtonClicked" } satisfies TaskAskResult) - vi.spyOn(task, "attemptApiRequest") - .mockImplementationOnce(() => stream([])) - .mockImplementationOnce(() => { - retryHistory = structuredClone(task.apiConversationHistory) - retryUserMessageCount = task.messageCounts.user - return stream([{ type: "text", text: "retry succeeded" }]) - }) - .mockImplementation(() => { - throw new Error("stop after retry response") - }) - - await task.recursivelyMakeClineRequests([{ type: "text", text: "original user request" }]) - - expect(retryHistory).toHaveLength(1) - expect(retryHistory?.[0]).toMatchObject({ - role: "user", - content: expect.arrayContaining([expect.objectContaining({ text: "original user request" })]), - }) - expect(retryUserMessageCount).toBe(1) - }) - - it("restores the user message and records the failure when retry is declined", async () => { - const task = await createTaskWithManualRetries() - - vi.spyOn(task, "ask").mockResolvedValue({ response: "noButtonClicked" } satisfies TaskAskResult) - vi.spyOn(task, "attemptApiRequest").mockImplementation(() => stream([])) - - const result = await task.recursivelyMakeClineRequests([{ type: "text", text: "original user request" }]) - - expect(result).toBe(false) - expect(task.apiConversationHistory).toMatchObject([ - { role: "user", content: [{ type: "text", text: "original user request" }] }, - { role: "assistant", content: [{ type: "text", text: "Failure: I did not provide a response." }] }, - ]) - expect(task.messageCounts).toEqual({ user: 1, assistant: 1 }) - }) - }) - describe("constructor", () => { it("should always have diff strategy defined", async () => { const cline = new Task({ @@ -590,7 +508,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(error: unknown) { + async throw(error: any) { throw error }, async [Symbol.asyncDispose]() { @@ -696,7 +614,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(error: unknown) { + async throw(error: any) { throw error }, async [Symbol.asyncDispose]() { @@ -773,7 +691,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, async [Symbol.asyncDispose]() { @@ -792,7 +710,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, async [Symbol.asyncDispose]() { @@ -864,7 +782,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, async [Symbol.asyncDispose]() {}, @@ -880,7 +798,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, async [Symbol.asyncDispose]() {}, @@ -941,7 +859,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, async [Symbol.asyncDispose]() { @@ -960,7 +878,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, async [Symbol.asyncDispose]() { @@ -1081,8 +999,8 @@ describe("Cline", () => { }) describe("Subtask Rate Limiting", () => { - let mockProvider: MockedClineProvider - let mockApiConfig: RateLimitedProviderSettings + let mockProvider: any + let mockApiConfig: any let mockDelay: ReturnType beforeEach(() => { @@ -1113,8 +1031,7 @@ describe("Cline", () => { postStateToWebviewWithoutTaskHistory: vi.fn().mockResolvedValue(undefined), postMessageToWebview: vi.fn().mockResolvedValue(undefined), updateTaskHistory: vi.fn().mockResolvedValue(undefined), - // Task receives a full ClineProvider at runtime; this focused unit test only exercises these methods. - } as unknown as MockedClineProvider + } // Get the mocked delay function mockDelay = delay as ReturnType @@ -1149,7 +1066,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -1190,7 +1107,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -1242,7 +1159,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -1308,7 +1225,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -1398,7 +1315,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -1456,7 +1373,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -1475,8 +1392,8 @@ describe("Cline", () => { }) describe("Dynamic Strategy Selection", () => { - let mockProvider: MockedClineProvider - let mockApiConfig: ProviderSettings + let mockProvider: any + let mockApiConfig: any beforeEach(() => { vi.clearAllMocks() @@ -1491,7 +1408,7 @@ describe("Cline", () => { globalStorageUri: { fsPath: "/test/storage" }, }, getState: vi.fn(), - } as MockedClineProvider + } }) it("should use MultiSearchReplaceDiffStrategy by default", async () => { @@ -2043,7 +1960,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -2194,7 +2111,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -2264,7 +2181,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -2330,7 +2247,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -2395,7 +2312,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -2555,7 +2472,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -2571,7 +2488,7 @@ describe("Cline", () => { async return() { return { done: true, value: undefined } }, - async throw(e: unknown) { + async throw(e: any) { throw e }, [Symbol.asyncDispose]: async () => {}, @@ -3250,7 +3167,7 @@ describe("Cline", () => { }) describe("Queued message processing after condense", () => { - function createProvider(): ClineProvider { + function createProvider(): any { const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } const ctx = { globalState: { @@ -3376,261 +3293,8 @@ describe("Queued message processing after condense", () => { }) }) -describe("Telemetry installments (idle/shutdown flush)", () => { - let mockProvider: ClineProvider - let mockApiConfig: ProviderSettings - let mockExtensionContext: vscode.ExtensionContext - let captureTaskCompletedSpy: ReturnType - - beforeEach(() => { - if (!TelemetryService.hasInstance()) { - TelemetryService.createInstance([]) - } - - captureTaskCompletedSpy = vi.spyOn(TelemetryService.instance, "captureTaskCompleted") - - const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } - - mockExtensionContext = { - globalState: { - get: vi.fn().mockReturnValue(undefined), - update: vi.fn().mockResolvedValue(undefined), - keys: vi.fn().mockReturnValue([]), - }, - globalStorageUri: storageUri, - workspaceState: { - get: vi.fn().mockReturnValue(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" } }, - } as unknown as vscode.ExtensionContext - - mockProvider = new ClineProvider( - mockExtensionContext, - { - appendLine: vi.fn(), - append: vi.fn(), - clear: vi.fn(), - show: vi.fn(), - hide: vi.fn(), - dispose: vi.fn(), - } as unknown as vscode.OutputChannel, - "sidebar", - new ContextProxy(mockExtensionContext), - ) - mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) - mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) - - mockApiConfig = { - apiProvider: "anthropic", - apiModelId: "claude-3-5-sonnet-20241022", - apiKey: "test-api-key", - } - }) - - const createdTasks: Task[] = [] - - afterEach(() => { - for (const task of createdTasks) { - task.dispose() - } - createdTasks.length = 0 - vi.useRealTimers() - captureTaskCompletedSpy.mockRestore() - }) - - function createTask() { - const task = new Task({ - provider: mockProvider, - apiConfiguration: mockApiConfig, - task: "test task", - startTask: false, - }) - task.startIdleTelemetryCheck() - createdTasks.push(task) - return task - } - - describe("flushTelemetryInstallment", () => { - it("reports nothing and does not call captureTaskCompleted when there is no new activity", () => { - const task = createTask() - - task.flushTelemetryInstallment("idle") - - expect(captureTaskCompletedSpy).not.toHaveBeenCalled() - }) - - it("reports the current toolUsage/messageCounts as the delta on the first flush", () => { - const task = createTask() - task.recordToolUsage("read_file") - task.recordToolUsage("read_file") - task.messageCounts = { user: 2, assistant: 3 } - - task.flushTelemetryInstallment("idle") - - expect(captureTaskCompletedSpy).toHaveBeenCalledWith( - task.taskId, - { read_file: { attempts: 2, failures: 0 } }, - { user: 2, assistant: 3 }, - "idle", - ) - }) - - it("does not mutate task.toolUsage/messageCounts (they stay running totals for the public API/UI)", () => { - const task = createTask() - task.recordToolUsage("read_file") - task.messageCounts = { user: 1, assistant: 1 } - - task.flushTelemetryInstallment("idle") - - expect(task.toolUsage).toEqual({ read_file: { attempts: 1, failures: 0 } }) - expect(task.messageCounts).toEqual({ user: 1, assistant: 1 }) - }) - - it("reports only the delta since the previous installment on a second flush", () => { - const task = createTask() - task.recordToolUsage("read_file") - task.messageCounts = { user: 1, assistant: 1 } - task.flushTelemetryInstallment("idle") - captureTaskCompletedSpy.mockClear() - - task.recordToolUsage("read_file") - task.recordToolUsage("write_to_file") - task.messageCounts = { user: 3, assistant: 2 } - task.flushTelemetryInstallment("shutdown") - - expect(captureTaskCompletedSpy).toHaveBeenCalledWith( - task.taskId, - { read_file: { attempts: 1, failures: 0 }, write_to_file: { attempts: 1, failures: 0 } }, - { user: 2, assistant: 1 }, - "shutdown", - ) - }) - - it("does not emit an empty second installment when nothing changed since the first flush", () => { - const task = createTask() - task.recordToolUsage("read_file") - task.flushTelemetryInstallment("idle") - captureTaskCompletedSpy.mockClear() - - task.flushTelemetryInstallment("shutdown") - - expect(captureTaskCompletedSpy).not.toHaveBeenCalled() - }) - - it("includes failure deltas alongside attempt deltas", () => { - const task = createTask() - task.recordToolUsage("read_file") - task.flushTelemetryInstallment("idle") - captureTaskCompletedSpy.mockClear() - - task.recordToolError("read_file") - - task.flushTelemetryInstallment("shutdown") - - expect(captureTaskCompletedSpy).toHaveBeenCalledWith( - task.taskId, - { read_file: { attempts: 0, failures: 1 } }, - { user: 0, assistant: 0 }, - "shutdown", - ) - }) - }) - - describe("idle flush timer", () => { - it("flushes once activity has been quiet for the idle threshold", () => { - vi.useFakeTimers() - const task = createTask() - task.recordToolUsage("read_file") - - vi.advanceTimersByTime(31 * 60 * 1000) - - expect(captureTaskCompletedSpy).toHaveBeenCalledWith( - task.taskId, - { read_file: { attempts: 1, failures: 0 } }, - { user: 0, assistant: 0 }, - "idle", - ) - }) - - it("does not flush before the idle threshold has elapsed", () => { - vi.useFakeTimers() - const task = createTask() - task.recordToolUsage("read_file") - - vi.advanceTimersByTime(10 * 60 * 1000) - - expect(captureTaskCompletedSpy).not.toHaveBeenCalled() - }) - - it("does not call the idle flush after a prior idle installment without new activity", () => { - vi.useFakeTimers() - const task = createTask() - const flushTelemetryInstallmentSpy = vi.spyOn(task, "flushTelemetryInstallment") - task.recordToolUsage("read_file") - - vi.advanceTimersByTime(31 * 60 * 1000) - expect(captureTaskCompletedSpy).toHaveBeenCalledTimes(1) - flushTelemetryInstallmentSpy.mockClear() - captureTaskCompletedSpy.mockClear() - - vi.advanceTimersByTime(5 * 60 * 1000) - - expect(flushTelemetryInstallmentSpy).not.toHaveBeenCalled() - expect(captureTaskCompletedSpy).not.toHaveBeenCalled() - }) - }) - - describe("dispose", () => { - it("flushes unreported activity as a shutdown installment", () => { - const task = createTask() - task.recordToolUsage("read_file") - task.messageCounts = { user: 1, assistant: 1 } - - task.dispose() - - expect(captureTaskCompletedSpy).toHaveBeenCalledWith( - task.taskId, - { read_file: { attempts: 1, failures: 0 } }, - { user: 1, assistant: 1 }, - "shutdown", - ) - }) - - it("does not flush again if everything was already reported before dispose", () => { - const task = createTask() - task.recordToolUsage("read_file") - task.flushTelemetryInstallment("attempt_completion") - captureTaskCompletedSpy.mockClear() - - task.dispose() - - expect(captureTaskCompletedSpy).not.toHaveBeenCalled() - }) - - it("stops the idle timer so a disposed task never flushes again", () => { - vi.useFakeTimers() - const task = createTask() - task.recordToolUsage("read_file") - task.dispose() - captureTaskCompletedSpy.mockClear() - - vi.advanceTimersByTime(60 * 60 * 1000) - - expect(captureTaskCompletedSpy).not.toHaveBeenCalled() - }) - }) -}) - describe("pushToolResultToUserContent", () => { - let mockProvider: ClineProvider + let mockProvider: any let mockApiConfig: ProviderSettings beforeEach(() => { diff --git a/src/core/task/__tests__/messageCounting.spec.ts b/src/core/task/__tests__/messageCounting.spec.ts deleted file mode 100644 index 6e49473201..0000000000 --- a/src/core/task/__tests__/messageCounting.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -// npx vitest run core/task/__tests__/messageCounting.spec.ts - -import { shouldAddUserMessageToHistory } from "../messageCounting" - -describe("shouldAddUserMessageToHistory", () => { - it("adds the message on a first attempt with non-empty content", () => { - expect( - shouldAddUserMessageToHistory({ - retryAttempt: 0, - isEmptyUserContent: false, - userMessageWasRemoved: false, - }), - ).toBe(true) - }) - - it("adds the message when retryAttempt is undefined (treated as first attempt) with non-empty content", () => { - expect( - shouldAddUserMessageToHistory({ - retryAttempt: undefined, - isEmptyUserContent: false, - userMessageWasRemoved: undefined, - }), - ).toBe(true) - }) - - it("skips an empty-content first attempt (delegation resume - already in history)", () => { - expect( - shouldAddUserMessageToHistory({ - retryAttempt: 0, - isEmptyUserContent: true, - userMessageWasRemoved: false, - }), - ).toBe(false) - }) - - it("skips a retry attempt (retryAttempt > 0) with non-empty content", () => { - expect( - shouldAddUserMessageToHistory({ - retryAttempt: 1, - isEmptyUserContent: false, - userMessageWasRemoved: false, - }), - ).toBe(false) - }) - - it("re-adds the message on a retry attempt if it was previously removed", () => { - expect( - shouldAddUserMessageToHistory({ - retryAttempt: 2, - isEmptyUserContent: false, - userMessageWasRemoved: true, - }), - ).toBe(true) - }) - - it("re-adds an empty-content message if it was previously removed", () => { - expect( - shouldAddUserMessageToHistory({ - retryAttempt: 0, - isEmptyUserContent: true, - userMessageWasRemoved: true, - }), - ).toBe(true) - }) - - it("skips a retry attempt with empty content that was not removed", () => { - expect( - shouldAddUserMessageToHistory({ - retryAttempt: 3, - isEmptyUserContent: true, - userMessageWasRemoved: false, - }), - ).toBe(false) - }) -}) diff --git a/src/core/task/messageCounting.ts b/src/core/task/messageCounting.ts deleted file mode 100644 index 11a886d619..0000000000 --- a/src/core/task/messageCounting.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Whether the current turn's user message should be added to API conversation - * history (and counted towards the per-task message-count telemetry summary). - * - * Only added when: - * 1. This is the first attempt (retryAttempt === 0) AND the content is non-empty, OR - * 2. The message was removed in a previous iteration (userMessageWasRemoved === true) - * - * Empty content on a first attempt signals a delegation resume, where the user message - * with tool_result and env details is already in history -- adding it again would create - * a duplicate (and inflate the message count). - */ -export function shouldAddUserMessageToHistory(params: { - retryAttempt: number | undefined - isEmptyUserContent: boolean - userMessageWasRemoved: boolean | undefined -}): boolean { - const { retryAttempt, isEmptyUserContent, userMessageWasRemoved } = params - return ((retryAttempt ?? 0) === 0 && !isEmptyUserContent) || Boolean(userMessageWasRemoved) -} diff --git a/src/core/tools/AttemptCompletionTool.ts b/src/core/tools/AttemptCompletionTool.ts index b5f19decb0..39feae0d9b 100644 --- a/src/core/tools/AttemptCompletionTool.ts +++ b/src/core/tools/AttemptCompletionTool.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import { RooCodeEventName, type HistoryItem } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" @@ -80,19 +81,6 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { await task.say("completion_result", result, undefined, false) - // Whether this attempt_completion call is a stale replay of an already-completed - // subtask (user revisiting it from history) rather than a live model-initiated - // completion. Determined below, before telemetry is flushed, so a replay -- which - // runs this handler again on a fresh Task instance with a zero telemetry baseline - // -- doesn't produce a duplicate "attempt_completion" installment for work that - // was already reported when the subtask first completed. - let isStaleHistoryReplay = false - // Whether the delegation branch below already flushed telemetry (it needs to - // flush before delegateToParent, which may return early) -- prevents the shared - // fallthrough flush from double-reporting when delegation falls through to - // "continue" instead of returning. - let hasFlushedTelemetry = false - // Check for subtask using parentTaskId (metadata-driven delegation) if (task.parentTaskId) { // Check if this subtask has already completed and returned to parent @@ -109,7 +97,6 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { // Fall through to normal completion ask flow below (outside this if block) // This shows the user the completion result and waits for acceptance // without injecting another tool_result to the parent - isStaleHistoryReplay = true } else if (status === "active" || status === "interrupted") { historyLookupTaskId = task.parentTaskId const { historyItem: parentHistory } = await provider.getTaskWithId(task.parentTaskId) @@ -118,14 +105,6 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { (parentHistory?.status === "delegated" || parentHistory?.status === "active") && parentHistory?.awaitingChildId === task.taskId ) { - // Known not to be a stale history replay (status was "active", not - // "completed"), so flush telemetry before the delegation call, which - // may return early below. hasFlushedTelemetry prevents the shared - // fallthrough flush further down from double-reporting if delegation - // falls through to "continue" instead of returning. - task.flushTelemetryInstallment("attempt_completion") - hasFlushedTelemetry = true - const delegation = await this.delegateToParent( task, result, @@ -134,7 +113,7 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { pushToolResult, ) if (delegation === "delegated") { - this.emitPublicTaskCompleted(task) + this.emitTaskCompleted(task) } if (delegation !== "continue") return } else { @@ -168,30 +147,10 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { } } - // PostHog telemetry: report here, once per model-initiated attempt_completion - // call, regardless of whether the user goes on to accept, decline, or give - // feedback. Gating this on user acceptance previously meant a task that never - // got an explicit "yes" (declined, abandoned mid-review, etc.) reported nothing - // at all. This is independent of the public TaskCompleted API event, which still - // only fires once the task is genuinely finished. Skipped for a stale history - // replay (revisiting an already-completed subtask) since that reruns this handler - // on a fresh Task instance and would otherwise double-report work already flushed - // when the subtask first completed, and skipped if the delegation branch above - // already flushed. - if (!isStaleHistoryReplay && !hasFlushedTelemetry) { - task.emitFinalTokenUsageUpdate() - task.flushTelemetryInstallment("attempt_completion") - } - const { response, text, images } = await task.ask("completion_result", "", false) if (response === "yesButtonClicked") { - // A stale history replay reruns this handler on a fresh Task instance for a - // subtask that already completed (and already emitted TaskCompleted) the first - // time through -- re-acknowledging it from history must not emit it again. - if (!isStaleHistoryReplay) { - this.emitPublicTaskCompleted(task) - } + this.emitTaskCompleted(task) return } @@ -258,17 +217,12 @@ export class AttemptCompletionTool extends BaseTool<"attempt_completion"> { } } - /** - * Emits the public RooCodeEventName.TaskCompleted API event. Only called once the - * task is genuinely finished (user accepted, or a subtask was successfully delegated - * back to its parent) -- unlike the PostHog telemetry flush, which reports on every - * model-initiated attempt_completion call regardless of outcome. - */ - private emitPublicTaskCompleted(task: Task): void { + private emitTaskCompleted(task: Task): void { // Force final token usage update before emitting TaskCompleted. // This ensures the latest stats are captured regardless of throttle timer. task.emitFinalTokenUsageUpdate() + TelemetryService.instance.captureTaskCompleted(task.taskId) task.emit(RooCodeEventName.TaskCompleted, task.taskId, task.getTokenUsage(), task.toolUsage) } } diff --git a/src/core/tools/__tests__/attemptCompletionTool.spec.ts b/src/core/tools/__tests__/attemptCompletionTool.spec.ts index 015be6f6bb..86ff112585 100644 --- a/src/core/tools/__tests__/attemptCompletionTool.spec.ts +++ b/src/core/tools/__tests__/attemptCompletionTool.spec.ts @@ -11,6 +11,17 @@ vi.mock("../../prompts/responses", () => ({ }, })) +const { mockCaptureTaskCompleted } = vi.hoisted(() => ({ + mockCaptureTaskCompleted: vi.fn(), +})) +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureTaskCompleted: mockCaptureTaskCompleted, + }, + }, +})) + // Mock vscode module vi.mock("vscode", () => ({ workspace: { @@ -42,6 +53,7 @@ describe("attemptCompletionTool", () => { let mockGetConfiguration: ReturnType any>> beforeEach(() => { + mockCaptureTaskCompleted.mockReset() mockPushToolResult = vi.fn() mockAskApproval = vi.fn() mockHandleError = vi.fn() @@ -69,11 +81,9 @@ describe("attemptCompletionTool", () => { emit: vi.fn(), getTokenUsage: vi.fn().mockReturnValue({}), toolUsage: {}, - messageCounts: { user: 0, assistant: 0 }, taskId: "task_1", apiConfiguration: { apiProvider: "test" } as any, api: { getModel: vi.fn().mockReturnValue({ id: "test-model", info: {} }) } as any, - flushTelemetryInstallment: vi.fn(), } }) @@ -575,10 +585,7 @@ describe("attemptCompletionTool", () => { }) expect(mockTask.ask).toHaveBeenCalledWith("completion_result", "", false) expect(mockPushToolResult).not.toHaveBeenCalledWith("") - // Flush once per validated attempt_completion call, before delegation is - // attempted, independent of whether delegation succeeds. - expect(mockTask.flushTelemetryInstallment).toHaveBeenCalledTimes(1) - expect(mockTask.flushTelemetryInstallment).toHaveBeenCalledWith("attempt_completion") + expect(mockCaptureTaskCompleted).not.toHaveBeenCalled() }) it("does not resume the parent when the parent is no longer awaiting this child", async () => { @@ -626,8 +633,7 @@ describe("attemptCompletionTool", () => { expect(mockProvider.reopenParentFromDelegation).not.toHaveBeenCalled() expect(mockProvider.log).toHaveBeenCalledWith(expect.stringContaining("Skipping delegation")) expect(mockTask.ask).toHaveBeenCalledWith("completion_result", "", false) - expect(mockTask.flushTelemetryInstallment).toHaveBeenCalledTimes(1) - expect(mockTask.flushTelemetryInstallment).toHaveBeenCalledWith("attempt_completion") + expect(mockCaptureTaskCompleted).toHaveBeenCalledWith("child-1") }) it("delegates an interrupted subtask completion when the parent is still delegated and awaiting that child", async () => { @@ -726,8 +732,7 @@ describe("attemptCompletionTool", () => { expect(mockProvider.reopenParentFromDelegation).not.toHaveBeenCalled() expect(mockProvider.log).toHaveBeenCalledWith(expect.stringContaining("Skipping delegation")) expect(mockTask.ask).toHaveBeenCalledWith("completion_result", "", false) - expect(mockTask.flushTelemetryInstallment).toHaveBeenCalledTimes(1) - expect(mockTask.flushTelemetryInstallment).toHaveBeenCalledWith("attempt_completion") + expect(mockCaptureTaskCompleted).toHaveBeenCalledWith("child-1") }) it("emits TaskCompleted only when completion is accepted", async () => { @@ -752,8 +757,7 @@ describe("attemptCompletionTool", () => { await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockHandleError).not.toHaveBeenCalled() - expect(mockTask.flushTelemetryInstallment).toHaveBeenCalledTimes(1) - expect(mockTask.flushTelemetryInstallment).toHaveBeenCalledWith("attempt_completion") + expect(mockCaptureTaskCompleted).toHaveBeenCalledWith("task_1") expect(mockTask.emit).toHaveBeenCalledWith( RooCodeEventName.TaskCompleted, "task_1", @@ -762,7 +766,7 @@ describe("attemptCompletionTool", () => { ) }) - it("reports telemetry but does not emit the public TaskCompleted event when user provides follow-up feedback", async () => { + it("does not emit TaskCompleted when user provides follow-up feedback", async () => { const block: AttemptCompletionToolUse = { type: "tool_use", name: "attempt_completion", @@ -788,12 +792,7 @@ describe("attemptCompletionTool", () => { await attemptCompletionTool.handle(mockTask as Task, block, callbacks) expect(mockHandleError).not.toHaveBeenCalled() - // Telemetry is reported on every model-initiated attempt_completion call, - // regardless of whether the user accepts, declines, or gives feedback. - expect(mockTask.flushTelemetryInstallment).toHaveBeenCalledTimes(1) - expect(mockTask.flushTelemetryInstallment).toHaveBeenCalledWith("attempt_completion") - // The public RooCodeEventName.TaskCompleted API event still only fires once - // the user actually accepts the result. + expect(mockCaptureTaskCompleted).not.toHaveBeenCalled() expect(mockTask.emit).not.toHaveBeenCalledWith( RooCodeEventName.TaskCompleted, expect.anything(), @@ -805,161 +804,3 @@ describe("attemptCompletionTool", () => { }) }) }) - -describe("attemptCompletionTool telemetry invariants", () => { - function makeTask(overrides: Partial = {}): Partial { - return { - consecutiveMistakeCount: 0, - recordToolError: vi.fn(), - todoList: undefined, - say: vi.fn().mockResolvedValue(undefined), - ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: "", images: [] }), - emitFinalTokenUsageUpdate: vi.fn(), - emit: vi.fn(), - getTokenUsage: vi.fn().mockReturnValue({}), - toolUsage: {}, - messageCounts: { user: 0, assistant: 0 }, - taskId: "task_1", - flushTelemetryInstallment: vi.fn(), - ...overrides, - } - } - - it("does not emit a duplicate telemetry installment when replaying an already-completed subtask from history", async () => { - const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", - params: { result: "done" }, - nativeArgs: { result: "done" }, - partial: false, - } - const mockProvider = { - log: vi.fn(), - getTaskWithId: vi.fn().mockImplementation((id: string) => { - if (id === "child-1") return Promise.resolve({ historyItem: { id, status: "completed" } }) - throw new Error(`unexpected task id ${id}`) - }), - reopenParentFromDelegation: vi.fn(), - } - - const task = makeTask({ - taskId: "child-1", - parentTaskId: "parent-1", - toolUsage: { read_file: { attempts: 5, failures: 0 } }, - messageCounts: { user: 3, assistant: 4 }, - }) - Object.assign(task, { providerRef: { deref: () => mockProvider } }) - - await attemptCompletionTool.handle(task as Task, block, { - askApproval: vi.fn(), - handleError: vi.fn(), - pushToolResult: vi.fn(), - askFinishSubTaskApproval: vi.fn(), - toolDescription: vi.fn(), - } as AttemptCompletionCallbacks) - - expect(task.flushTelemetryInstallment).not.toHaveBeenCalled() - }) - - it("does not emit the public TaskCompleted event when replaying an already-completed subtask from history", async () => { - const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", - params: { result: "done" }, - nativeArgs: { result: "done" }, - partial: false, - } - const mockProvider = { - log: vi.fn(), - getTaskWithId: vi.fn().mockImplementation((id: string) => { - if (id === "child-1") return Promise.resolve({ historyItem: { id, status: "completed" } }) - throw new Error(`unexpected task id ${id}`) - }), - reopenParentFromDelegation: vi.fn(), - } - - const task = makeTask({ - taskId: "child-1", - parentTaskId: "parent-1", - ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: "", images: [] }), - }) - Object.assign(task, { providerRef: { deref: () => mockProvider } }) - - await attemptCompletionTool.handle(task as Task, block, { - askApproval: vi.fn(), - handleError: vi.fn(), - pushToolResult: vi.fn(), - askFinishSubTaskApproval: vi.fn(), - toolDescription: vi.fn(), - } as AttemptCompletionCallbacks) - - expect(task.emit).not.toHaveBeenCalledWith( - RooCodeEventName.TaskCompleted, - expect.anything(), - expect.anything(), - expect.anything(), - ) - }) - - it("emits the public TaskCompleted API event only when completion is accepted, but reports telemetry either way", async () => { - const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", - params: { result: "done" }, - nativeArgs: { result: "done" }, - partial: false, - } - - const task = makeTask({ - ask: vi.fn().mockResolvedValue({ response: "yesButtonClicked", text: "", images: [] }), - }) - - await attemptCompletionTool.handle(task as Task, block, { - askApproval: vi.fn(), - handleError: vi.fn(), - pushToolResult: vi.fn(), - askFinishSubTaskApproval: vi.fn(), - toolDescription: vi.fn(), - } as AttemptCompletionCallbacks) - - expect(task.flushTelemetryInstallment).toHaveBeenCalledTimes(1) - expect(task.flushTelemetryInstallment).toHaveBeenCalledWith("attempt_completion") - expect(task.emit).toHaveBeenCalledWith( - RooCodeEventName.TaskCompleted, - "task_1", - expect.anything(), - expect.anything(), - ) - }) - - it("still reports telemetry for a model-initiated completion even when the user provides follow-up feedback instead of accepting", async () => { - const block: AttemptCompletionToolUse = { - type: "tool_use", - name: "attempt_completion", - params: { result: "done" }, - nativeArgs: { result: "done" }, - partial: false, - } - - const task = makeTask({ - ask: vi.fn().mockResolvedValue({ response: "messageResponse", text: "one more thing", images: [] }), - }) - - await attemptCompletionTool.handle(task as Task, block, { - askApproval: vi.fn(), - handleError: vi.fn(), - pushToolResult: vi.fn(), - askFinishSubTaskApproval: vi.fn(), - toolDescription: vi.fn(), - } as AttemptCompletionCallbacks) - - expect(task.flushTelemetryInstallment).toHaveBeenCalledTimes(1) - expect(task.flushTelemetryInstallment).toHaveBeenCalledWith("attempt_completion") - expect(task.emit).not.toHaveBeenCalledWith( - RooCodeEventName.TaskCompleted, - expect.anything(), - expect.anything(), - expect.anything(), - ) - }) -}) diff --git a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts index 3ceeb2f895..a50e73cb16 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.routerModels.spec.ts @@ -128,7 +128,7 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { getModelsMock.mockImplementation(async (options: any) => { if (options?.provider === "deepseek") { - return { "deepseek-v4-flash": { contextWindow: 1_000_000, supportsPromptCache: true } } + return { "deepseek-chat": { contextWindow: 128000, supportsPromptCache: true } } } switch (options?.provider) { @@ -163,7 +163,7 @@ describe("webviewMessageHandler - requestRouterModels provider filter", () => { ) expect(call).toBeTruthy() expect(call[0].routerModels.deepseek).toEqual({ - "deepseek-v4-flash": { contextWindow: 1_000_000, supportsPromptCache: true }, + "deepseek-chat": { contextWindow: 128000, supportsPromptCache: true }, }) }) diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 13d7b06c96..7558fb6d57 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -186,7 +186,7 @@ }, "api/providers/__tests__/mimo.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 18 + "count": 29 } }, "api/providers/__tests__/minimax.spec.ts": { @@ -241,7 +241,7 @@ }, "api/providers/__tests__/opencode-go.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 3 + "count": 11 } }, "api/providers/__tests__/openrouter.spec.ts": { @@ -256,7 +256,7 @@ }, "api/providers/__tests__/qwen-code-native-tools.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 2 + "count": 5 } }, "api/providers/__tests__/sambanova.spec.ts": { @@ -844,6 +844,11 @@ "count": 8 } }, + "core/task/__tests__/Task.spec.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 31 + } + }, "core/task/__tests__/Task.sticky-profile-race.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 3 @@ -1484,6 +1489,11 @@ "count": 3 } }, + "services/code-index/semble/semble-downloader.ts": { + "@typescript-eslint/no-explicit-any": { + "count": 1 + } + }, "services/code-index/shared/__tests__/validation-helpers.spec.ts": { "@typescript-eslint/no-explicit-any": { "count": 4 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 7cf7599c89..7a3eee9a70 100644 --- a/src/services/code-index/semble/__tests__/semble-downloader.spec.ts +++ b/src/services/code-index/semble/__tests__/semble-downloader.spec.ts @@ -32,20 +32,11 @@ vi.mock("fs/promises", () => ({ readdir: vi.fn().mockResolvedValue([]), })) -vi.mock("proper-lockfile", () => ({ - lock: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(undefined)), -})) - // Mock fs (createWriteStream and createReadStream for checksum verification) -let closeHandler: (() => void) | undefined const mockWriteStream = { on: vi.fn(), close: vi.fn(), } -const onWriteStreamEvent = (event: string, callback: () => void) => { - if (event === "finish") setImmediate(callback) - if (event === "close") closeHandler = callback -} vi.mock("fs", () => ({ createWriteStream: vi.fn(() => mockWriteStream), createReadStream: vi.fn(() => { @@ -112,9 +103,8 @@ describe("SEMBLE_SHA256 checksum fixture", () => { describe("semble-downloader", () => { beforeEach(() => { vi.clearAllMocks() - closeHandler = undefined - mockWriteStream.on = vi.fn(onWriteStreamEvent) - mockWriteStream.close = vi.fn(() => closeHandler?.()) + mockWriteStream.on = vi.fn() + mockWriteStream.close = vi.fn() // Restore the default https.get mock so tests that override it don't leak ;(https.get as any).mockImplementation((_url: string, callback: (res: any) => void) => { @@ -233,6 +223,13 @@ describe("semble-downloader", () => { // No version file exists ;(fs.readFile as any).mockRejectedValue(new Error("ENOENT")) + // Simulate successful download: pipe is called, then "finish" fires + mockWriteStream.on.mockImplementation((event: string, cb: () => void) => { + if (event === "finish") { + setImmediate(cb) + } + }) + try { const result = await downloadSemble("/storage") @@ -266,14 +263,12 @@ describe("semble-downloader", () => { ) // Version file should be written expect(fs.writeFile).toHaveBeenCalledWith( - path.join("/storage", "semble.new", ".semble-version"), + path.join("/storage", "semble", ".semble-version"), "v0.4.1", "utf-8", ) // Archive should be cleaned up (version-prefixed local cache path) - expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-x64-fast.tar.gz"), { - force: true, - }) + expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-x64-fast.tar.gz")) } finally { if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform) if (originalArch) Object.defineProperty(process, "arch", originalArch) @@ -330,9 +325,7 @@ describe("semble-downloader", () => { try { await expect(downloadSemble("/storage")).rejects.toThrow("Failed to download semble") - expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-arm64-fast.tar.gz"), { - force: true, - }) + expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.1-semble-linux-arm64-fast.tar.gz")) // Should clean up staging directory, not the original expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "semble.new"), { recursive: true, @@ -381,6 +374,12 @@ describe("semble-downloader", () => { }) // Simulate successful download on the second response + mockWriteStream.on.mockImplementation((event: string, cb: () => void) => { + if (event === "finish") { + setImmediate(cb) + } + }) + try { const result = await downloadSemble("/storage") @@ -543,6 +542,12 @@ describe("semble-downloader", () => { ;(fs.readFile as any).mockRejectedValue(new Error("ENOENT")) // Simulate successful download + mockWriteStream.on.mockImplementation((event: string, cb: () => void) => { + if (event === "finish") { + setImmediate(cb) + } + }) + try { const result = await downloadSemble("/storage") @@ -577,8 +582,14 @@ describe("semble-downloader", () => { ;(fs.readFile as any).mockRejectedValue(new Error("ENOENT")) // Simulate successful download + mockWriteStream.on.mockImplementation((event: string, cb: () => void) => { + if (event === "finish") { + setImmediate(cb) + } + }) + // Archive cleanup fails but should not throw (only archive removal after extraction) - ;(fs.rm as any).mockRejectedValueOnce(new Error("archive cleanup failed")) + ;(fs.unlink as any).mockRejectedValue(new Error("unlink cleanup failed")) try { const result = await downloadSemble("/storage") @@ -606,6 +617,12 @@ describe("semble-downloader", () => { ;(fs.access as any).mockResolvedValue(undefined) // Simulate successful download + mockWriteStream.on.mockImplementation((event: string, cb: () => void) => { + if (event === "finish") { + setImmediate(cb) + } + }) + try { const result = await downloadSemble("/storage") @@ -624,7 +641,7 @@ describe("semble-downloader", () => { expect(https.get).toHaveBeenCalledWith(expect.stringContaining("v0.4.1"), expect.any(Function)) // Should write the new version file expect(fs.writeFile).toHaveBeenCalledWith( - path.join("/storage", "semble.new", ".semble-version"), + path.join("/storage", "semble", ".semble-version"), "v0.4.1", "utf-8", ) @@ -656,6 +673,12 @@ describe("semble-downloader", () => { ;(fs.access as any).mockResolvedValue(undefined) // Simulate successful download + mockWriteStream.on.mockImplementation((event: string, cb: () => void) => { + if (event === "finish") { + setImmediate(cb) + } + }) + try { const result = await downloadSemble("/storage") @@ -677,23 +700,19 @@ describe("semble-downloader", () => { ) // The stale archive is removed before the fresh download to guarantee // a clean package is verified against the new checksum. - expect(fs.rm).toHaveBeenCalledWith(versionedArchive, { force: true }) + expect(fs.unlink).toHaveBeenCalledWith(versionedArchive) // The prior-version archive (v0.4.0-*) is swept by cleanupStaleArchives // after a successful install, so a version upgrade doesn't accumulate // orphaned packages on disk. - expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz"), { - force: true, - }) + expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz")) // The legacy unversioned archive (pre-v0.4.0 cache layout) is also // swept, covering the v0.3.1 → v0.4.1 upgrade path. - expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz"), { - force: true, - }) + expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz")) // Unrelated files in the storage dir must not be touched. - expect(fs.rm).not.toHaveBeenCalledWith(path.join("/storage", "unrelated-file.txt"), expect.anything()) + expect(fs.unlink).not.toHaveBeenCalledWith(path.join("/storage", "unrelated-file.txt")) // The new version file is recorded expect(fs.writeFile).toHaveBeenCalledWith( - path.join("/storage", "semble.new", ".semble-version"), + path.join("/storage", "semble", ".semble-version"), "v0.4.1", "utf-8", ) @@ -750,6 +769,12 @@ describe("semble-downloader", () => { }) // Simulate successful download + mockWriteStream.on.mockImplementation((event: string, cb: () => void) => { + if (event === "finish") { + setImmediate(cb) + } + }) + try { const result = await downloadSemble("/storage") @@ -764,7 +789,7 @@ describe("semble-downloader", () => { ) // Should write version file again expect(fs.writeFile).toHaveBeenCalledWith( - path.join("/storage", "semble.new", ".semble-version"), + path.join("/storage", "semble", ".semble-version"), "v0.4.1", "utf-8", ) @@ -787,6 +812,12 @@ describe("semble-downloader", () => { ;(fs.access as any).mockResolvedValue(undefined) // Simulate successful download + mockWriteStream.on.mockImplementation((event: string, cb: () => void) => { + if (event === "finish") { + setImmediate(cb) + } + }) + try { const result = await downloadSemble("/storage") @@ -800,7 +831,7 @@ describe("semble-downloader", () => { ) // Should write version file expect(fs.writeFile).toHaveBeenCalledWith( - path.join("/storage", "semble.new", ".semble-version"), + path.join("/storage", "semble", ".semble-version"), "v0.4.1", "utf-8", ) @@ -825,6 +856,12 @@ describe("semble-downloader", () => { // readdir rejects — exercises the catch block in cleanupStaleArchives ;(fs.readdir as any).mockRejectedValue(new Error("EACCES")) + mockWriteStream.on.mockImplementation((event: string, cb: () => void) => { + if (event === "finish") { + setImmediate(cb) + } + }) + try { const result = await downloadSemble("/storage") @@ -855,27 +892,29 @@ describe("semble-downloader", () => { "unrelated.txt", ]) + mockWriteStream.on.mockImplementation((event: string, cb: () => void) => { + if (event === "finish") { + setImmediate(cb) + } + }) + try { await downloadSemble("/storage") const currentArchive = path.join("/storage", "v0.4.1-semble-linux-x64-fast.tar.gz") // Stale versioned + legacy unversioned archives are swept - expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz"), { - force: true, - }) - expect(fs.rm).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz"), { - force: true, - }) + expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "v0.4.0-semble-linux-x64-fast.tar.gz")) + expect(fs.unlink).toHaveBeenCalledWith(path.join("/storage", "semble-linux-x64-fast.tar.gz")) // The current archive is never swept by cleanupStaleArchives (it is // excluded by the currentArchivePath guard). It is unlinked only by // the pre-download partial-archive cleanup and the post-install // archive cleanup steps. unrelated.txt is never touched. - expect(fs.rm).not.toHaveBeenCalledWith(path.join("/storage", "unrelated.txt"), expect.anything()) + expect(fs.unlink).not.toHaveBeenCalledWith(path.join("/storage", "unrelated.txt")) // Sanity: the current archive path is never passed to the stale sweep. // It is unlinked exactly twice (pre-download cleanup + post-install // archive cleanup), never via cleanupStaleArchives. - const currentRemovals = (fs.rm as any).mock.calls.filter((c: any[]) => c[0] === currentArchive) - expect(currentRemovals.length).toBe(2) + const currentUnlinks = (fs.unlink as any).mock.calls.filter((c: any[]) => c[0] === currentArchive) + expect(currentUnlinks.length).toBe(2) } finally { if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform) if (originalArch) Object.defineProperty(process, "arch", originalArch) diff --git a/src/services/code-index/semble/semble-downloader.ts b/src/services/code-index/semble/semble-downloader.ts index 5f68ffe58c..fc8a8e2a33 100644 --- a/src/services/code-index/semble/semble-downloader.ts +++ b/src/services/code-index/semble/semble-downloader.ts @@ -1,9 +1,10 @@ import * as fs from "fs/promises" import * as path from "path" - -import { extractTarGzArchive, extractZipArchive } from "../../managed-binary/archive" -import { downloadBinaryFile, verifySha256Checksum } from "../../managed-binary/download" -import { ensureManagedBinaryInstalled } from "../../managed-binary/install" +import * as https from "https" +import { createWriteStream } from "fs" +import { createHash } from "crypto" +import { createReadStream } from "fs" +import { spawn } from "child_process" /** * Supported platform/arch combinations for the semble standalone executable. @@ -26,7 +27,6 @@ const SEMBLE_ARCHIVES: Record = { export const SEMBLE_VERSION = "v0.4.1" const DOWNLOAD_BASE_URL = `https://github.com/Zoo-Code-Org/sembleexec/releases/download/${SEMBLE_VERSION}` const VERSION_FILE = ".semble-version" -const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024 /** * SHA-256 checksums for each platform archive at SEMBLE_VERSION. @@ -47,14 +47,19 @@ export const SEMBLE_SHA256: Record = { * Throws if the checksum does not match. */ export async function verifyChecksum(filePath: string, expected: string): Promise { - await verifySha256Checksum( - filePath, - expected, - (actual) => - new Error( - `Checksum mismatch for ${path.basename(filePath)}: expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…`, - ), - ) + const hash = createHash("sha256") + await new Promise((resolve, reject) => { + const stream = createReadStream(filePath) + stream.on("data", (chunk) => hash.update(chunk)) + stream.on("end", resolve) + stream.on("error", reject) + }) + const actual = hash.digest("hex") + if (actual !== expected) { + throw new Error( + `Checksum mismatch for ${path.basename(filePath)}: expected ${expected.slice(0, 12)}…, got ${actual.slice(0, 12)}…`, + ) + } } /** @@ -82,6 +87,64 @@ function getArchiveInfo(platform?: string, arch?: string): { archive: string; bi return SEMBLE_ARCHIVES[`${p}-${a}`] } +/** + * Reads the locally installed version from the version metadata file. + * Returns undefined if no version file exists (first install or legacy). + */ +async function getInstalledVersion(storageDir: string): Promise { + try { + const versionPath = path.join(storageDir, "semble", VERSION_FILE) + const version = (await fs.readFile(versionPath, "utf-8")).trim() + return version || undefined + } catch { + return undefined + } +} + +/** + * Writes the version metadata file after a successful download. + */ +async function writeInstalledVersion(storageDir: string, version: string): Promise { + const versionPath = path.join(storageDir, "semble", VERSION_FILE) + await fs.writeFile(versionPath, version, "utf-8") +} + +/** + * Best-effort removal of archive files left over from previous semble versions. + * + * Because the local archive cache path is version-prefixed (see `downloadSemble`), + * upgrading SEMBLE_VERSION leaves the prior version's archive orphaned on disk. + * This sweeps those stale packages so a version upgrade doesn't accumulate them. + * + * Matches both the version-prefixed cache names (`${version}-${archiveName}`, + * used since v0.4.0) and the legacy unversioned cache name (`${archiveName}`, + * used before v0.4.0), so a v0.3.1 → v0.4.1 upgrade also clears the legacy file. + * The current archive path is always preserved. + * + * Errors are swallowed since this is purely cosmetic cleanup. + */ +async function cleanupStaleArchives( + storageDir: string, + archiveName: string, + currentArchivePath: string, +): Promise { + try { + const entries = await fs.readdir(storageDir) + const suffix = `-${archiveName}` + await Promise.all( + entries + .filter( + (name) => + (name === archiveName || name.endsWith(suffix)) && + path.join(storageDir, name) !== currentArchivePath, + ) + .map((name) => fs.unlink(path.join(storageDir, name)).catch(() => {})), + ) + } catch { + // ignore — storage dir may not be listable yet + } +} + /** * Downloads and extracts the semble archive for the current platform. * @@ -101,43 +164,130 @@ export async function downloadSemble(storageDir: string): Promise - downloadBinaryFile(url, archivePath, { - name: "Semble", - trustedDomains: TRUSTED_DOWNLOAD_DOMAINS, - timeoutMs: 120_000, - maxBytes: MAX_ARCHIVE_BYTES, - }), - verifyArchive: (archivePath) => verifyChecksum(archivePath, expectedChecksum), - extractArchive: async (archivePath, stagingDir) => { - if (info.archive.endsWith(".tar.gz")) { - await extractTarGzArchive(archivePath, stagingDir) - } else if (info.archive.endsWith(".zip")) { - await extractZipArchive(archivePath, stagingDir) - } else { - throw new Error(`Unsupported semble archive format: ${info.archive}`) - } - }, - }) + try { + // Clean any leftover staging directory from a previous failed attempt + try { + await fs.rm(stagingDir, { recursive: true, force: true }) + } catch { + // ignore + } + + // Remove any stale/partial archive from a previous attempt so we always + // download a fresh package. This is critical immediately after a version + // upgrade, where a corrupt leftover would otherwise fail checksum + // verification against the new SEMBLE_SHA256 on first launch. + try { + await fs.unlink(archivePath) + } catch { + // ignore — may not exist + } + + await downloadFile(url, archivePath) + + // Verify archive integrity before extraction + const platformKey = `${process.platform}-${process.arch}` + const expectedChecksum = SEMBLE_SHA256[platformKey] + if (!expectedChecksum) { + throw new Error(`No checksum configured for platform ${platformKey} at ${SEMBLE_VERSION}`) + } + await verifyChecksum(archivePath, expectedChecksum) + + // Extract to staging directory + await fs.mkdir(stagingDir, { recursive: true }) + + if (info.archive.endsWith(".tar.gz")) { + await extractTarGz(archivePath, stagingDir) + } else if (info.archive.endsWith(".zip")) { + await extractZip(archivePath, stagingDir) + } + + // Make binary executable on unix platforms + if (process.platform !== "win32") { + await fs.chmod(stagedBinaryPath, 0o755) + } + + // Verify the staged binary exists before swapping + await fs.access(stagedBinaryPath) + + // Atomic swap: remove old installation, rename staging → final + try { + await fs.rm(extractDir, { recursive: true, force: true }) + } catch { + // ignore — may not exist on first install + } + await fs.rename(stagingDir, extractDir) + + // Record the installed version + await writeInstalledVersion(storageDir, SEMBLE_VERSION) + + // Clean up the archive file + try { + await fs.unlink(archivePath) + } catch { + // ignore cleanup errors + } - console.log(`[SembleDownloader] Successfully installed semble ${SEMBLE_VERSION} to ${result}`) - return result + // Best-effort: remove orphaned archives left by previous semble versions + // so a version upgrade doesn't accumulate stale packages on disk. + await cleanupStaleArchives(storageDir, info.archive, archivePath) + + console.log(`[SembleDownloader] Successfully installed semble ${SEMBLE_VERSION} to ${binaryPath}`) + return binaryPath + } catch (error: any) { + // Clean up partial download/staging — leave old installation intact + try { + await fs.unlink(archivePath) + } catch { + // ignore cleanup errors + } + try { + await fs.rm(stagingDir, { recursive: true, force: true }) + } catch { + // ignore cleanup errors + } + console.error(`[SembleDownloader] Failed to download semble: ${error?.message || error}`) + throw new Error(`Failed to download semble: ${error?.message || error}`) + } } /** @@ -159,8 +309,174 @@ export async function getSembleBinaryPath(storageDir: string): Promise { + return new Promise((resolve, reject) => { + const args = ["-xzf", archivePath, "-C", destDir, "--no-same-owner"] + // GNU tar: --no-overwrite-dir adds defense-in-depth against ../relative traversal. + // macOS bsdtar strips absolute paths by default. + if (process.platform === "linux") { + args.push("--no-overwrite-dir") + } + const child = spawn("tar", args, { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }) + + let stderr = "" + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString() + }) + + child.on("error", (err) => reject(err)) + child.on("close", (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`tar extraction failed (code ${code}): ${stderr.trim()}`)) + } + }) + }) +} + +/** + * Escapes a string for use inside a PowerShell single-quoted literal. + * In PowerShell, the only special character in a single-quoted string is the + * apostrophe itself, which is escaped by doubling it. + */ +function escapePowerShellLiteral(value: string): string { + return value.replace(/'/g, "''") +} + +/** + * Extracts a .zip archive into the destination directory. + * Uses PowerShell on Windows, unzip on other platforms. + */ +function extractZip(archivePath: string, destDir: string): Promise { + return new Promise((resolve, reject) => { + let child + + if (process.platform === "win32") { + child = spawn( + "powershell", + [ + "-NoProfile", + "-Command", + `Expand-Archive -Path '${escapePowerShellLiteral(archivePath)}' -DestinationPath '${escapePowerShellLiteral(destDir)}' -Force`, + ], + { shell: false, stdio: ["ignore", "pipe", "pipe"] }, + ) + } else { + child = spawn("unzip", ["-o", archivePath, "-d", destDir], { + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }) + } + + let stderr = "" + child.stderr?.on("data", (data: Buffer) => { + stderr += data.toString() + }) + + child.on("error", (err) => reject(err)) + child.on("close", (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`zip extraction failed (code ${code}): ${stderr.trim()}`)) + } + }) + }) +} + /** * Trusted domains for following redirects during semble binary download. * GitHub releases redirect to objects.githubusercontent.com for the actual download. */ const TRUSTED_DOWNLOAD_DOMAINS = ["github.com", "objects.githubusercontent.com", "release-assets.githubusercontent.com"] + +/** + * Validates that a URL belongs to a trusted domain. + * Uses domain-boundary aware matching to prevent suffix-based bypasses + * (e.g. "evilgithub.com" does NOT match "github.com"). + */ +function isTrustedDownloadUrl(url: string): boolean { + try { + const parsed = new URL(url) + const h = parsed.hostname + return parsed.protocol === "https:" && TRUSTED_DOWNLOAD_DOMAINS.some((d) => h === d || h.endsWith("." + d)) + } catch { + return false + } +} + +/** + * Downloads a file from the given URL to the destination path. + * Follows redirects (GitHub releases use 302 redirects to CDN). + * Only follows redirects to trusted domains to prevent redirect-based attacks. + */ +function downloadFile(url: string, destPath: string, maxRedirects = 5): Promise { + return new Promise((resolve, reject) => { + if (maxRedirects <= 0) { + reject(new Error("Too many redirects")) + return + } + + const request = https.get(url, (response) => { + // Follow redirects + if ( + response.statusCode && + response.statusCode >= 300 && + response.statusCode < 400 && + response.headers.location + ) { + response.destroy() + const redirectUrl = response.headers.location + if (!isTrustedDownloadUrl(redirectUrl)) { + reject( + new Error( + `Redirect to untrusted domain blocked: ${redirectUrl}. Only ${TRUSTED_DOWNLOAD_DOMAINS.join(", ")} are allowed.`, + ), + ) + return + } + downloadFile(redirectUrl, destPath, maxRedirects - 1) + .then(resolve) + .catch(reject) + return + } + + if (response.statusCode !== 200) { + response.destroy() + reject(new Error(`HTTP ${response.statusCode}: Failed to download ${url}`)) + return + } + + const file = createWriteStream(destPath) + response.pipe(file) + + file.on("finish", () => { + file.close() + resolve() + }) + + file.on("error", (err) => { + file.close() + reject(err) + }) + }) + + request.on("error", reject) + request.on("timeout", () => { + request.destroy() + reject(new Error("Download timed out")) + }) + + // 2 minute timeout for download + request.setTimeout(120_000) + }) +} diff --git a/src/services/managed-binary/__tests__/archive.spec.ts b/src/services/managed-binary/__tests__/archive.spec.ts deleted file mode 100644 index cc06e85fd4..0000000000 --- a/src/services/managed-binary/__tests__/archive.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { EventEmitter } from "events" -import * as path from "path" -import { PassThrough } from "stream" - -import { spawn } from "child_process" - -import { - extractSingleFileTarXzArchive, - extractSingleFileZipArchive, - extractTarGzArchive, - extractTarXzArchive, - extractZipArchive, - runProcess, -} from "../archive" - -vi.mock("child_process", () => ({ spawn: vi.fn() })) - -const mockSpawn = vi.mocked(spawn) - -function createChild() { - return Object.assign(new EventEmitter(), { - stdout: new PassThrough(), - stderr: new PassThrough(), - kill: vi.fn(), - }) -} - -describe("managed binary archive utilities", () => { - beforeEach(() => mockSpawn.mockReset()) - - it("runs processes without a shell and returns their output", async () => { - const child = createChild() - mockSpawn.mockReturnValue(child as unknown as ReturnType) - const processResult = runProcess("tool", ["--version"]) - child.stdout.write("1.2.3") - child.emit("close", 0) - - await expect(processResult).resolves.toEqual({ stdout: "1.2.3", stderr: "" }) - expect(mockSpawn).toHaveBeenCalledWith("tool", ["--version"], { - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }) - }) - - it("kills a process that exceeds its timeout", async () => { - vi.useFakeTimers() - try { - const child = createChild() - mockSpawn.mockReturnValue(child as unknown as ReturnType) - const processResult = runProcess("tool", [], 100) - const assertion = expect(processResult).rejects.toThrow("tool timed out") - - await vi.advanceTimersByTimeAsync(100) - await assertion - expect(child.kill).toHaveBeenCalledWith("SIGKILL") - } finally { - vi.useRealTimers() - } - }) - - it("extracts tar.gz archives with hardened flags", async () => { - const child = createChild() - mockSpawn.mockReturnValue(child as unknown as ReturnType) - const extraction = extractTarGzArchive("/tmp/archive.tar.gz", "/tmp/output") - child.emit("close", 0) - await extraction - - expect(mockSpawn).toHaveBeenCalledWith( - "tar", - expect.arrayContaining(["-xzf", "/tmp/archive.tar.gz", "-C", "/tmp/output", "--no-same-owner"]), - expect.objectContaining({ shell: false }), - ) - }) - - it("extracts tar.xz archives with hardened flags", async () => { - const child = createChild() - mockSpawn.mockReturnValue(child as unknown as ReturnType) - const extraction = extractTarXzArchive("/tmp/archive.tar.xz", "/tmp/output") - child.emit("close", 0) - await extraction - - const expectedArgs = ["-xJf", "/tmp/archive.tar.xz", "-C", "/tmp/output", "--no-same-owner"] - if (process.platform === "linux") expectedArgs.push("--no-overwrite-dir") - expect(mockSpawn).toHaveBeenCalledWith("tar", expectedArgs, { - shell: false, - stdio: ["ignore", "pipe", "pipe"], - }) - }) - - it("extracts ZIP archives with platform-safe process arguments", async () => { - const child = createChild() - mockSpawn.mockReturnValue(child as unknown as ReturnType) - const extraction = extractZipArchive("/tmp/archive.zip", "/tmp/output") - child.emit("close", 0) - await extraction - - if (process.platform === "win32") { - expect(mockSpawn).toHaveBeenCalledWith( - "powershell", - ["-NoProfile", "-NonInteractive", "-Command", expect.any(String), "/tmp/archive.zip", "/tmp/output"], - expect.objectContaining({ shell: false }), - ) - } else { - expect(mockSpawn).toHaveBeenCalledWith( - "unzip", - ["-o", "/tmp/archive.zip", "-d", "/tmp/output"], - expect.objectContaining({ shell: false }), - ) - } - }) - - it("validates a single-file tar.xz layout before extraction", async () => { - const listing = createChild() - const extraction = createChild() - mockSpawn.mockReturnValueOnce(listing as unknown as ReturnType) - mockSpawn.mockReturnValueOnce(extraction as unknown as ReturnType) - const result = extractSingleFileTarXzArchive("/tmp/archive.tar.xz", "/tmp/output", "binary", "Tool") - listing.stdout.write("-rwxr-xr-x user/group 1 2026-01-01 00:00 ./binary\n") - listing.emit("close", 0) - await new Promise((resolve) => setImmediate(resolve)) - extraction.emit("close", 0) - await result - - expect(mockSpawn).toHaveBeenNthCalledWith( - 2, - "tar", - [ - "-xJf", - "/tmp/archive.tar.xz", - "-C", - "/tmp/output", - "--no-same-owner", - ...(process.platform === "linux" ? ["--no-overwrite-dir"] : []), - "./binary", - ], - expect.any(Object), - ) - }) - - it.each([ - ["-rwxr-xr-x user/group 1 2026-01-01 00:00 ./other\n", "an unexpected filename"], - [ - "-rwxr-xr-x user/group 1 2026-01-01 00:00 ./binary\n-rwxr-xr-x user/group 1 2026-01-01 00:00 ./other\n", - "multiple entries", - ], - ["lrwxrwxrwx user/group 0 2026-01-01 00:00 ./binary\n", "a non-regular entry"], - ])("rejects a tar.xz archive with %s", async (listingOutput) => { - const listing = createChild() - mockSpawn.mockReturnValue(listing as unknown as ReturnType) - const result = extractSingleFileTarXzArchive("/tmp/archive.tar.xz", "/tmp/output", "binary", "Tool") - listing.stdout.write(listingOutput) - listing.emit("close", 0) - - await expect(result).rejects.toThrow("Tool archive has an unexpected layout") - }) - - it("builds a single-entry-validated PowerShell ZIP extraction", async () => { - const child = createChild() - mockSpawn.mockReturnValue(child as unknown as ReturnType) - const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform") - Object.defineProperty(process, "platform", { value: "win32", configurable: true }) - try { - const extraction = extractSingleFileZipArchive("C:\\archive.zip", "C:\\output", "binary.exe", "Tool") - child.emit("close", 0) - await extraction - - const args = mockSpawn.mock.calls[0][1] - const script = args[3] - expect(script).toContain("$entries.Count -ne 1") - expect(script).not.toContain("C:\\archive.zip") - expect(args.slice(4)).toEqual([ - "C:\\archive.zip", - path.join("C:\\output", "binary.exe"), - "binary.exe", - "Tool", - ]) - } finally { - if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform) - } - }) -}) diff --git a/src/services/managed-binary/__tests__/download.spec.ts b/src/services/managed-binary/__tests__/download.spec.ts deleted file mode 100644 index 32543ce8ed..0000000000 --- a/src/services/managed-binary/__tests__/download.spec.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { EventEmitter } from "events" -import { createReadStream, createWriteStream } from "fs" -import { get } from "https" -import type { IncomingMessage, RequestOptions } from "http" - -import { - assertSizeWithinLimit, - downloadBinaryFile, - isTrustedHttpsUrl, - resolveTrustedRedirect, - verifySha256Checksum, -} from "../download" - -vi.mock("crypto", () => ({ - createHash: vi.fn(() => ({ - update: vi.fn(), - digest: vi.fn(() => "actual-checksum"), - })), -})) - -vi.mock("fs", () => ({ - createReadStream: vi.fn(), - createWriteStream: vi.fn(), -})) - -vi.mock("https", () => ({ get: vi.fn() })) - -const trustedDomains = ["github.com", "objects.githubusercontent.com"] -const mockGet = vi.mocked(get) -const mockCreateReadStream = vi.mocked(createReadStream) -const mockCreateWriteStream = vi.mocked(createWriteStream) - -function createRequest(): EventEmitter & { setTimeout: ReturnType; destroy: ReturnType } { - return Object.assign(new EventEmitter(), { setTimeout: vi.fn(), destroy: vi.fn() }) -} - -function createResponse(statusCode: number, headers: Record = {}) { - return Object.assign(new EventEmitter(), { - statusCode, - headers, - destroy: vi.fn(), - pipe: vi.fn(), - unpipe: vi.fn(), - }) -} - -describe("managed binary downloads", () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it("validates HTTPS URLs against hostname boundaries", () => { - expect(isTrustedHttpsUrl("https://github.com/release", trustedDomains)).toBe(true) - expect(isTrustedHttpsUrl("https://cdn.objects.githubusercontent.com/release", trustedDomains)).toBe(true) - expect(isTrustedHttpsUrl("http://github.com/release", trustedDomains)).toBe(false) - expect(isTrustedHttpsUrl("https://evilgithub.com/release", trustedDomains)).toBe(false) - expect(isTrustedHttpsUrl("not a URL", trustedDomains)).toBe(false) - }) - - it("resolves relative redirects and rejects unsafe or exhausted redirects", () => { - const options = { name: "Example", trustedDomains } - expect(resolveTrustedRedirect("https://github.com/release", "/asset", 5, options)).toBe( - "https://github.com/asset", - ) - expect(() => - resolveTrustedRedirect("https://github.com/release", "https://example.com/asset", 5, options), - ).toThrow("Example download redirected to an untrusted host") - expect(() => resolveTrustedRedirect("https://github.com/release", "/asset", 0, options)).toThrow( - "Too many Example download redirects", - ) - expect(() => resolveTrustedRedirect("https://github.com/release", undefined, 5, options)).toThrow( - "Example download redirect is missing a Location header", - ) - }) - - it("distinguishes an untrusted initial URL from an unsafe redirect", async () => { - await expect( - downloadBinaryFile("http://github.com/release", "/tmp/archive", { - name: "Example", - trustedDomains, - timeoutMs: 1_000, - }), - ).rejects.toThrow("Example download URL is not a trusted HTTPS host") - expect(mockGet).not.toHaveBeenCalled() - }) - - it("enforces configurable archive size limits", () => { - expect(() => assertSizeWithinLimit(10, 10, "Example")).not.toThrow() - expect(() => assertSizeWithinLimit(11, 10, "Example")).toThrow( - "Example archive exceeds the download size limit", - ) - }) - - it("reports the actual SHA-256 value through a caller-defined mismatch error", async () => { - const input = new EventEmitter() - mockCreateReadStream.mockReturnValue(input as ReturnType) - const verification = verifySha256Checksum( - "/tmp/archive", - "expected-checksum", - (actual) => new Error(`checksum mismatch: ${actual}`), - ) - input.emit("data", Buffer.from("archive")) - input.emit("end") - await expect(verification).rejects.toThrow("checksum mismatch: actual-checksum") - }) - - it("accepts a matching SHA-256 checksum", async () => { - const input = new EventEmitter() - mockCreateReadStream.mockReturnValue(input as ReturnType) - const verification = verifySha256Checksum("/tmp/archive", "actual-checksum", () => new Error("should not fail")) - input.emit("data", Buffer.from("archive")) - input.emit("end") - - await expect(verification).resolves.toBeUndefined() - }) - - it("follows a trusted redirect and applies destination security options", async () => { - const requestOne = createRequest() - const requestTwo = createRequest() - const redirect = createResponse(302, { location: "/asset" }) - const success = createResponse(200, { "content-length": "7" }) - const output = Object.assign(new EventEmitter(), { close: vi.fn() }) - mockCreateWriteStream.mockReturnValue(output as unknown as ReturnType) - - mockGet - .mockImplementationOnce((_url, optionsOrCallback, optionalCallback) => { - const callback = - typeof optionsOrCallback === "function" - ? optionsOrCallback - : (optionalCallback as ((response: IncomingMessage) => void) | undefined) - setImmediate(() => callback?.(redirect as unknown as IncomingMessage)) - return requestOne as unknown as ReturnType - }) - .mockImplementationOnce((_url, optionsOrCallback, optionalCallback) => { - const callback = - typeof optionsOrCallback === "function" - ? optionsOrCallback - : (optionalCallback as ((response: IncomingMessage) => void) | undefined) - setImmediate(() => callback?.(success as unknown as IncomingMessage)) - return requestTwo as unknown as ReturnType - }) - - const download = downloadBinaryFile("https://github.com/release", "/tmp/archive", { - name: "Example", - trustedDomains, - timeoutMs: 1_000, - maxBytes: 10, - exclusiveDestination: true, - }) - await new Promise((resolve) => setImmediate(resolve)) - await new Promise((resolve) => setImmediate(resolve)) - let resolved = false - void download.then(() => { - resolved = true - }) - output.emit("finish") - await Promise.resolve() - expect(resolved).toBe(false) - output.emit("close") - await download - - expect(mockGet).toHaveBeenNthCalledWith(2, "https://github.com/asset", expect.any(Function)) - expect(mockCreateWriteStream).toHaveBeenCalledWith("/tmp/archive", { flags: "wx", mode: 0o600 }) - expect(requestOne.setTimeout).toHaveBeenCalledWith(1_000, expect.any(Function)) - expect(requestTwo.setTimeout).toHaveBeenCalledWith(1_000, expect.any(Function)) - }) - - it("rejects an oversized declared response before opening the destination", async () => { - const request = createRequest() - const response = createResponse(200, { "content-length": "11" }) - mockGet.mockImplementation( - ( - _url: string | URL, - optionsOrCallback: RequestOptions | ((response: IncomingMessage) => void), - optionalCallback?: (response: IncomingMessage) => void, - ) => { - const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback - setImmediate(() => callback?.(response as unknown as IncomingMessage)) - return request as unknown as ReturnType - }, - ) - - await expect( - downloadBinaryFile("https://github.com/release", "/tmp/archive", { - name: "Example", - trustedDomains, - timeoutMs: 1_000, - maxBytes: 10, - }), - ).rejects.toThrow("Example archive exceeds the download size limit") - expect(mockCreateWriteStream).not.toHaveBeenCalled() - }) - - it("unpipes and destroys the destination when streamed bytes exceed the limit", async () => { - const request = createRequest() - const response = createResponse(200) - const output = Object.assign(new EventEmitter(), { close: vi.fn(), destroy: vi.fn() }) - mockCreateWriteStream.mockReturnValue(output as unknown as ReturnType) - mockGet.mockImplementation((_url, optionsOrCallback, optionalCallback) => { - const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : optionalCallback - setImmediate(() => callback?.(response as unknown as IncomingMessage)) - return request as unknown as ReturnType - }) - - const download = downloadBinaryFile("https://github.com/release", "/tmp/archive", { - name: "Example", - trustedDomains, - timeoutMs: 1_000, - maxBytes: 10, - }) - await new Promise((resolve) => setImmediate(resolve)) - response.emit("data", Buffer.alloc(11)) - - await expect(download).rejects.toThrow("Example archive exceeds the download size limit") - expect(response.unpipe).toHaveBeenCalledWith(output) - expect(output.destroy).toHaveBeenCalledOnce() - expect(response.destroy).toHaveBeenCalledOnce() - expect(request.destroy).toHaveBeenCalledOnce() - }) -}) diff --git a/src/services/managed-binary/__tests__/install.spec.ts b/src/services/managed-binary/__tests__/install.spec.ts deleted file mode 100644 index bf903264f8..0000000000 --- a/src/services/managed-binary/__tests__/install.spec.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { access, chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "fs/promises" -import { tmpdir } from "os" -import * as path from "path" - -import { ensureManagedBinaryInstalled, getManagedBinaryPaths, type ManagedBinaryInstallOptions } from "../install" - -describe("managed binary installation", () => { - let tempDir: string - - beforeEach(async () => { - tempDir = await mkdtemp(path.join(tmpdir(), "managed-binary-")) - }) - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }) - }) - - function createOptions(overrides: Partial = {}): ManagedBinaryInstallOptions { - return { - storageDir: tempDir, - id: "example", - version: "v1.2.3", - versionFile: ".example-version", - archiveName: "example.tar.gz", - binaryName: "example", - errorPrefix: "Failed to install example", - download: vi.fn(), - verifyArchive: vi.fn(), - extractArchive: vi.fn(), - ...overrides, - } - } - - it("derives one consistent mutable installation layout", () => { - expect(getManagedBinaryPaths(createOptions())).toEqual({ - installRoot: path.join(tempDir, "example"), - binaryPath: path.join(tempDir, "example", "example"), - versionPath: path.join(tempDir, "example", ".example-version"), - stagingDir: path.join(tempDir, "example.new"), - stagedBinaryPath: path.join(tempDir, "example.new", "example"), - archivePath: path.join(tempDir, "v1.2.3-example.tar.gz"), - }) - }) - - it("reuses a current executable without invoking update callbacks", async () => { - const options = createOptions() - const paths = getManagedBinaryPaths(options) - await mkdir(paths.installRoot, { recursive: true }) - await writeFile(paths.binaryPath, "current") - await writeFile(paths.versionPath, options.version) - if (process.platform !== "win32") await chmod(paths.binaryPath, 0o600) - - await expect(ensureManagedBinaryInstalled(options)).resolves.toBe(paths.binaryPath) - expect(options.download).not.toHaveBeenCalled() - }) - - it("deduplicates concurrent installations", async () => { - let finishDownload: (() => void) | undefined - const download = vi.fn(() => new Promise((resolve) => (finishDownload = resolve))) - const options = createOptions({ - download, - extractArchive: async (_archivePath, stagingDir) => { - await writeFile(path.join(stagingDir, "example"), "binary") - }, - }) - const first = ensureManagedBinaryInstalled(options) - const second = ensureManagedBinaryInstalled(options) - expect(first).toBe(second) - await vi.waitFor(() => expect(download).toHaveBeenCalledOnce()) - finishDownload?.() - await expect(Promise.all([first, second])).resolves.toEqual([ - getManagedBinaryPaths(options).binaryPath, - getManagedBinaryPaths(options).binaryPath, - ]) - expect(download).toHaveBeenCalledOnce() - }) - - it("coordinates update, metadata promotion, and cleanup", async () => { - const calls: string[] = [] - const options = createOptions({ - download: async (archivePath) => { - calls.push("download") - await writeFile(archivePath, "archive") - }, - verifyArchive: async () => { - calls.push("verify") - }, - extractArchive: async (_archivePath, stagingDir) => { - calls.push("extract") - await writeFile(path.join(stagingDir, "example"), "binary") - }, - validateBinary: async () => { - calls.push("validate") - }, - }) - const paths = getManagedBinaryPaths(options) - - await expect(ensureManagedBinaryInstalled(options)).resolves.toBe(paths.binaryPath) - expect(calls).toEqual(["download", "verify", "extract", "validate"]) - expect(await readFile(paths.binaryPath, "utf8")).toBe("binary") - expect(await readFile(paths.versionPath, "utf8")).toBe(options.version) - await expect(access(paths.archivePath)).rejects.toThrow() - await expect(access(paths.stagingDir)).rejects.toThrow() - await expect(access(path.join(tempDir, ".example.install.lock"))).rejects.toThrow() - }) - - it("cleans up partial artifacts when downloading fails", async () => { - const options = createOptions({ - download: async (archivePath) => { - await writeFile(archivePath, "partial") - throw new Error("network failure") - }, - }) - const paths = getManagedBinaryPaths(options) - - await expect(ensureManagedBinaryInstalled(options)).rejects.toThrow( - "Failed to install example: network failure", - ) - await expect(access(paths.archivePath)).rejects.toThrow() - await expect(access(paths.stagingDir)).rejects.toThrow() - await expect(access(paths.binaryPath)).rejects.toThrow() - }) - - it("removes stale versioned archives without touching unrelated files", async () => { - const options = createOptions({ - download: async (archivePath) => writeFile(archivePath, "archive"), - extractArchive: async (_archivePath, stagingDir) => { - await writeFile(path.join(stagingDir, "example"), "binary") - }, - }) - const staleArchive = path.join(tempDir, "v1.2.2-example.tar.gz") - const unrelated = path.join(tempDir, "notes.txt") - await writeFile(staleArchive, "stale") - await writeFile(unrelated, "keep") - - await ensureManagedBinaryInstalled(options) - - await expect(access(staleArchive)).rejects.toThrow() - await expect(readFile(unrelated, "utf8")).resolves.toBe("keep") - }) -}) diff --git a/src/services/managed-binary/archive.ts b/src/services/managed-binary/archive.ts deleted file mode 100644 index 674fe93ebb..0000000000 --- a/src/services/managed-binary/archive.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { spawn } from "child_process" -import * as path from "path" - -export interface ProcessResult { - stdout: string - stderr: string -} - -export function runProcess(executable: string, args: string[], timeoutMs = 30_000): Promise { - return new Promise((resolve, reject) => { - const child = spawn(executable, args, { shell: false, stdio: ["ignore", "pipe", "pipe"] }) - let stdout = "" - let stderr = "" - const timer = setTimeout(() => { - child.kill("SIGKILL") - reject(new Error(`${path.basename(executable)} timed out`)) - }, timeoutMs) - child.stdout?.on("data", (chunk: Buffer) => (stdout += chunk.toString())) - child.stderr?.on("data", (chunk: Buffer) => (stderr += chunk.toString())) - child.on("error", (error) => { - clearTimeout(timer) - reject(error) - }) - child.on("close", (code) => { - clearTimeout(timer) - if (code === 0) { - resolve({ stdout, stderr }) - } else { - reject(new Error(stderr.trim() || `Process exited with code ${code}`)) - } - }) - }) -} - -export async function extractTarGzArchive(archivePath: string, destination: string): Promise { - const args = ["-xzf", archivePath, "-C", destination, "--no-same-owner"] - if (process.platform === "linux") { - args.push("--no-overwrite-dir") - } - await runProcess("tar", args) -} - -export async function extractTarXzArchive(archivePath: string, destination: string): Promise { - const args = ["-xJf", archivePath, "-C", destination, "--no-same-owner"] - if (process.platform === "linux") { - args.push("--no-overwrite-dir") - } - await runProcess("tar", args) -} - -export async function extractZipArchive(archivePath: string, destination: string): Promise { - if (process.platform === "win32") { - await runProcess("powershell", [ - "-NoProfile", - "-NonInteractive", - "-Command", - "$archivePath = $args[0]; $destination = $args[1]; Expand-Archive -LiteralPath $archivePath -DestinationPath $destination -Force", - archivePath, - destination, - ]) - return - } - - await runProcess("unzip", ["-o", archivePath, "-d", destination]) -} - -export async function extractSingleFileZipArchive( - archivePath: string, - destination: string, - expectedFile: string, - archiveName: string, -): Promise { - if (process.platform !== "win32") { - throw new Error("Single-file ZIP extraction is only supported on Windows") - } - - const script = [ - "$ErrorActionPreference = 'Stop'", - "$archivePath = $args[0]", - "$outputPath = $args[1]", - "$expectedFile = $args[2]", - "$archiveName = $args[3]", - "Add-Type -AssemblyName System.IO.Compression.FileSystem", - "$archive = [System.IO.Compression.ZipFile]::OpenRead($archivePath)", - "try {", - " $entries = @($archive.Entries | Where-Object { -not [string]::IsNullOrEmpty($_.Name) })", - ' if ($entries.Count -ne 1 -or $entries[0].FullName -ne $expectedFile) { throw "$archiveName archive has an unexpected layout" }', - " [System.IO.Compression.ZipFileExtensions]::ExtractToFile($entries[0], $outputPath, $false)", - "} finally { $archive.Dispose() }", - ].join("; ") - - await runProcess("powershell", [ - "-NoProfile", - "-NonInteractive", - "-Command", - script, - archivePath, - path.join(destination, expectedFile), - expectedFile, - archiveName, - ]) -} - -export async function extractSingleFileTarXzArchive( - archivePath: string, - destination: string, - expectedFile: string, - archiveName: string, -): Promise { - const listing = await runProcess("tar", ["-tvJf", archivePath]) - const entries = listing.stdout - .split(/\r?\n/) - .map((entry) => entry.trim()) - .filter(Boolean) - if (entries.length !== 1) { - throw new Error(`${archiveName} archive has an unexpected layout`) - } - const archiveEntry = entries[0] - const entryName = archiveEntry?.split(/\s+/).at(-1) - if ( - !archiveEntry || - !entryName || - !archiveEntry.startsWith("-") || - entryName.replace(/^\.\//, "") !== expectedFile - ) { - throw new Error(`${archiveName} archive has an unexpected layout`) - } - - const args = ["-xJf", archivePath, "-C", destination, "--no-same-owner"] - if (process.platform === "linux") { - args.push("--no-overwrite-dir") - } - args.push(entryName) - await runProcess("tar", args) -} diff --git a/src/services/managed-binary/download.ts b/src/services/managed-binary/download.ts deleted file mode 100644 index 78c21da1ca..0000000000 --- a/src/services/managed-binary/download.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { createHash } from "crypto" -import { createReadStream, createWriteStream } from "fs" -import * as https from "https" - -export interface BinaryDownloadOptions { - name: string - trustedDomains: readonly string[] - timeoutMs: number - maxBytes?: number - maxRedirects?: number - exclusiveDestination?: boolean -} - -export function isTrustedHttpsUrl(url: string, trustedDomains: readonly string[]): boolean { - try { - const parsed = new URL(url) - return ( - parsed.protocol === "https:" && - trustedDomains.some((domain) => parsed.hostname === domain || parsed.hostname.endsWith(`.${domain}`)) - ) - } catch { - return false - } -} - -export function resolveTrustedRedirect( - url: string, - location: string | undefined, - redirectsRemaining: number, - options: Pick, -): string { - if (redirectsRemaining <= 0) { - throw new Error(`Too many ${options.name} download redirects`) - } - if (!location) { - throw new Error(`${options.name} download redirect is missing a Location header`) - } - - const nextUrl = new URL(location, url).toString() - if (!isTrustedHttpsUrl(nextUrl, options.trustedDomains)) { - throw new Error(`${options.name} download redirected to an untrusted host (untrusted domain)`) - } - - return nextUrl -} - -export function assertSizeWithinLimit(size: number, maxBytes: number, name: string): void { - if (size > maxBytes) { - throw new Error(`${name} archive exceeds the download size limit`) - } -} - -export async function verifySha256Checksum( - filePath: string, - expected: string, - createMismatchError: (actual: string) => Error, -): Promise { - const hash = createHash("sha256") - await new Promise((resolve, reject) => { - const input = createReadStream(filePath) - input.on("data", (chunk) => hash.update(chunk)) - input.on("end", resolve) - input.on("error", reject) - }) - - const actual = hash.digest("hex") - if (actual !== expected) { - throw createMismatchError(actual) - } -} - -export function downloadBinaryFile(url: string, destination: string, options: BinaryDownloadOptions): Promise { - return downloadBinaryFileWithRedirects(url, destination, options, options.maxRedirects ?? 5) -} - -function downloadBinaryFileWithRedirects( - url: string, - destination: string, - options: BinaryDownloadOptions, - redirectsRemaining: number, -): Promise { - return new Promise((resolve, reject) => { - if (!isTrustedHttpsUrl(url, options.trustedDomains)) { - reject(new Error(`${options.name} download URL is not a trusted HTTPS host (untrusted domain)`)) - return - } - - const request = https.get(url, (response) => { - const status = response.statusCode ?? 0 - if ([301, 302, 303, 307, 308].includes(status)) { - response.destroy() - let nextUrl: string - try { - nextUrl = resolveTrustedRedirect(url, response.headers.location, redirectsRemaining, options) - } catch (error) { - reject(error) - return - } - downloadBinaryFileWithRedirects(nextUrl, destination, options, redirectsRemaining - 1).then( - resolve, - reject, - ) - return - } - - if (status !== 200) { - response.destroy() - reject(new Error(`${options.name} download failed with HTTP ${status}`)) - return - } - - const declaredSize = Number(response.headers["content-length"] ?? 0) - if (options.maxBytes !== undefined) { - try { - assertSizeWithinLimit(declaredSize, options.maxBytes, options.name) - } catch (error) { - response.destroy() - reject(error) - return - } - } - - let received = 0 - const output = createWriteStream( - destination, - options.exclusiveDestination ? { flags: "wx", mode: 0o600 } : undefined, - ) - const abort = (error: Error) => { - response.unpipe(output) - output.destroy() - response.destroy() - request.destroy() - reject(error) - } - response.on("data", (chunk: Buffer) => { - received += chunk.length - if (options.maxBytes !== undefined) { - try { - assertSizeWithinLimit(received, options.maxBytes, options.name) - } catch (error) { - abort(error instanceof Error ? error : new Error(String(error))) - } - } - }) - response.on("error", abort) - response.pipe(output) - output.on("finish", () => output.close()) - output.on("close", resolve) - output.on("error", abort) - }) - - request.setTimeout(options.timeoutMs, () => request.destroy(new Error(`${options.name} download timed out`))) - request.on("error", reject) - }) -} diff --git a/src/services/managed-binary/install.ts b/src/services/managed-binary/install.ts deleted file mode 100644 index 4a6a548b0b..0000000000 --- a/src/services/managed-binary/install.ts +++ /dev/null @@ -1,148 +0,0 @@ -import * as fs from "fs/promises" -import * as path from "path" -import * as lockfile from "proper-lockfile" - -const installationPromises = new Map>() - -export interface ManagedBinaryInstallOptions { - storageDir: string - id: string - version: string - versionFile: string - archiveName: string - binaryName: string - download: (archivePath: string) => Promise - verifyArchive: (archivePath: string) => Promise - extractArchive: (archivePath: string, stagingDir: string) => Promise - validateBinary?: (stagedBinaryPath: string) => Promise - errorPrefix: string -} - -export interface ManagedBinaryPaths { - installRoot: string - binaryPath: string - versionPath: string - stagingDir: string - stagedBinaryPath: string - archivePath: string -} - -export function getManagedBinaryPaths( - options: Pick< - ManagedBinaryInstallOptions, - "storageDir" | "id" | "version" | "versionFile" | "archiveName" | "binaryName" - >, -): ManagedBinaryPaths { - const installRoot = path.join(options.storageDir, options.id) - const stagingDir = path.join(options.storageDir, `${options.id}.new`) - return { - installRoot, - binaryPath: path.join(installRoot, options.binaryName), - versionPath: path.join(installRoot, options.versionFile), - stagingDir, - stagedBinaryPath: path.join(stagingDir, options.binaryName), - archivePath: path.join(options.storageDir, `${options.version}-${options.archiveName}`), - } -} - -async function readInstalledVersion(versionPath: string): Promise { - try { - const version = (await fs.readFile(versionPath, "utf8")).trim() - return version || undefined - } catch { - return undefined - } -} - -async function makeExecutable(binaryPath: string): Promise { - await fs.access(binaryPath) - if (process.platform !== "win32") { - await fs.chmod(binaryPath, 0o755) - } -} - -async function cleanupStaleArchives(options: ManagedBinaryInstallOptions, currentArchivePath: string): Promise { - try { - const entries = await fs.readdir(options.storageDir) - const suffix = `-${options.archiveName}` - await Promise.all( - entries - .filter( - (name) => - (name === options.archiveName || name.endsWith(suffix)) && - path.join(options.storageDir, name) !== currentArchivePath, - ) - .map((name) => fs.rm(path.join(options.storageDir, name), { force: true }).catch(() => {})), - ) - } catch { - // Archive cleanup is cosmetic and must not invalidate a successful installation. - } -} - -async function installManagedBinary(options: ManagedBinaryInstallOptions): Promise { - const paths = getManagedBinaryPaths(options) - await fs.mkdir(options.storageDir, { recursive: true }) - const installedVersion = await readInstalledVersion(paths.versionPath) - if (installedVersion === options.version) { - try { - await makeExecutable(paths.binaryPath) - return paths.binaryPath - } catch { - // The installation is absent or incomplete, so rebuild it below. - } - } - - await fs.rm(paths.archivePath, { force: true }).catch(() => {}) - await fs.rm(paths.stagingDir, { recursive: true, force: true }).catch(() => {}) - await fs.mkdir(paths.stagingDir, { recursive: true }) - - try { - await options.download(paths.archivePath) - await options.verifyArchive(paths.archivePath) - await options.extractArchive(paths.archivePath, paths.stagingDir) - await makeExecutable(paths.stagedBinaryPath) - await options.validateBinary?.(paths.stagedBinaryPath) - await fs.writeFile(path.join(paths.stagingDir, options.versionFile), options.version, "utf-8") - await fs.rm(paths.installRoot, { recursive: true, force: true }) - await fs.rename(paths.stagingDir, paths.installRoot) - await cleanupStaleArchives(options, paths.archivePath) - return paths.binaryPath - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - throw new Error(`${options.errorPrefix}: ${message}`, { cause: error }) - } finally { - await fs.rm(paths.archivePath, { force: true }).catch(() => {}) - await fs.rm(paths.stagingDir, { recursive: true, force: true }).catch(() => {}) - } -} - -async function installManagedBinaryWithLock(options: ManagedBinaryInstallOptions): Promise { - await fs.mkdir(options.storageDir, { recursive: true }) - const lockTarget = path.join(options.storageDir, `.${options.id}.install`) - const release = await lockfile.lock(lockTarget, { - realpath: false, - stale: 5 * 60_000, - update: 30_000, - retries: { retries: 10, factor: 1.5, minTimeout: 100, maxTimeout: 1_000 }, - onCompromised: (error) => { - throw error - }, - }) - try { - return await installManagedBinary(options) - } finally { - await release() - } -} - -export function ensureManagedBinaryInstalled(options: ManagedBinaryInstallOptions): Promise { - const key = path.join(options.storageDir, options.id) - const existing = installationPromises.get(key) - if (existing) { - return existing - } - - const installation = installManagedBinaryWithLock(options).finally(() => installationPromises.delete(key)) - installationPromises.set(key, installation) - return installation -} diff --git a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx index 8b11c128c7..e095954255 100644 --- a/webview-ui/src/components/settings/providers/OpenAICompatible.tsx +++ b/webview-ui/src/components/settings/providers/OpenAICompatible.tsx @@ -162,6 +162,16 @@ export const OpenAICompatible = ({ onChange={handleInputChange("openAiStreamingEnabled", noTransform)}> {t("settings:modelInfo.enableStreaming")} +
+ + {t("settings:modelInfo.strictToolSchemas")} + +
+ {t("settings:modelInfo.strictToolSchemasDescription")} +
+
{{serviceName}}. Si no esteu segur de quin model triar, Zoo Code funciona millor amb {{defaultModelId}}. També podeu cercar \"free\" per a opcions gratuïtes actualment disponibles.", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 504bb56cba..f144b0bb29 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Kostenlos bis zu {{count}} Anfragen pro Minute. Danach hängt die Abrechnung von der Prompt-Größe ab.", "pricingDetails": "Weitere Informationen finden Sie unter Preisdetails.", "billingEstimate": "* Die Abrechnung ist eine Schätzung - die genauen Kosten hängen von der Prompt-Größe ab." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "Die Erweiterung ruft automatisch die neueste Liste der auf {{serviceName}} verfügbaren Modelle ab. Wenn du dir nicht sicher bist, welches Modell du wählen sollst, funktioniert Zoo Code am besten mit {{defaultModelId}}. Du kannst auch versuchen, nach \"kostenlos\" zu suchen, um die derzeit verfügbaren kostenlosen Optionen zu finden.", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 3d84065849..6398c4f919 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -1040,6 +1040,8 @@ "enableR1FormatTips": "Must be enabled when using R1 models such as QWQ to prevent 400 errors", "useAzure": "Use Azure", "azureApiVersion": "Set Azure API version", + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.", "gemini": { "freeRequests": "* Free up to {{count}} requests per minute. After that, billing depends on prompt size.", "pricingDetails": "For more info, see pricing details.", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index eba338005f..f90f972c91 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Gratis hasta {{count}} solicitudes por minuto. Después de eso, la facturación depende del tamaño del prompt.", "pricingDetails": "Para más información, consulte los detalles de precios.", "billingEstimate": "* La facturación es una estimación - el costo exacto depende del tamaño del prompt." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "La extensión obtiene automáticamente la lista más reciente de modelos disponibles en {{serviceName}}. Si no está seguro de qué modelo elegir, Zoo Code funciona mejor con {{defaultModelId}}. También puede buscar \"free\" para opciones sin costo actualmente disponibles.", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index d6e6e0e64e..a050c094d3 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Gratuit jusqu'à {{count}} requêtes par minute. Après cela, la facturation dépend de la taille du prompt.", "pricingDetails": "Pour plus d'informations, voir les détails de tarification.", "billingEstimate": "* La facturation est une estimation - le coût exact dépend de la taille du prompt." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "L'extension récupère automatiquement la liste la plus récente des modèles disponibles sur {{serviceName}}. Si vous ne savez pas quel modèle choisir, Zoo Code fonctionne mieux avec {{defaultModelId}}. Vous pouvez également rechercher \"free\" pour les options gratuites actuellement disponibles.", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index 3ff02125c5..6b4e28651e 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* प्रति मिनट {{count}} अनुरोधों तक मुफ्त। उसके बाद, बिलिंग प्रॉम्प्ट आकार पर निर्भर करती है।", "pricingDetails": "अधिक जानकारी के लिए, मूल्य निर्धारण विवरण देखें।", "billingEstimate": "* बिलिंग एक अनुमान है - सटीक लागत प्रॉम्प्ट आकार पर निर्भर करती है।" - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "एक्सटेंशन {{serviceName}} पर उपलब्ध मॉडलों की नवीनतम सूची स्वचालित रूप से प्राप्त करता है। यदि आप अनिश्चित हैं कि कौन सा मॉडल चुनना है, तो Zoo Code {{defaultModelId}} के साथ सबसे अच्छा काम करता है। आप वर्तमान में उपलब्ध निःशुल्क विकल्पों के लिए \"free\" भी खोज सकते हैं।", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index 6c4b91243f..b1d4251161 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Gratis hingga {{count}} permintaan per menit. Setelah itu, penagihan tergantung pada ukuran prompt.", "pricingDetails": "Untuk info lebih lanjut, lihat detail harga.", "billingEstimate": "* Penagihan adalah estimasi - biaya sebenarnya tergantung pada ukuran prompt." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "Ekstensi secara otomatis mengambil daftar model terbaru yang tersedia di {{serviceName}}. Jika kamu tidak yakin model mana yang harus dipilih, Zoo Code bekerja terbaik dengan {{defaultModelId}}. Kamu juga dapat mencoba mencari \"free\" untuk opsi tanpa biaya yang saat ini tersedia.", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index 8f7fd7e917..544fc94c6e 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Gratuito fino a {{count}} richieste al minuto. Dopo, la fatturazione dipende dalla dimensione del prompt.", "pricingDetails": "Per maggiori informazioni, vedi i dettagli sui prezzi.", "billingEstimate": "* La fatturazione è una stima - il costo esatto dipende dalle dimensioni del prompt." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "L'estensione recupera automaticamente l'elenco più recente dei modelli disponibili su {{serviceName}}. Se non sei sicuro di quale modello scegliere, Zoo Code funziona meglio con {{defaultModelId}}. Puoi anche cercare \"free\" per opzioni gratuite attualmente disponibili.", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index ab692a49f8..a730a90f01 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* 1分間あたり{{count}}リクエストまで無料。それ以降は、プロンプトサイズに応じて課金されます。", "pricingDetails": "詳細は価格情報をご覧ください。", "billingEstimate": "* 課金は見積もりです - 正確な費用はプロンプトのサイズによって異なります。" - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "拡張機能は{{serviceName}}で利用可能な最新のモデルリストを自動的に取得します。どのモデルを選ぶべきか迷っている場合、Zoo Codeは{{defaultModelId}}で最適に動作します。また、「free」で検索すると、現在利用可能な無料オプションを見つけることができます。", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 4e44f8170d..5b3dc9dfa4 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* 분당 {{count}}개의 요청까지 무료. 이후에는 프롬프트 크기에 따라 요금이 부과됩니다.", "pricingDetails": "자세한 내용은 가격 정보를 참조하세요.", "billingEstimate": "* 요금은 추정치입니다 - 정확한 비용은 프롬프트 크기에 따라 달라집니다." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "확장 프로그램은 {{serviceName}}에서 사용 가능한 최신 모델 목록을 자동으로 가져옵니다. 어떤 모델을 선택해야 할지 확실하지 않다면, Zoo Code는 {{defaultModelId}}로 가장 잘 작동합니다. 현재 사용 가능한 무료 옵션을 찾으려면 \"free\"를 검색해 볼 수도 있습니다.", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index d517df4bd0..6ee1688758 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Gratis tot {{count}} verzoeken per minuut. Daarna is de prijs afhankelijk van de promptgrootte.", "pricingDetails": "Zie prijsdetails voor meer info.", "billingEstimate": "* Facturering is een schatting - de exacte kosten hangen af van de promptgrootte." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "De extensie haalt automatisch de nieuwste lijst met modellen op van {{serviceName}}. Weet je niet welk model je moet kiezen? Zoo Code werkt het beste met {{defaultModelId}}. Je kunt ook zoeken op 'free' voor gratis opties die nu beschikbaar zijn.", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 3ef8e06c32..f031fe1c03 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Darmowe do {{count}} zapytań na minutę. Po tym, rozliczanie zależy od rozmiaru podpowiedzi.", "pricingDetails": "Więcej informacji znajdziesz w szczegółach cennika.", "billingEstimate": "* Rozliczenie jest szacunkowe - dokładny koszt zależy od rozmiaru podpowiedzi." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "Rozszerzenie automatycznie pobiera najnowszą listę modeli dostępnych w {{serviceName}}. Jeśli nie jesteś pewien, który model wybrać, Zoo Code działa najlepiej z {{defaultModelId}}. Możesz również wyszukać \"free\", aby znaleźć obecnie dostępne opcje bezpłatne.", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index 9c67418d16..55cc05fbfe 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Gratuito até {{count}} requisições por minuto. Depois disso, a cobrança depende do tamanho do prompt.", "pricingDetails": "Para mais informações, consulte os detalhes de preços.", "billingEstimate": "* A cobrança é uma estimativa - o custo exato depende do tamanho do prompt." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "A extensão busca automaticamente a lista mais recente de modelos disponíveis em {{serviceName}}. Se você não tem certeza sobre qual modelo escolher, o Zoo Code funciona melhor com {{defaultModelId}}. Você também pode pesquisar por \"free\" para encontrar opções gratuitas atualmente disponíveis.", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index 6d81073dbe..2bcbcec4a2 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Бесплатно до {{count}} запросов в минуту. Далее тарификация зависит от размера подсказки.", "pricingDetails": "Подробнее о ценах.", "billingEstimate": "* Счёт — приблизительный, точная стоимость зависит от размера подсказки." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "Расширение автоматически получает актуальный список моделей на {{serviceName}}. Если не уверены, что выбрать, Zoo Code лучше всего работает с {{defaultModelId}}. Также попробуйте поискать \"free\" для бесплатных вариантов.", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index 0456367efc..6c642bb4f3 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Dakikada {{count}} isteğe kadar ücretsiz. Bundan sonra, ücretlendirme istem boyutuna bağlıdır.", "pricingDetails": "Daha fazla bilgi için fiyatlandırma ayrıntılarına bakın.", "billingEstimate": "* Ücretlendirme bir tahmindir - kesin maliyet istem boyutuna bağlıdır." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "Uzantı {{serviceName}} üzerinde bulunan mevcut modellerin en güncel listesini otomatik olarak alır. Hangi modeli seçeceğinizden emin değilseniz, Zoo Code {{defaultModelId}} ile en iyi şekilde çalışır. Şu anda mevcut olan ücretsiz seçenekleri bulmak için \"free\" araması da yapabilirsiniz.", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 4beb3f7171..df1d78be46 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* Miễn phí đến {{count}} yêu cầu mỗi phút. Sau đó, thanh toán phụ thuộc vào kích thước lời nhắc.", "pricingDetails": "Để biết thêm thông tin, xem chi tiết giá.", "billingEstimate": "* Thanh toán là ước tính - chi phí chính xác phụ thuộc vào kích thước lời nhắc." - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "Tiện ích mở rộng tự động lấy danh sách mới nhất các mô hình có sẵn trên {{serviceName}}. Nếu bạn không chắc chắn nên chọn mô hình nào, Zoo Code hoạt động tốt nhất với {{defaultModelId}}. Bạn cũng có thể thử tìm kiếm \"free\" cho các tùy chọn miễn phí hiện có.", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index 8624c1899b..0d3f3d36fa 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -964,7 +964,9 @@ "freeRequests": "* 每分钟免费 {{count}} 个请求。之后,计费取决于提示大小。", "pricingDetails": "有关更多信息,请参阅定价详情。", "billingEstimate": "* 计费为估计值 - 具体费用取决于提示大小。" - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "自动获取 {{serviceName}} 上可用的最新模型列表。如果您不确定选择哪个模型,Zoo Code 与 {{defaultModelId}} 配合最佳。您还可以搜索\"free\"以查找当前可用的免费选项。", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 8556e8b2f4..1d39e36f69 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -991,7 +991,9 @@ "freeRequests": "* 每分鐘可免費使用 {{count}} 次請求,超過後將依提示詞大小計費。", "pricingDetails": "詳細資訊請參閱定價說明。", "billingEstimate": "* 費用為估算值 - 實際費用取決於提示大小。" - } + }, + "strictToolSchemas": "Strict tool schemas", + "strictToolSchemasDescription": "Enables strict mode for function tool schemas, ensuring tool outputs match the schema exactly. Some providers may not support strict mode. MCP tools are always kept non-strict regardless of this setting. (This setting is saved per profile and also applies to other providers that use the OpenAI protocol within the same profile.)" }, "modelPicker": { "automaticFetch": "此擴充功能會自動從 {{serviceName}} 取得最新的可用模型清單。如果不確定要選哪個模型,建議使用 {{defaultModelId}},這是與 Zoo Code 最佳搭配的模型。您也可以搜尋「free」來檢視目前可用的免費選項。",