From dc40ba1ee3e48e1645dd248495b7649d6d7fc035 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 6 Feb 2026 20:17:36 +0000 Subject: [PATCH 01/31] feat: remove Roomote Control from extension Remove all Roomote Control (remote control) functionality: - Remove BridgeOrchestrator and entire bridge directory from @roo-code/cloud - Remove remoteControlEnabled, featureRoomoteControlEnabled from extension state - Remove extensionBridgeEnabled from CloudUserInfo and user settings - Remove roomoteControlEnabled from organization/user feature schemas - Remove enableBridge from Task and ClineProvider - Remove remote control toggle from CloudView UI - Remove remoteControlEnabled message handler - Remove extension bridge disconnect on logout/deactivate - Update CloudTaskButton to show for all logged-in users - Remove remote control translation strings from all locales - Update all related tests CLO-765 --- packages/cloud/src/StaticSettingsService.ts | 12 +- packages/cloud/src/StaticTokenAuthService.ts | 1 - packages/cloud/src/WebAuthService.ts | 16 - .../CloudSettingsService.parsing.test.ts | 8 +- .../__tests__/StaticTokenAuthService.spec.ts | 19 +- .../src/__tests__/WebAuthService.spec.ts | 5 - packages/cloud/src/bridge/BaseChannel.ts | 142 ------ .../cloud/src/bridge/BridgeOrchestrator.ts | 355 --------------- packages/cloud/src/bridge/ExtensionChannel.ts | 282 ------------ packages/cloud/src/bridge/SocketTransport.ts | 281 ------------ packages/cloud/src/bridge/TaskChannel.ts | 241 ----------- .../bridge/__tests__/ExtensionChannel.test.ts | 402 ----------------- .../src/bridge/__tests__/TaskChannel.test.ts | 407 ------------------ packages/cloud/src/bridge/index.ts | 6 - packages/cloud/src/index.ts | 2 - packages/types/src/__tests__/cloud.test.ts | 90 +--- packages/types/src/cloud.ts | 10 +- packages/types/src/vscode-extension-host.ts | 3 - src/__tests__/extension.spec.ts | 81 +--- src/__tests__/single-open-invariant.spec.ts | 2 - src/core/task/Task.ts | 38 +- .../task/__tests__/grounding-sources.test.ts | 3 - .../__tests__/reasoning-preservation.test.ts | 3 - src/core/webview/ClineProvider.ts | 100 +---- .../ClineProvider.apiHandlerRebuild.spec.ts | 3 - .../ClineProvider.flicker-free-cancel.spec.ts | 3 - .../webview/__tests__/ClineProvider.spec.ts | 5 - .../ClineProvider.sticky-mode.spec.ts | 3 - .../ClineProvider.sticky-profile.spec.ts | 3 - .../ClineProvider.taskHistory.spec.ts | 3 - src/core/webview/webviewMessageHandler.ts | 15 - src/extension.ts | 43 +- .../src/components/chat/CloudTaskButton.tsx | 2 +- .../chat/__tests__/CloudTaskButton.spec.tsx | 2 - webview-ui/src/components/cloud/CloudView.tsx | 44 +- .../cloud/__tests__/CloudView.spec.tsx | 84 ---- .../src/context/ExtensionStateContext.tsx | 11 - .../__tests__/ExtensionStateContext.spec.tsx | 2 - webview-ui/src/i18n/locales/ca/cloud.json | 3 - webview-ui/src/i18n/locales/de/cloud.json | 3 - webview-ui/src/i18n/locales/en/cloud.json | 3 - webview-ui/src/i18n/locales/es/cloud.json | 3 - webview-ui/src/i18n/locales/fr/cloud.json | 3 - webview-ui/src/i18n/locales/hi/cloud.json | 3 - webview-ui/src/i18n/locales/id/cloud.json | 3 - webview-ui/src/i18n/locales/it/cloud.json | 3 - webview-ui/src/i18n/locales/ja/cloud.json | 3 - webview-ui/src/i18n/locales/ko/cloud.json | 3 - webview-ui/src/i18n/locales/nl/cloud.json | 3 - webview-ui/src/i18n/locales/pl/cloud.json | 3 - webview-ui/src/i18n/locales/pt-BR/cloud.json | 3 - webview-ui/src/i18n/locales/ru/cloud.json | 3 - webview-ui/src/i18n/locales/tr/cloud.json | 3 - webview-ui/src/i18n/locales/vi/cloud.json | 3 - webview-ui/src/i18n/locales/zh-CN/cloud.json | 3 - webview-ui/src/i18n/locales/zh-TW/cloud.json | 3 - 56 files changed, 25 insertions(+), 2761 deletions(-) delete mode 100644 packages/cloud/src/bridge/BaseChannel.ts delete mode 100644 packages/cloud/src/bridge/BridgeOrchestrator.ts delete mode 100644 packages/cloud/src/bridge/ExtensionChannel.ts delete mode 100644 packages/cloud/src/bridge/SocketTransport.ts delete mode 100644 packages/cloud/src/bridge/TaskChannel.ts delete mode 100644 packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts delete mode 100644 packages/cloud/src/bridge/__tests__/TaskChannel.test.ts delete mode 100644 packages/cloud/src/bridge/index.ts diff --git a/packages/cloud/src/StaticSettingsService.ts b/packages/cloud/src/StaticSettingsService.ts index 492a0a8d4b6..2365208f9d7 100644 --- a/packages/cloud/src/StaticSettingsService.ts +++ b/packages/cloud/src/StaticSettingsService.ts @@ -42,15 +42,12 @@ export class StaticSettingsService implements SettingsService { } /** - * Returns static user settings with roomoteControlEnabled and extensionBridgeEnabled as true + * Returns static user settings with task sync enabled */ public getUserSettings(): UserSettingsData | undefined { return { - features: { - roomoteControlEnabled: true, - }, + features: {}, settings: { - extensionBridgeEnabled: true, taskSyncEnabled: true, }, version: 1, @@ -58,14 +55,11 @@ export class StaticSettingsService implements SettingsService { } public getUserFeatures(): UserFeatures { - return { - roomoteControlEnabled: true, - } + return {} } public getUserSettingsConfig(): UserSettingsConfig { return { - extensionBridgeEnabled: true, taskSyncEnabled: true, } } diff --git a/packages/cloud/src/StaticTokenAuthService.ts b/packages/cloud/src/StaticTokenAuthService.ts index 2ff7b75f0e0..c2450b0c22a 100644 --- a/packages/cloud/src/StaticTokenAuthService.ts +++ b/packages/cloud/src/StaticTokenAuthService.ts @@ -30,7 +30,6 @@ export class StaticTokenAuthService extends EventEmitter impl this.userInfo = { id: payload?.r?.u || payload?.sub || undefined, organizationId: payload?.r?.o || undefined, - extensionBridgeEnabled: true, } } diff --git a/packages/cloud/src/WebAuthService.ts b/packages/cloud/src/WebAuthService.ts index 69ad28e8ecd..8f51bad236c 100644 --- a/packages/cloud/src/WebAuthService.ts +++ b/packages/cloud/src/WebAuthService.ts @@ -625,8 +625,6 @@ export class WebAuthService extends EventEmitter implements A )?.email_address } - let extensionBridgeEnabled = true - // Fetch organization info if user is in organization context try { const storedOrgId = this.getStoredOrganizationId() @@ -641,8 +639,6 @@ export class WebAuthService extends EventEmitter implements A if (userMembership) { this.setUserOrganizationInfo(userInfo, userMembership) - extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization(storedOrgId) - this.log("[auth] User in organization context:", { id: userMembership.organization.id, name: userMembership.organization.name, @@ -662,10 +658,6 @@ export class WebAuthService extends EventEmitter implements A if (primaryOrgMembership) { this.setUserOrganizationInfo(userInfo, primaryOrgMembership) - extensionBridgeEnabled = await this.isExtensionBridgeEnabledForOrganization( - primaryOrgMembership.organization.id, - ) - this.log("[auth] Legacy credentials: Found organization membership:", { id: primaryOrgMembership.organization.id, name: primaryOrgMembership.organization.name, @@ -680,9 +672,6 @@ export class WebAuthService extends EventEmitter implements A // Don't throw - organization info is optional } - // Set the extension bridge enabled flag - userInfo.extensionBridgeEnabled = extensionBridgeEnabled - return userInfo } @@ -754,11 +743,6 @@ export class WebAuthService extends EventEmitter implements A } } - private async isExtensionBridgeEnabledForOrganization(organizationId: string): Promise { - const orgMetadata = await this.getOrganizationMetadata(organizationId) - return orgMetadata?.public_metadata?.extension_bridge_enabled === true - } - private async clerkLogout(credentials: AuthCredentials): Promise { const formData = new URLSearchParams() formData.append("_is_native", "1") diff --git a/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts b/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts index 8d69303c380..b0486b971c4 100644 --- a/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts +++ b/packages/cloud/src/__tests__/CloudSettingsService.parsing.test.ts @@ -105,12 +105,8 @@ describe("CloudSettingsService - Response Parsing", () => { }, }, user: { - features: { - roomoteControlEnabled: true, - }, - settings: { - extensionBridgeEnabled: true, - }, + features: {}, + settings: {}, version: 1, }, } diff --git a/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts b/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts index a3756082eac..c37f2a7df9e 100644 --- a/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts +++ b/packages/cloud/src/__tests__/StaticTokenAuthService.spec.ts @@ -89,7 +89,6 @@ describe("StaticTokenAuthService", () => { const userInfo = serviceWithJWT.getUserInfo() expect(userInfo?.id).toBe("user_2xmBhejNeDTwanM8CgIOnMgVxzC") expect(userInfo?.organizationId).toBe("org_123abc") - expect(userInfo?.extensionBridgeEnabled).toBe(true) }) it("should parse job token without orgId (null orgId case)", () => { @@ -98,7 +97,6 @@ describe("StaticTokenAuthService", () => { const userInfo = serviceWithJWT.getUserInfo() expect(userInfo?.id).toBe("user_2xmBhejNeDTwanM8CgIOnMgVxzC") expect(userInfo?.organizationId).toBeUndefined() - expect(userInfo?.extensionBridgeEnabled).toBe(true) }) it("should parse auth token and extract userId from r.u", () => { @@ -107,7 +105,6 @@ describe("StaticTokenAuthService", () => { const userInfo = serviceWithAuthToken.getUserInfo() expect(userInfo?.id).toBe("user_123") expect(userInfo?.organizationId).toBeUndefined() - expect(userInfo?.extensionBridgeEnabled).toBe(true) }) it("should handle legacy JWT format with sub field", () => { @@ -116,7 +113,6 @@ describe("StaticTokenAuthService", () => { const userInfo = serviceWithLegacyJWT.getUserInfo() expect(userInfo?.id).toBe("user_123") expect(userInfo?.organizationId).toBeUndefined() - expect(userInfo?.extensionBridgeEnabled).toBe(true) }) it("should handle invalid JWT gracefully", () => { @@ -125,7 +121,6 @@ describe("StaticTokenAuthService", () => { const userInfo = serviceWithInvalidJWT.getUserInfo() expect(userInfo?.id).toBeUndefined() expect(userInfo?.organizationId).toBeUndefined() - expect(userInfo?.extensionBridgeEnabled).toBe(true) expect(mockLog).toHaveBeenCalledWith("[auth] Failed to parse JWT:", expect.any(Error)) }) @@ -183,9 +178,7 @@ describe("StaticTokenAuthService", () => { authService.broadcast() expect(spy).toHaveBeenCalledWith({ - userInfo: expect.objectContaining({ - extensionBridgeEnabled: true, - }), + userInfo: expect.objectContaining({}), }) }) @@ -199,7 +192,6 @@ describe("StaticTokenAuthService", () => { expect(spy).toHaveBeenCalledWith({ userInfo: { - extensionBridgeEnabled: true, id: "user_2xmBhejNeDTwanM8CgIOnMgVxzC", organizationId: "org_123abc", }, @@ -220,10 +212,9 @@ describe("StaticTokenAuthService", () => { }) describe("getUserInfo", () => { - it("should return object with extensionBridgeEnabled flag", () => { + it("should return user info object", () => { const userInfo = authService.getUserInfo() - expect(userInfo).toHaveProperty("extensionBridgeEnabled") - expect(userInfo?.extensionBridgeEnabled).toBe(true) + expect(userInfo).toBeDefined() }) }) @@ -305,9 +296,7 @@ describe("StaticTokenAuthService", () => { }) expect(userInfoSpy).toHaveBeenCalledWith({ - userInfo: expect.objectContaining({ - extensionBridgeEnabled: true, - }), + userInfo: expect.objectContaining({}), }) }) }) diff --git a/packages/cloud/src/__tests__/WebAuthService.spec.ts b/packages/cloud/src/__tests__/WebAuthService.spec.ts index 3398e3f2a3a..aa406e400d7 100644 --- a/packages/cloud/src/__tests__/WebAuthService.spec.ts +++ b/packages/cloud/src/__tests__/WebAuthService.spec.ts @@ -636,7 +636,6 @@ describe("WebAuthService", () => { name: "John Doe", email: "john@example.com", picture: "https://example.com/avatar.jpg", - extensionBridgeEnabled: true, }, }) }) @@ -801,7 +800,6 @@ describe("WebAuthService", () => { name: "Jane Smith", email: "jane@example.com", picture: "https://example.com/jane.jpg", - extensionBridgeEnabled: true, }) }) @@ -869,7 +867,6 @@ describe("WebAuthService", () => { name: "Jane Smith", email: "jane@example.com", picture: "https://example.com/jane.jpg", - extensionBridgeEnabled: false, organizationId: "org_1", organizationName: "Org 1", organizationRole: "member", @@ -920,7 +917,6 @@ describe("WebAuthService", () => { name: "John Doe", email: undefined, picture: undefined, - extensionBridgeEnabled: true, }) }) }) @@ -1045,7 +1041,6 @@ describe("WebAuthService", () => { name: "Test User", email: undefined, picture: undefined, - extensionBridgeEnabled: true, }, }) }) diff --git a/packages/cloud/src/bridge/BaseChannel.ts b/packages/cloud/src/bridge/BaseChannel.ts deleted file mode 100644 index 1b2615b24c3..00000000000 --- a/packages/cloud/src/bridge/BaseChannel.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { Socket } from "socket.io-client" -import * as vscode from "vscode" - -import type { StaticAppProperties, GitProperties } from "@roo-code/types" - -export interface BaseChannelOptions { - instanceId: string - appProperties: StaticAppProperties - gitProperties?: GitProperties - isCloudAgent: boolean -} - -/** - * Abstract base class for communication channels in the bridge system. - * Provides common functionality for bidirectional communication between - * the VSCode extension and web application. - * - * @template TCommand - Type of commands this channel can receive. - * @template TEvent - Type of events this channel can publish. - */ -export abstract class BaseChannel { - protected socket: Socket | null = null - protected readonly instanceId: string - protected readonly appProperties: StaticAppProperties - protected readonly gitProperties?: GitProperties - protected readonly isCloudAgent: boolean - - constructor(options: BaseChannelOptions) { - this.instanceId = options.instanceId - this.appProperties = options.appProperties - this.gitProperties = options.gitProperties - this.isCloudAgent = options.isCloudAgent - } - - /** - * Called when socket connects. - */ - public async onConnect(socket: Socket): Promise { - this.socket = socket - await this.handleConnect(socket) - } - - /** - * Called when socket disconnects. - */ - public onDisconnect(): void { - this.socket = null - this.handleDisconnect() - } - - /** - * Called when socket reconnects. - */ - public async onReconnect(socket: Socket): Promise { - this.socket = socket - await this.handleReconnect(socket) - } - - /** - * Cleanup resources. - */ - public async cleanup(socket: Socket | null): Promise { - if (socket) { - await this.handleCleanup(socket) - } - - this.socket = null - } - - /** - * Emit a socket event with error handling. - */ - protected publish( - eventName: TEventName, - data: TEventData, - callback?: (params: Params) => void, - ): boolean { - if (!this.socket) { - console.error(`[${this.constructor.name}#emit] socket not available for ${eventName}`) - return false - } - - try { - // console.log(`[${this.constructor.name}#emit] emit() -> ${eventName}`, data) - this.socket.emit(eventName, data, callback) - - return true - } catch (error) { - console.error( - `[${this.constructor.name}#emit] emit() failed -> ${eventName}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - - return false - } - } - - /** - * Handle incoming commands - template method that ensures common functionality - * is executed before subclass-specific logic. - * - * This method should be called by subclasses to handle commands. - * It will execute common functionality and then delegate to the abstract - * handleCommandImplementation method. - */ - public async handleCommand(command: TCommand): Promise { - // Common functionality: focus the sidebar. - await vscode.commands.executeCommand(`${this.appProperties.appName}.SidebarProvider.focus`) - - // Delegate to subclass-specific implementation. - await this.handleCommandImplementation(command) - } - - /** - * Handle command-specific logic - must be implemented by subclasses. - * This method is called after common functionality has been executed. - */ - protected abstract handleCommandImplementation(command: TCommand): Promise - - /** - * Handle connection-specific logic. - */ - protected abstract handleConnect(socket: Socket): Promise - - /** - * Handle disconnection-specific logic. - */ - protected handleDisconnect(): void { - // Default implementation - can be overridden. - } - - /** - * Handle reconnection-specific logic. - */ - protected abstract handleReconnect(socket: Socket): Promise - - /** - * Handle cleanup-specific logic. - */ - protected abstract handleCleanup(socket: Socket): Promise -} diff --git a/packages/cloud/src/bridge/BridgeOrchestrator.ts b/packages/cloud/src/bridge/BridgeOrchestrator.ts deleted file mode 100644 index 16ad0244f08..00000000000 --- a/packages/cloud/src/bridge/BridgeOrchestrator.ts +++ /dev/null @@ -1,355 +0,0 @@ -import crypto from "crypto" -import os from "os" - -import { - type TaskProviderLike, - type TaskLike, - type CloudUserInfo, - type ExtensionBridgeCommand, - type TaskBridgeCommand, - type StaticAppProperties, - type GitProperties, - ConnectionState, - ExtensionSocketEvents, - TaskSocketEvents, -} from "@roo-code/types" - -import { SocketTransport } from "./SocketTransport.js" -import { ExtensionChannel } from "./ExtensionChannel.js" -import { TaskChannel } from "./TaskChannel.js" - -export interface BridgeOrchestratorOptions { - userId: string - socketBridgeUrl: string - token: string - provider: TaskProviderLike - sessionId: string - isCloudAgent: boolean -} - -/** - * Central orchestrator for the extension bridge system. - * Coordinates communication between the VSCode extension and web application - * through WebSocket connections and manages extension/task channels. - */ -export class BridgeOrchestrator { - private static instance: BridgeOrchestrator | null = null - - private static pendingTask: TaskLike | null = null - - // Core - private readonly userId: string - private readonly socketBridgeUrl: string - private readonly token: string - private readonly provider: TaskProviderLike - private readonly instanceId: string - private readonly appProperties: StaticAppProperties - private readonly gitProperties?: GitProperties - private readonly isCloudAgent?: boolean - - // Components - private socketTransport: SocketTransport - private extensionChannel: ExtensionChannel - private taskChannel: TaskChannel - - // Reconnection - private readonly MAX_RECONNECT_ATTEMPTS = Infinity - private readonly RECONNECT_DELAY = 1_000 - private readonly RECONNECT_DELAY_MAX = 30_000 - - public static getInstance(): BridgeOrchestrator | null { - return BridgeOrchestrator.instance - } - - public static isEnabled(user: CloudUserInfo | null, remoteControlEnabled: boolean): boolean { - // Always disabled if signed out. - if (!user) { - return false - } - - // Disabled by the user's organization? - if (!user.extensionBridgeEnabled) { - return false - } - - // Disabled by the user? - if (!remoteControlEnabled) { - return false - } - - return true - } - - public static async connectOrDisconnect( - userInfo: CloudUserInfo, - remoteControlEnabled: boolean, - options: BridgeOrchestratorOptions, - ): Promise { - if (BridgeOrchestrator.isEnabled(userInfo, remoteControlEnabled)) { - await BridgeOrchestrator.connect(options) - } else { - await BridgeOrchestrator.disconnect() - } - } - - public static async connect(options: BridgeOrchestratorOptions) { - const instance = BridgeOrchestrator.instance - - if (!instance) { - try { - console.log(`[BridgeOrchestrator#connectOrDisconnect] Connecting...`) - - // Populate telemetry properties before registering the instance. - await options.provider.getTelemetryProperties() - - BridgeOrchestrator.instance = new BridgeOrchestrator(options) - await BridgeOrchestrator.instance.connect() - } catch (error) { - console.error( - `[BridgeOrchestrator#connectOrDisconnect] connect() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } else { - if ( - instance.connectionState === ConnectionState.FAILED || - instance.connectionState === ConnectionState.DISCONNECTED - ) { - console.log( - `[BridgeOrchestrator#connectOrDisconnect] Re-connecting... (state: ${instance.connectionState})`, - ) - - instance.reconnect().catch((error) => { - console.error( - `[BridgeOrchestrator#connectOrDisconnect] reconnect() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - }) - } else { - console.log( - `[BridgeOrchestrator#connectOrDisconnect] Already connected or connecting (state: ${instance.connectionState})`, - ) - } - } - } - - public static async disconnect() { - const instance = BridgeOrchestrator.instance - - if (instance) { - try { - console.log( - `[BridgeOrchestrator#connectOrDisconnect] Disconnecting... (state: ${instance.connectionState})`, - ) - - await instance.disconnect() - } catch (error) { - console.error( - `[BridgeOrchestrator#connectOrDisconnect] disconnect() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } finally { - BridgeOrchestrator.instance = null - } - } else { - console.log(`[BridgeOrchestrator#connectOrDisconnect] Already disconnected`) - } - } - - /** - * @TODO: What if subtasks also get spawned? We'd probably want deferred - * subscriptions for those too. - */ - public static async subscribeToTask(task: TaskLike): Promise { - const instance = BridgeOrchestrator.instance - - if (instance && instance.socketTransport.isConnected()) { - console.log(`[BridgeOrchestrator#subscribeToTask] Subscribing to task ${task.taskId}`) - await instance.subscribeToTask(task) - } else { - console.log(`[BridgeOrchestrator#subscribeToTask] Deferring subscription for task ${task.taskId}`) - BridgeOrchestrator.pendingTask = task - } - } - - private constructor(options: BridgeOrchestratorOptions) { - this.userId = options.userId - this.socketBridgeUrl = options.socketBridgeUrl - this.token = options.token - this.provider = options.provider - this.instanceId = options.sessionId || crypto.randomUUID() - this.appProperties = { ...options.provider.appProperties, hostname: os.hostname() } - this.gitProperties = options.provider.gitProperties - this.isCloudAgent = options.isCloudAgent - - this.socketTransport = new SocketTransport({ - url: this.socketBridgeUrl, - socketOptions: { - query: { - token: this.token, - clientType: "extension", - instanceId: this.instanceId, - }, - transports: ["websocket", "polling"], - reconnection: true, - reconnectionAttempts: this.MAX_RECONNECT_ATTEMPTS, - reconnectionDelay: this.RECONNECT_DELAY, - reconnectionDelayMax: this.RECONNECT_DELAY_MAX, - }, - onConnect: () => this.handleConnect(), - onDisconnect: () => this.handleDisconnect(), - onReconnect: () => this.handleReconnect(), - }) - - this.extensionChannel = new ExtensionChannel({ - instanceId: this.instanceId, - appProperties: this.appProperties, - gitProperties: this.gitProperties, - userId: this.userId, - provider: this.provider, - isCloudAgent: this.isCloudAgent, - }) - - this.taskChannel = new TaskChannel({ - instanceId: this.instanceId, - appProperties: this.appProperties, - gitProperties: this.gitProperties, - isCloudAgent: this.isCloudAgent, - }) - } - - private setupSocketListeners() { - const socket = this.socketTransport.getSocket() - - if (!socket) { - console.error("[BridgeOrchestrator] Socket not available") - return - } - - // Remove any existing listeners first to prevent duplicates. - socket.off(ExtensionSocketEvents.RELAYED_COMMAND) - socket.off(TaskSocketEvents.RELAYED_COMMAND) - socket.off("connected") - - socket.on(ExtensionSocketEvents.RELAYED_COMMAND, (message: ExtensionBridgeCommand) => { - console.log( - `[BridgeOrchestrator] on(${ExtensionSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.instanceId}`, - ) - - this.extensionChannel?.handleCommand(message) - }) - - socket.on(TaskSocketEvents.RELAYED_COMMAND, (message: TaskBridgeCommand) => { - console.log( - `[BridgeOrchestrator] on(${TaskSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.taskId}`, - ) - - this.taskChannel.handleCommand(message) - }) - } - - private async handleConnect() { - const socket = this.socketTransport.getSocket() - - if (!socket) { - console.error("[BridgeOrchestrator#handleConnect] Socket not available") - return - } - - await this.extensionChannel.onConnect(socket) - await this.taskChannel.onConnect(socket) - - if (BridgeOrchestrator.pendingTask) { - console.log( - `[BridgeOrchestrator#handleConnect] Subscribing to task ${BridgeOrchestrator.pendingTask.taskId}`, - ) - - try { - await this.subscribeToTask(BridgeOrchestrator.pendingTask) - BridgeOrchestrator.pendingTask = null - } catch (error) { - console.error( - `[BridgeOrchestrator#handleConnect] subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - } - - private handleDisconnect() { - this.extensionChannel.onDisconnect() - this.taskChannel.onDisconnect() - } - - private async handleReconnect() { - const socket = this.socketTransport.getSocket() - - if (!socket) { - console.error("[BridgeOrchestrator] Socket not available after reconnect") - return - } - - // Re-setup socket listeners to ensure they're properly configured - // after automatic reconnection (Socket.IO's built-in reconnection) - // The socket.off() calls in setupSocketListeners prevent duplicates - this.setupSocketListeners() - - await this.extensionChannel.onReconnect(socket) - await this.taskChannel.onReconnect(socket) - } - - // Task API - - public async subscribeToTask(task: TaskLike): Promise { - const socket = this.socketTransport.getSocket() - - if (!socket || !this.socketTransport.isConnected()) { - console.warn("[BridgeOrchestrator] Cannot subscribe to task: not connected. Will retry when connected.") - this.taskChannel.addPendingTask(task) - - if ( - this.connectionState === ConnectionState.DISCONNECTED || - this.connectionState === ConnectionState.FAILED - ) { - await this.connect() - } - - return - } - - await this.taskChannel.subscribeToTask(task, socket) - } - - public async unsubscribeFromTask(taskId: string): Promise { - const socket = this.socketTransport.getSocket() - - if (!socket) { - return - } - - await this.taskChannel.unsubscribeFromTask(taskId, socket) - } - - // Shared API - - public get connectionState(): ConnectionState { - return this.socketTransport.getConnectionState() - } - - private async connect(): Promise { - await this.socketTransport.connect() - this.setupSocketListeners() - } - - public async disconnect(): Promise { - await this.extensionChannel.cleanup(this.socketTransport.getSocket()) - await this.taskChannel.cleanup(this.socketTransport.getSocket()) - await this.socketTransport.disconnect() - BridgeOrchestrator.instance = null - BridgeOrchestrator.pendingTask = null - } - - public async reconnect(): Promise { - await this.socketTransport.reconnect() - - // After a manual reconnect, we have a new socket instance - // so we need to set up listeners again. - this.setupSocketListeners() - } -} diff --git a/packages/cloud/src/bridge/ExtensionChannel.ts b/packages/cloud/src/bridge/ExtensionChannel.ts deleted file mode 100644 index 26fce96228e..00000000000 --- a/packages/cloud/src/bridge/ExtensionChannel.ts +++ /dev/null @@ -1,282 +0,0 @@ -import type { Socket } from "socket.io-client" - -import { - type TaskProviderLike, - type TaskProviderEvents, - type ExtensionInstance, - type ExtensionBridgeCommand, - type ExtensionBridgeEvent, - RooCodeEventName, - TaskStatus, - ExtensionBridgeCommandName, - ExtensionBridgeEventName, - ExtensionSocketEvents, - HEARTBEAT_INTERVAL_MS, -} from "@roo-code/types" - -import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js" - -interface ExtensionChannelOptions extends BaseChannelOptions { - userId: string - provider: TaskProviderLike -} - -/** - * Manages the extension-level communication channel. - * Handles extension registration, heartbeat, and extension-specific commands. - */ -export class ExtensionChannel extends BaseChannel< - ExtensionBridgeCommand, - ExtensionSocketEvents, - ExtensionBridgeEvent | ExtensionInstance -> { - private userId: string - private provider: TaskProviderLike - private extensionInstance: ExtensionInstance - private heartbeatInterval: NodeJS.Timeout | null = null - private eventListeners: Map void> = new Map() - - constructor(options: ExtensionChannelOptions) { - super({ - instanceId: options.instanceId, - appProperties: options.appProperties, - gitProperties: options.gitProperties, - isCloudAgent: options.isCloudAgent, - }) - - this.userId = options.userId - this.provider = options.provider - - this.extensionInstance = { - instanceId: this.instanceId, - userId: this.userId, - workspacePath: this.provider.cwd, - appProperties: this.appProperties, - gitProperties: this.gitProperties, - lastHeartbeat: Date.now(), - task: { taskId: "", taskStatus: TaskStatus.None }, - taskHistory: [], - isCloudAgent: this.isCloudAgent, - } - - this.setupListeners() - } - - protected async handleCommandImplementation(command: ExtensionBridgeCommand): Promise { - if (command.instanceId !== this.instanceId) { - console.log(`[ExtensionChannel] command -> instance id mismatch | ${this.instanceId}`, { - messageInstanceId: command.instanceId, - }) - - return - } - - switch (command.type) { - case ExtensionBridgeCommandName.StartTask: { - console.log(`[ExtensionChannel] command -> createTask() | ${command.instanceId}`, { - text: command.payload.text?.substring(0, 100) + "...", - hasImages: !!command.payload.images, - mode: command.payload.mode, - providerProfile: command.payload.providerProfile, - }) - - this.provider.createTask( - command.payload.text, - command.payload.images, - undefined, // parentTask - undefined, // options - { mode: command.payload.mode, currentApiConfigName: command.payload.providerProfile }, - ) - - break - } - case ExtensionBridgeCommandName.StopTask: { - const instance = await this.updateInstance() - - if (instance.task.taskStatus === TaskStatus.Running) { - console.log(`[ExtensionChannel] command -> cancelTask() | ${command.instanceId}`) - this.provider.cancelTask() - this.provider.postStateToWebview() - } else if (instance.task.taskId) { - console.log(`[ExtensionChannel] command -> clearTask() | ${command.instanceId}`) - this.provider.clearTask() - this.provider.postStateToWebview() - } - - break - } - case ExtensionBridgeCommandName.ResumeTask: { - console.log(`[ExtensionChannel] command -> resumeTask() | ${command.instanceId}`, { - taskId: command.payload.taskId, - }) - - this.provider.resumeTask(command.payload.taskId) - this.provider.postStateToWebview() - break - } - } - } - - protected async handleConnect(socket: Socket): Promise { - await this.registerInstance(socket) - this.startHeartbeat(socket) - } - - protected async handleReconnect(socket: Socket): Promise { - await this.registerInstance(socket) - this.startHeartbeat(socket) - } - - protected override handleDisconnect(): void { - this.stopHeartbeat() - } - - protected async handleCleanup(socket: Socket): Promise { - this.stopHeartbeat() - this.cleanupListeners() - await this.unregisterInstance(socket) - } - - private async registerInstance(_socket: Socket): Promise { - const instance = await this.updateInstance() - await this.publish(ExtensionSocketEvents.REGISTER, instance) - } - - private async unregisterInstance(_socket: Socket): Promise { - const instance = await this.updateInstance() - await this.publish(ExtensionSocketEvents.UNREGISTER, instance) - } - - private startHeartbeat(socket: Socket): void { - this.stopHeartbeat() - - this.heartbeatInterval = setInterval(async () => { - const instance = await this.updateInstance() - - try { - socket.emit(ExtensionSocketEvents.HEARTBEAT, instance) - // Heartbeat is too frequent to log - } catch (error) { - console.error( - `[ExtensionChannel] emit() failed -> ${ExtensionSocketEvents.HEARTBEAT}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - }, HEARTBEAT_INTERVAL_MS) - } - - private stopHeartbeat(): void { - if (this.heartbeatInterval) { - clearInterval(this.heartbeatInterval) - this.heartbeatInterval = null - } - } - - private setupListeners(): void { - const eventMapping = [ - { from: RooCodeEventName.TaskCreated, to: ExtensionBridgeEventName.TaskCreated }, - { from: RooCodeEventName.TaskStarted, to: ExtensionBridgeEventName.TaskStarted }, - { from: RooCodeEventName.TaskCompleted, to: ExtensionBridgeEventName.TaskCompleted }, - { from: RooCodeEventName.TaskAborted, to: ExtensionBridgeEventName.TaskAborted }, - { from: RooCodeEventName.TaskFocused, to: ExtensionBridgeEventName.TaskFocused }, - { from: RooCodeEventName.TaskUnfocused, to: ExtensionBridgeEventName.TaskUnfocused }, - { from: RooCodeEventName.TaskActive, to: ExtensionBridgeEventName.TaskActive }, - { from: RooCodeEventName.TaskInteractive, to: ExtensionBridgeEventName.TaskInteractive }, - { from: RooCodeEventName.TaskResumable, to: ExtensionBridgeEventName.TaskResumable }, - { from: RooCodeEventName.TaskIdle, to: ExtensionBridgeEventName.TaskIdle }, - { from: RooCodeEventName.TaskPaused, to: ExtensionBridgeEventName.TaskPaused }, - { from: RooCodeEventName.TaskUnpaused, to: ExtensionBridgeEventName.TaskUnpaused }, - { from: RooCodeEventName.TaskSpawned, to: ExtensionBridgeEventName.TaskSpawned }, - { from: RooCodeEventName.TaskDelegated, to: ExtensionBridgeEventName.TaskDelegated }, - { from: RooCodeEventName.TaskDelegationCompleted, to: ExtensionBridgeEventName.TaskDelegationCompleted }, - { from: RooCodeEventName.TaskDelegationResumed, to: ExtensionBridgeEventName.TaskDelegationResumed }, - { from: RooCodeEventName.TaskUserMessage, to: ExtensionBridgeEventName.TaskUserMessage }, - { from: RooCodeEventName.TaskTokenUsageUpdated, to: ExtensionBridgeEventName.TaskTokenUsageUpdated }, - ] as const - - eventMapping.forEach(({ from, to }) => { - // Create and store the listener function for cleanup. - const listener = async (...args: unknown[]) => { - const baseEvent: { - type: ExtensionBridgeEventName - instance: ExtensionInstance - timestamp: number - } = { - type: to, - instance: await this.updateInstance(), - timestamp: Date.now(), - } - - let eventToPublish: ExtensionBridgeEvent - - // Add payload for delegation events while avoiding `any` - if (to === ExtensionBridgeEventName.TaskDelegationCompleted) { - const [parentTaskId, childTaskId, summary] = args as [string, string, string] - eventToPublish = { - ...(baseEvent as unknown as ExtensionBridgeEvent), - payload: { parentTaskId, childTaskId, summary }, - } as unknown as ExtensionBridgeEvent - } else if (to === ExtensionBridgeEventName.TaskDelegationResumed) { - const [parentTaskId, childTaskId] = args as [string, string] - eventToPublish = { - ...(baseEvent as unknown as ExtensionBridgeEvent), - payload: { parentTaskId, childTaskId }, - } as unknown as ExtensionBridgeEvent - } else { - eventToPublish = baseEvent as unknown as ExtensionBridgeEvent - } - - this.publish(ExtensionSocketEvents.EVENT, eventToPublish) - } - - this.eventListeners.set(from, listener) - this.provider.on(from, listener) - }) - } - - private cleanupListeners(): void { - this.eventListeners.forEach((listener, eventName) => { - // Cast is safe because we only store valid event names from eventMapping. - this.provider.off(eventName as keyof TaskProviderEvents, listener) - }) - - this.eventListeners.clear() - } - - private async updateInstance(): Promise { - const task = this.provider?.getCurrentTask() - const taskHistory = this.provider?.getRecentTasks() ?? [] - - const mode = await this.provider?.getMode() - const modes = (await this.provider?.getModes()) ?? [] - - const providerProfile = await this.provider?.getProviderProfile() - const providerProfiles = (await this.provider?.getProviderProfiles()) ?? [] - - this.extensionInstance = { - ...this.extensionInstance, - lastHeartbeat: Date.now(), - task: task - ? { - taskId: task.taskId, - parentTaskId: task.parentTaskId, - childTaskId: task.childTaskId, - taskStatus: task.taskStatus, - taskAsk: task?.taskAsk, - queuedMessages: task.queuedMessages, - tokenUsage: task.tokenUsage, - ...task.metadata, - } - : { taskId: "", taskStatus: TaskStatus.None }, - taskAsk: task?.taskAsk, - taskHistory, - mode, - providerProfile, - modes, - providerProfiles, - } - - return this.extensionInstance - } -} diff --git a/packages/cloud/src/bridge/SocketTransport.ts b/packages/cloud/src/bridge/SocketTransport.ts deleted file mode 100644 index 2df3cf95eba..00000000000 --- a/packages/cloud/src/bridge/SocketTransport.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { io, type Socket, type SocketOptions, type ManagerOptions } from "socket.io-client" - -import { ConnectionState, type RetryConfig } from "@roo-code/types" - -export interface SocketTransportOptions { - url: string - socketOptions: Partial - onConnect?: () => void | Promise - onDisconnect?: (reason: string) => void - onReconnect?: () => void | Promise - logger?: { - log: (message: string, ...args: unknown[]) => void - error: (message: string, ...args: unknown[]) => void - warn: (message: string, ...args: unknown[]) => void - } -} - -/** - * Manages the WebSocket transport layer for the bridge system. - * Handles connection lifecycle, retries, and reconnection logic. - */ -export class SocketTransport { - private socket: Socket | null = null - private connectionState: ConnectionState = ConnectionState.DISCONNECTED - private retryTimeout: NodeJS.Timeout | null = null - private isPreviouslyConnected: boolean = false - - private readonly retryConfig: RetryConfig = { - maxInitialAttempts: Infinity, - initialDelay: 1_000, - maxDelay: 15_000, - backoffMultiplier: 2, - } - - private readonly CONNECTION_TIMEOUT = 2_000 - private readonly options: SocketTransportOptions - - constructor(options: SocketTransportOptions, retryConfig?: Partial) { - this.options = options - - if (retryConfig) { - this.retryConfig = { ...this.retryConfig, ...retryConfig } - } - } - - // This is the initial connnect attempt. We need to implement our own - // infinite retry mechanism since Socket.io's automatic reconnection only - // kicks in after a successful initial connection. - public async connect(): Promise { - if (this.connectionState === ConnectionState.CONNECTED) { - console.log(`[SocketTransport#connect] Already connected`) - return - } - - if (this.connectionState === ConnectionState.CONNECTING || this.connectionState === ConnectionState.RETRYING) { - console.log(`[SocketTransport#connect] Already in progress`) - return - } - - let attempt = 0 - let delay = this.retryConfig.initialDelay - - while (attempt < this.retryConfig.maxInitialAttempts) { - console.log(`[SocketTransport#connect] attempt = ${attempt + 1}, delay = ${delay}ms`) - this.connectionState = attempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING - - try { - await this._connect() - break - } catch (_error) { - attempt++ - - if (this.socket) { - this.socket.disconnect() - this.socket = null - } - - const promise = new Promise((resolve) => { - this.retryTimeout = setTimeout(resolve, delay) - }) - - await promise - - delay = Math.min(delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelay) - } - } - - if (this.retryTimeout) { - clearTimeout(this.retryTimeout) - this.retryTimeout = null - } - - if (this.socket?.connected) { - console.log(`[SocketTransport#connect] connected - ${this.options.url}`) - } else { - // Since we have infinite retries this should never happen. - this.connectionState = ConnectionState.FAILED - console.error(`[SocketTransport#connect] Giving up`) - } - } - - private async _connect(): Promise { - return new Promise((resolve, reject) => { - this.socket = io(this.options.url, this.options.socketOptions) - - let connectionTimeout: NodeJS.Timeout | null = setTimeout(() => { - console.error(`[SocketTransport#_connect] failed to connect after ${this.CONNECTION_TIMEOUT}ms`) - - if (this.connectionState !== ConnectionState.CONNECTED) { - this.socket?.disconnect() - reject(new Error("Connection timeout")) - } - }, this.CONNECTION_TIMEOUT) - - // https://socket.io/docs/v4/client-api/#event-connect - this.socket.on("connect", async () => { - console.log( - `[SocketTransport#_connect] on(connect): isPreviouslyConnected = ${this.isPreviouslyConnected}`, - ) - - if (connectionTimeout) { - clearTimeout(connectionTimeout) - connectionTimeout = null - } - - this.connectionState = ConnectionState.CONNECTED - - if (this.isPreviouslyConnected) { - if (this.options.onReconnect) { - await this.options.onReconnect() - } - } else { - if (this.options.onConnect) { - await this.options.onConnect() - } - } - - this.isPreviouslyConnected = true - resolve() - }) - - // https://socket.io/docs/v4/client-api/#event-connect_error - this.socket.on("connect_error", (error) => { - if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) { - console.error(`[SocketTransport] on(connect_error): ${error.message}`) - clearTimeout(connectionTimeout) - connectionTimeout = null - reject(error) - } - }) - - // https://socket.io/docs/v4/client-api/#event-disconnect - this.socket.on("disconnect", (reason, details) => { - console.log( - `[SocketTransport#_connect] on(disconnect) (reason: ${reason}, details: ${JSON.stringify(details)})`, - ) - this.connectionState = ConnectionState.DISCONNECTED - - if (this.options.onDisconnect) { - this.options.onDisconnect(reason) - } - - // Don't attempt to reconnect if we're manually disconnecting. - const isManualDisconnect = reason === "io client disconnect" - - if (!isManualDisconnect && this.isPreviouslyConnected) { - // After successful initial connection, rely entirely on - // Socket.IO's reconnection logic. - console.log("[SocketTransport#_connect] will attempt to reconnect") - } else { - console.log("[SocketTransport#_connect] will *NOT* attempt to reconnect") - } - }) - - // https://socket.io/docs/v4/client-api/#event-error - // Fired upon a connection error. - this.socket.io.on("error", (error) => { - // Connection error. - if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) { - console.error(`[SocketTransport#_connect] on(error): ${error.message}`) - clearTimeout(connectionTimeout) - connectionTimeout = null - reject(error) - } - - // Post-connection error. - if (this.connectionState === ConnectionState.CONNECTED) { - console.error(`[SocketTransport#_connect] on(error): ${error.message}`) - } - }) - - // https://socket.io/docs/v4/client-api/#event-reconnect - // Fired upon a successful reconnection. - this.socket.io.on("reconnect", (attempt) => { - console.log(`[SocketTransport#_connect] on(reconnect) - ${attempt}`) - this.connectionState = ConnectionState.CONNECTED - - if (this.options.onReconnect) { - this.options.onReconnect() - } - }) - - // https://socket.io/docs/v4/client-api/#event-reconnect_attempt - // Fired upon an attempt to reconnect. - this.socket.io.on("reconnect_attempt", (attempt) => { - console.log(`[SocketTransport#_connect] on(reconnect_attempt) - ${attempt}`) - }) - - // https://socket.io/docs/v4/client-api/#event-reconnect_error - // Fired upon a reconnection attempt error. - this.socket.io.on("reconnect_error", (error) => { - console.error(`[SocketTransport#_connect] on(reconnect_error): ${error.message}`) - }) - - // https://socket.io/docs/v4/client-api/#event-reconnect_failed - // Fired when couldn't reconnect within `reconnectionAttempts`. - // Since we use infinite retries, this should never fire. - this.socket.io.on("reconnect_failed", () => { - console.error(`[SocketTransport#_connect] on(reconnect_failed) - giving up`) - this.connectionState = ConnectionState.FAILED - }) - - // This is a custom event fired by the server. - this.socket.on("auth_error", (error) => { - console.error( - `[SocketTransport#_connect] on(auth_error): ${error instanceof Error ? error.message : String(error)}`, - ) - - if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) { - clearTimeout(connectionTimeout) - connectionTimeout = null - reject(new Error(error.message || "Authentication failed")) - } - }) - }) - } - - public async disconnect(): Promise { - console.log(`[SocketTransport#disconnect] Disconnecting...`) - - if (this.retryTimeout) { - clearTimeout(this.retryTimeout) - this.retryTimeout = null - } - - if (this.socket) { - this.socket.removeAllListeners() - this.socket.io.removeAllListeners() - this.socket.disconnect() - this.socket = null - } - - this.connectionState = ConnectionState.DISCONNECTED - console.log(`[SocketTransport#disconnect] Disconnected`) - } - - public getSocket(): Socket | null { - return this.socket - } - - public getConnectionState(): ConnectionState { - return this.connectionState - } - - public isConnected(): boolean { - return this.connectionState === ConnectionState.CONNECTED && this.socket?.connected === true - } - - public async reconnect(): Promise { - console.log(`[SocketTransport#reconnect] Manually reconnecting...`) - - if (this.connectionState === ConnectionState.CONNECTED) { - console.log(`[SocketTransport#reconnect] Already connected`) - return - } - - this.isPreviouslyConnected = false - await this.disconnect() - await this.connect() - } -} diff --git a/packages/cloud/src/bridge/TaskChannel.ts b/packages/cloud/src/bridge/TaskChannel.ts deleted file mode 100644 index 433e740d4ed..00000000000 --- a/packages/cloud/src/bridge/TaskChannel.ts +++ /dev/null @@ -1,241 +0,0 @@ -import type { Socket } from "socket.io-client" - -import { - type ClineMessage, - type TaskEvents, - type TaskLike, - type TaskBridgeCommand, - type TaskBridgeEvent, - type JoinResponse, - type LeaveResponse, - RooCodeEventName, - TaskBridgeEventName, - TaskBridgeCommandName, - TaskSocketEvents, -} from "@roo-code/types" - -import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js" - -type TaskEventListener = { - [K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise -}[keyof TaskEvents] - -type TaskEventMapping = { - from: keyof TaskEvents - to: TaskBridgeEventName - createPayload: (task: TaskLike, ...args: any[]) => any // eslint-disable-line @typescript-eslint/no-explicit-any -} - -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -interface TaskChannelOptions extends BaseChannelOptions {} - -/** - * Manages task-level communication channels. - * Handles task subscriptions, messaging, and task-specific commands. - */ -export class TaskChannel extends BaseChannel< - TaskBridgeCommand, - TaskSocketEvents, - TaskBridgeEvent | { taskId: string } -> { - private subscribedTasks: Map = new Map() - private pendingTasks: Map = new Map() - private taskListeners: Map> = new Map() - - private readonly eventMapping: readonly TaskEventMapping[] = [ - { - from: RooCodeEventName.Message, - to: TaskBridgeEventName.Message, - createPayload: (task: TaskLike, data: { action: string; message: ClineMessage }) => ({ - type: TaskBridgeEventName.Message, - taskId: task.taskId, - action: data.action, - message: data.message, - }), - }, - { - from: RooCodeEventName.TaskModeSwitched, - to: TaskBridgeEventName.TaskModeSwitched, - createPayload: (task: TaskLike, mode: string) => ({ - type: TaskBridgeEventName.TaskModeSwitched, - taskId: task.taskId, - mode, - }), - }, - { - from: RooCodeEventName.TaskInteractive, - to: TaskBridgeEventName.TaskInteractive, - createPayload: (task: TaskLike, _taskId: string) => ({ - type: TaskBridgeEventName.TaskInteractive, - taskId: task.taskId, - }), - }, - ] as const - - constructor(options: TaskChannelOptions) { - super(options) - } - - protected async handleCommandImplementation(command: TaskBridgeCommand): Promise { - const task = this.subscribedTasks.get(command.taskId) - - if (!task) { - console.error(`[TaskChannel] Unable to find task ${command.taskId}`) - return - } - - switch (command.type) { - case TaskBridgeCommandName.Message: - console.log( - `[TaskChannel] ${TaskBridgeCommandName.Message} ${command.taskId} -> submitUserMessage()`, - command, - ) - - await task.submitUserMessage( - command.payload.text, - command.payload.images, - command.payload.mode, - command.payload.providerProfile, - ) - - break - - case TaskBridgeCommandName.ApproveAsk: - console.log( - `[TaskChannel] ${TaskBridgeCommandName.ApproveAsk} ${command.taskId} -> approveAsk()`, - command, - ) - - task.approveAsk(command.payload) - break - - case TaskBridgeCommandName.DenyAsk: - console.log(`[TaskChannel] ${TaskBridgeCommandName.DenyAsk} ${command.taskId} -> denyAsk()`, command) - task.denyAsk(command.payload) - break - } - } - - protected async handleConnect(socket: Socket): Promise { - // Rejoin all subscribed tasks. - for (const taskId of this.subscribedTasks.keys()) { - await this.publish(TaskSocketEvents.JOIN, { taskId }) - } - - // Subscribe to any pending tasks. - for (const task of this.pendingTasks.values()) { - await this.subscribeToTask(task, socket) - } - - this.pendingTasks.clear() - } - - protected async handleReconnect(_socket: Socket): Promise { - // Rejoin all subscribed tasks. - for (const taskId of this.subscribedTasks.keys()) { - await this.publish(TaskSocketEvents.JOIN, { taskId }) - } - } - - protected async handleCleanup(socket: Socket): Promise { - const unsubscribePromises = [] - - for (const taskId of this.subscribedTasks.keys()) { - unsubscribePromises.push(this.unsubscribeFromTask(taskId, socket)) - } - - await Promise.allSettled(unsubscribePromises) - this.subscribedTasks.clear() - this.taskListeners.clear() - this.pendingTasks.clear() - } - - /** - * Add a task to the pending queue (will be subscribed when connected). - */ - public addPendingTask(task: TaskLike): void { - this.pendingTasks.set(task.taskId, task) - } - - public async subscribeToTask(task: TaskLike, _socket: Socket): Promise { - const taskId = task.taskId - - await this.publish(TaskSocketEvents.JOIN, { taskId }, (response: JoinResponse) => { - if (response.success) { - console.log(`[TaskChannel#subscribeToTask] subscribed to ${taskId}`) - this.subscribedTasks.set(taskId, task) - this.setupTaskListeners(task) - } else { - console.error(`[TaskChannel#subscribeToTask] failed to subscribe to ${taskId}: ${response.error}`) - } - }) - } - - public async unsubscribeFromTask(taskId: string, _socket: Socket): Promise { - const task = this.subscribedTasks.get(taskId) - - if (!task) { - return - } - - await this.publish(TaskSocketEvents.LEAVE, { taskId }, (response: LeaveResponse) => { - if (response.success) { - console.log(`[TaskChannel#unsubscribeFromTask] unsubscribed from ${taskId}`) - } else { - console.error(`[TaskChannel#unsubscribeFromTask] failed to unsubscribe from ${taskId}`) - } - - // If we failed to unsubscribe then something is probably wrong and - // we should still discard this task from `subscribedTasks`. - this.removeTaskListeners(task) - this.subscribedTasks.delete(taskId) - }) - } - - private setupTaskListeners(task: TaskLike): void { - if (this.taskListeners.has(task.taskId)) { - console.warn(`[TaskChannel] Listeners already exist for task, removing old listeners for ${task.taskId}`) - this.removeTaskListeners(task) - } - - const listeners = new Map() - - this.eventMapping.forEach(({ from, to, createPayload }) => { - const listener = (...args: unknown[]) => { - const payload = createPayload(task, ...args) - this.publish(TaskSocketEvents.EVENT, payload) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - task.on(from, listener as any) - listeners.set(to, listener) - }) - - this.taskListeners.set(task.taskId, listeners) - } - - private removeTaskListeners(task: TaskLike): void { - const listeners = this.taskListeners.get(task.taskId) - - if (!listeners) { - return - } - - this.eventMapping.forEach(({ from, to }) => { - const listener = listeners.get(to) - if (listener) { - try { - task.off(from, listener as any) // eslint-disable-line @typescript-eslint/no-explicit-any - } catch (error) { - console.error( - `[TaskChannel] task.off(${from}) failed for task ${task.taskId}: ${ - error instanceof Error ? error.message : String(error) - }`, - ) - } - } - }) - - this.taskListeners.delete(task.taskId) - } -} diff --git a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts b/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts deleted file mode 100644 index 188e2cc0294..00000000000 --- a/packages/cloud/src/bridge/__tests__/ExtensionChannel.test.ts +++ /dev/null @@ -1,402 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import type { Socket } from "socket.io-client" - -import { - type TaskProviderLike, - type TaskProviderEvents, - type StaticAppProperties, - RooCodeEventName, - ExtensionBridgeEventName, - ExtensionSocketEvents, -} from "@roo-code/types" - -import { ExtensionChannel } from "../ExtensionChannel.js" - -describe("ExtensionChannel", () => { - let mockSocket: Socket - let mockProvider: TaskProviderLike - let extensionChannel: ExtensionChannel - const instanceId = "test-instance-123" - const userId = "test-user-456" - - const appProperties: StaticAppProperties = { - appName: "roo-code", - appVersion: "1.0.0", - vscodeVersion: "1.0.0", - platform: "darwin", - editorName: "Roo Code", - hostname: "test-host", - } - - // Track registered event listeners - const eventListeners = new Map unknown>>() - - beforeEach(() => { - // Reset the event listeners tracker - eventListeners.clear() - - // Create mock socket - mockSocket = { - emit: vi.fn(), - on: vi.fn(), - off: vi.fn(), - disconnect: vi.fn(), - } as unknown as Socket - - // Create mock provider with event listener tracking - mockProvider = { - cwd: "/test/workspace", - appProperties: { - version: "1.0.0", - extensionVersion: "1.0.0", - }, - gitProperties: undefined, - getCurrentTask: vi.fn().mockReturnValue(undefined), - getCurrentTaskStack: vi.fn().mockReturnValue([]), - getRecentTasks: vi.fn().mockReturnValue([]), - createTask: vi.fn(), - cancelTask: vi.fn(), - clearTask: vi.fn(), - resumeTask: vi.fn(), - getState: vi.fn(), - postStateToWebview: vi.fn(), - postMessageToWebview: vi.fn(), - getTelemetryProperties: vi.fn(), - getMode: vi.fn().mockResolvedValue("code"), - getModes: vi.fn().mockResolvedValue([ - { slug: "code", name: "Code", description: "Code mode" }, - { slug: "architect", name: "Architect", description: "Architect mode" }, - ]), - getProviderProfile: vi.fn().mockResolvedValue("default"), - getProviderProfiles: vi.fn().mockResolvedValue([{ name: "default", description: "Default profile" }]), - on: vi.fn((event: keyof TaskProviderEvents, listener: (...args: unknown[]) => unknown) => { - if (!eventListeners.has(event)) { - eventListeners.set(event, new Set()) - } - eventListeners.get(event)!.add(listener) - return mockProvider - }), - off: vi.fn((event: keyof TaskProviderEvents, listener: (...args: unknown[]) => unknown) => { - const listeners = eventListeners.get(event) - if (listeners) { - listeners.delete(listener) - if (listeners.size === 0) { - eventListeners.delete(event) - } - } - return mockProvider - }), - } as unknown as TaskProviderLike - - // Create extension channel instance - extensionChannel = new ExtensionChannel({ - instanceId, - appProperties, - userId, - provider: mockProvider, - isCloudAgent: false, - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe("Event Listener Management", () => { - it("should register event listeners on initialization", () => { - // Verify that listeners were registered for all expected events - const expectedEvents: RooCodeEventName[] = [ - RooCodeEventName.TaskCreated, - RooCodeEventName.TaskStarted, - RooCodeEventName.TaskCompleted, - RooCodeEventName.TaskAborted, - RooCodeEventName.TaskFocused, - RooCodeEventName.TaskUnfocused, - RooCodeEventName.TaskActive, - RooCodeEventName.TaskInteractive, - RooCodeEventName.TaskResumable, - RooCodeEventName.TaskIdle, - RooCodeEventName.TaskPaused, - RooCodeEventName.TaskUnpaused, - RooCodeEventName.TaskSpawned, - RooCodeEventName.TaskDelegated, - RooCodeEventName.TaskDelegationCompleted, - RooCodeEventName.TaskDelegationResumed, - - RooCodeEventName.TaskUserMessage, - RooCodeEventName.TaskTokenUsageUpdated, - ] - - // Check that on() was called for each event - expect(mockProvider.on).toHaveBeenCalledTimes(expectedEvents.length) - - // Verify each event was registered - expectedEvents.forEach((eventName) => { - expect(mockProvider.on).toHaveBeenCalledWith(eventName, expect.any(Function)) - }) - - // Verify listeners are tracked in our Map - expect(eventListeners.size).toBe(expectedEvents.length) - }) - - it("should remove all event listeners during cleanup", async () => { - // Verify initial state - listeners are registered - const initialListenerCount = eventListeners.size - expect(initialListenerCount).toBeGreaterThan(0) - - // Get the count of listeners for each event before cleanup - const listenerCountsBefore = new Map() - eventListeners.forEach((listeners, event) => { - listenerCountsBefore.set(event, listeners.size) - }) - - // Perform cleanup - await extensionChannel.cleanup(mockSocket) - - // Verify that off() was called for each registered event - expect(mockProvider.off).toHaveBeenCalledTimes(initialListenerCount) - - // Verify all listeners were removed from our tracking Map - expect(eventListeners.size).toBe(0) - - // Verify that the same listener functions that were added were removed - const onCalls = (mockProvider.on as any).mock.calls - const offCalls = (mockProvider.off as any).mock.calls - - // Each on() call should have a corresponding off() call with the same listener - onCalls.forEach(([eventName, listener]: [keyof TaskProviderEvents, any]) => { - const hasMatchingOff = offCalls.some( - ([offEvent, offListener]: [keyof TaskProviderEvents, any]) => - offEvent === eventName && offListener === listener, - ) - expect(hasMatchingOff).toBe(true) - }) - }) - - it("should not have duplicate listeners after multiple channel creations", () => { - // Create a second channel with the same provider - const secondChannel = new ExtensionChannel({ - instanceId: "instance-2", - appProperties, - userId, - provider: mockProvider, - isCloudAgent: false, - }) - - // Each event should have exactly 2 listeners (one from each channel) - eventListeners.forEach((listeners) => { - expect(listeners.size).toBe(2) - }) - - // Clean up the first channel - extensionChannel.cleanup(mockSocket) - - // Each event should now have exactly 1 listener (from the second channel) - eventListeners.forEach((listeners) => { - expect(listeners.size).toBe(1) - }) - - // Clean up the second channel - secondChannel.cleanup(mockSocket) - - // All listeners should be removed - expect(eventListeners.size).toBe(0) - }) - - it("should handle cleanup even if called multiple times", async () => { - // First cleanup - await extensionChannel.cleanup(mockSocket) - const firstOffCallCount = (mockProvider.off as any).mock.calls.length - - // Second cleanup (should be safe to call again) - await extensionChannel.cleanup(mockSocket) - const secondOffCallCount = (mockProvider.off as any).mock.calls.length - - // The second cleanup shouldn't try to remove listeners again - // since the internal Map was cleared - expect(secondOffCallCount).toBe(firstOffCallCount) - }) - - it("should properly forward events to socket when listeners are triggered", async () => { - // Connect the socket to enable publishing - await extensionChannel.onConnect(mockSocket) - - // Clear the mock calls from the connection (which emits a register event) - ;(mockSocket.emit as any).mockClear() - - // Get a listener that was registered for TaskStarted - const taskStartedListeners = eventListeners.get(RooCodeEventName.TaskStarted) - expect(taskStartedListeners).toBeDefined() - expect(taskStartedListeners!.size).toBe(1) - - // Trigger the listener - const listener = Array.from(taskStartedListeners!)[0] - if (listener) { - await listener("test-task-id") - } - - // Verify the event was published to the socket - expect(mockSocket.emit).toHaveBeenCalledWith( - ExtensionSocketEvents.EVENT, - expect.objectContaining({ - type: ExtensionBridgeEventName.TaskStarted, - instance: expect.objectContaining({ - instanceId, - userId, - }), - timestamp: expect.any(Number), - }), - undefined, - ) - }) - - it("should forward delegation events to socket", async () => { - await extensionChannel.onConnect(mockSocket) - ;(mockSocket.emit as any).mockClear() - - const delegatedListeners = eventListeners.get(RooCodeEventName.TaskDelegated) - expect(delegatedListeners).toBeDefined() - expect(delegatedListeners!.size).toBe(1) - - const listener = Array.from(delegatedListeners!)[0] - if (listener) { - await (listener as any)("parent-id", "child-id") - } - - expect(mockSocket.emit).toHaveBeenCalledWith( - ExtensionSocketEvents.EVENT, - expect.objectContaining({ - type: ExtensionBridgeEventName.TaskDelegated, - instance: expect.any(Object), - timestamp: expect.any(Number), - }), - undefined, - ) - }) - - it("should forward TaskDelegationCompleted with correct payload", async () => { - await extensionChannel.onConnect(mockSocket) - ;(mockSocket.emit as any).mockClear() - - const completedListeners = eventListeners.get(RooCodeEventName.TaskDelegationCompleted) - expect(completedListeners).toBeDefined() - - const listener = Array.from(completedListeners!)[0] - if (listener) { - await (listener as any)("parent-1", "child-1", "Summary text") - } - - expect(mockSocket.emit).toHaveBeenCalledWith( - ExtensionSocketEvents.EVENT, - expect.objectContaining({ - type: ExtensionBridgeEventName.TaskDelegationCompleted, - instance: expect.any(Object), - timestamp: expect.any(Number), - payload: expect.objectContaining({ - parentTaskId: "parent-1", - childTaskId: "child-1", - summary: "Summary text", - }), - }), - undefined, - ) - }) - - it("should forward TaskDelegationResumed with correct payload", async () => { - await extensionChannel.onConnect(mockSocket) - ;(mockSocket.emit as any).mockClear() - - const resumedListeners = eventListeners.get(RooCodeEventName.TaskDelegationResumed) - expect(resumedListeners).toBeDefined() - - const listener = Array.from(resumedListeners!)[0] - if (listener) { - await (listener as any)("parent-2", "child-2") - } - - expect(mockSocket.emit).toHaveBeenCalledWith( - ExtensionSocketEvents.EVENT, - expect.objectContaining({ - type: ExtensionBridgeEventName.TaskDelegationResumed, - instance: expect.any(Object), - timestamp: expect.any(Number), - payload: expect.objectContaining({ - parentTaskId: "parent-2", - childTaskId: "child-2", - }), - }), - undefined, - ) - }) - - it("should propagate all three delegation events in order", async () => { - await extensionChannel.onConnect(mockSocket) - ;(mockSocket.emit as any).mockClear() - - // Trigger TaskDelegated - const delegatedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegated)!)[0] - await (delegatedListener as any)("p1", "c1") - - // Trigger TaskDelegationCompleted - const completedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegationCompleted)!)[0] - await (completedListener as any)("p1", "c1", "result") - - // Trigger TaskDelegationResumed - const resumedListener = Array.from(eventListeners.get(RooCodeEventName.TaskDelegationResumed)!)[0] - await (resumedListener as any)("p1", "c1") - - // Verify all three events were emitted - const emittedEvents = (mockSocket.emit as any).mock.calls.map((call: any[]) => call[1]?.type) - expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegated) - expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegationCompleted) - expect(emittedEvents).toContain(ExtensionBridgeEventName.TaskDelegationResumed) - - // Verify correct order: Delegated → Completed → Resumed - const delegatedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegated) - const completedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegationCompleted) - const resumedIdx = emittedEvents.indexOf(ExtensionBridgeEventName.TaskDelegationResumed) - - expect(delegatedIdx).toBeLessThan(completedIdx) - expect(completedIdx).toBeLessThan(resumedIdx) - }) - }) - - describe("Memory Leak Prevention", () => { - it("should not accumulate listeners over multiple connect/disconnect cycles", async () => { - // Simulate multiple connect/disconnect cycles - for (let i = 0; i < 5; i++) { - await extensionChannel.onConnect(mockSocket) - extensionChannel.onDisconnect() - } - - // Listeners should still be the same count (not accumulated) - expect(eventListeners.size).toBe(18) - - // Each event should have exactly 1 listener - eventListeners.forEach((listeners) => { - expect(listeners.size).toBe(1) - }) - }) - - it("should properly clean up heartbeat interval", async () => { - // Spy on setInterval and clearInterval - const setIntervalSpy = vi.spyOn(global, "setInterval") - const clearIntervalSpy = vi.spyOn(global, "clearInterval") - - // Connect to start heartbeat - await extensionChannel.onConnect(mockSocket) - expect(setIntervalSpy).toHaveBeenCalled() - - // Get the interval ID - const intervalId = setIntervalSpy.mock.results[0]?.value - - // Cleanup should stop the heartbeat - await extensionChannel.cleanup(mockSocket) - expect(clearIntervalSpy).toHaveBeenCalledWith(intervalId) - - setIntervalSpy.mockRestore() - clearIntervalSpy.mockRestore() - }) - }) -}) diff --git a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts b/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts deleted file mode 100644 index f4f15266077..00000000000 --- a/packages/cloud/src/bridge/__tests__/TaskChannel.test.ts +++ /dev/null @@ -1,407 +0,0 @@ -/* eslint-disable @typescript-eslint/no-unsafe-function-type */ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import type { Socket } from "socket.io-client" - -import { - type TaskLike, - type ClineMessage, - type StaticAppProperties, - RooCodeEventName, - TaskBridgeEventName, - TaskBridgeCommandName, - TaskSocketEvents, - TaskStatus, -} from "@roo-code/types" - -import { TaskChannel } from "../TaskChannel.js" - -describe("TaskChannel", () => { - let mockSocket: Socket - let taskChannel: TaskChannel - let mockTask: TaskLike - const instanceId = "test-instance-123" - const taskId = "test-task-456" - - const appProperties: StaticAppProperties = { - appName: "roo-code", - appVersion: "1.0.0", - vscodeVersion: "1.0.0", - platform: "darwin", - editorName: "Roo Code", - hostname: "test-host", - } - - beforeEach(() => { - // Create mock socket - mockSocket = { - emit: vi.fn(), - on: vi.fn(), - off: vi.fn(), - disconnect: vi.fn(), - } as unknown as Socket - - // Create mock task with event emitter functionality - const listeners = new Map unknown>>() - mockTask = { - taskId, - taskStatus: TaskStatus.Running, - taskAsk: undefined, - metadata: {}, - on: vi.fn((event: string, listener: (...args: unknown[]) => unknown) => { - if (!listeners.has(event)) { - listeners.set(event, new Set()) - } - listeners.get(event)!.add(listener) - return mockTask - }), - off: vi.fn((event: string, listener: (...args: unknown[]) => unknown) => { - const eventListeners = listeners.get(event) - if (eventListeners) { - eventListeners.delete(listener) - if (eventListeners.size === 0) { - listeners.delete(event) - } - } - return mockTask - }), - approveAsk: vi.fn(), - denyAsk: vi.fn(), - submitUserMessage: vi.fn(), - abortTask: vi.fn(), - // Helper to trigger events in tests - _triggerEvent: (event: string, ...args: any[]) => { - const eventListeners = listeners.get(event) - if (eventListeners) { - eventListeners.forEach((listener) => listener(...args)) - } - }, - _getListenerCount: (event: string) => { - return listeners.get(event)?.size || 0 - }, - } as unknown as TaskLike & { - _triggerEvent: (event: string, ...args: any[]) => void - _getListenerCount: (event: string) => number - } - - // Create task channel instance - taskChannel = new TaskChannel({ - instanceId, - appProperties, - isCloudAgent: false, - }) - }) - - afterEach(() => { - vi.clearAllMocks() - }) - - describe("Event Mapping Refactoring", () => { - it("should use the unified event mapping approach", () => { - // Access the private eventMapping through type assertion - const channel = taskChannel as any - - // Verify eventMapping exists and has the correct structure - expect(channel.eventMapping).toBeDefined() - expect(Array.isArray(channel.eventMapping)).toBe(true) - expect(channel.eventMapping.length).toBe(3) - - // Verify each mapping has the required properties - channel.eventMapping.forEach((mapping: any) => { - expect(mapping).toHaveProperty("from") - expect(mapping).toHaveProperty("to") - expect(mapping).toHaveProperty("createPayload") - expect(typeof mapping.createPayload).toBe("function") - }) - - // Verify specific mappings - expect(channel.eventMapping[0].from).toBe(RooCodeEventName.Message) - expect(channel.eventMapping[0].to).toBe(TaskBridgeEventName.Message) - - expect(channel.eventMapping[1].from).toBe(RooCodeEventName.TaskModeSwitched) - expect(channel.eventMapping[1].to).toBe(TaskBridgeEventName.TaskModeSwitched) - - expect(channel.eventMapping[2].from).toBe(RooCodeEventName.TaskInteractive) - expect(channel.eventMapping[2].to).toBe(TaskBridgeEventName.TaskInteractive) - }) - - it("should setup listeners using the event mapping", async () => { - // Mock the publish method to simulate successful subscription - const channel = taskChannel as any - channel.publish = vi.fn((event: string, data: any, callback?: Function) => { - if (event === TaskSocketEvents.JOIN && callback) { - // Simulate successful join response - callback({ success: true }) - } - return true - }) - - // Connect and subscribe to task - await taskChannel.onConnect(mockSocket) - await channel.subscribeToTask(mockTask, mockSocket) - - // Wait for async operations - await new Promise((resolve) => setTimeout(resolve, 0)) - - // Verify listeners were registered for all mapped events - const task = mockTask as any - expect(task._getListenerCount(RooCodeEventName.Message)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskModeSwitched)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskInteractive)).toBe(1) - }) - - it("should correctly transform Message event payloads", async () => { - // Setup channel with task - const channel = taskChannel as any - let publishCalls: any[] = [] - - channel.publish = vi.fn((event: string, data: any, callback?: Function) => { - publishCalls.push({ event, data }) - - if (event === TaskSocketEvents.JOIN && callback) { - callback({ success: true }) - } - - return true - }) - - await taskChannel.onConnect(mockSocket) - await channel.subscribeToTask(mockTask, mockSocket) - await new Promise((resolve) => setTimeout(resolve, 0)) - - // Clear previous calls - publishCalls = [] - - // Trigger Message event - const messageData = { - action: "test-action", - message: { type: "say", text: "Hello" } as ClineMessage, - } - - ;(mockTask as any)._triggerEvent(RooCodeEventName.Message, messageData) - - // Verify the event was published with correct payload - expect(publishCalls.length).toBe(1) - expect(publishCalls[0]).toEqual({ - event: TaskSocketEvents.EVENT, - data: { - type: TaskBridgeEventName.Message, - taskId: taskId, - action: messageData.action, - message: messageData.message, - }, - }) - }) - - it("should correctly transform TaskModeSwitched event payloads", async () => { - // Setup channel with task - const channel = taskChannel as any - let publishCalls: any[] = [] - - channel.publish = vi.fn((event: string, data: any, callback?: Function) => { - publishCalls.push({ event, data }) - - if (event === TaskSocketEvents.JOIN && callback) { - callback({ success: true }) - } - - return true - }) - - await taskChannel.onConnect(mockSocket) - await channel.subscribeToTask(mockTask, mockSocket) - await new Promise((resolve) => setTimeout(resolve, 0)) - - // Clear previous calls - publishCalls = [] - - // Trigger TaskModeSwitched event - const mode = "architect" - ;(mockTask as any)._triggerEvent(RooCodeEventName.TaskModeSwitched, mode) - - // Verify the event was published with correct payload - expect(publishCalls.length).toBe(1) - expect(publishCalls[0]).toEqual({ - event: TaskSocketEvents.EVENT, - data: { - type: TaskBridgeEventName.TaskModeSwitched, - taskId: taskId, - mode: mode, - }, - }) - }) - - it("should correctly transform TaskInteractive event payloads", async () => { - // Setup channel with task - const channel = taskChannel as any - let publishCalls: any[] = [] - - channel.publish = vi.fn((event: string, data: any, callback?: Function) => { - publishCalls.push({ event, data }) - if (event === TaskSocketEvents.JOIN && callback) { - callback({ success: true }) - } - return true - }) - - await taskChannel.onConnect(mockSocket) - await channel.subscribeToTask(mockTask, mockSocket) - await new Promise((resolve) => setTimeout(resolve, 0)) - - // Clear previous calls - publishCalls = [] - - // Trigger TaskInteractive event - ;(mockTask as any)._triggerEvent(RooCodeEventName.TaskInteractive, taskId) - - // Verify the event was published with correct payload - expect(publishCalls.length).toBe(1) - expect(publishCalls[0]).toEqual({ - event: TaskSocketEvents.EVENT, - data: { - type: TaskBridgeEventName.TaskInteractive, - taskId: taskId, - }, - }) - }) - - it("should properly clean up listeners using event mapping", async () => { - // Setup channel with task - const channel = taskChannel as any - - channel.publish = vi.fn((event: string, data: any, callback?: Function) => { - if (event === TaskSocketEvents.JOIN && callback) { - callback({ success: true }) - } - if (event === TaskSocketEvents.LEAVE && callback) { - callback({ success: true }) - } - return true - }) - - await taskChannel.onConnect(mockSocket) - await channel.subscribeToTask(mockTask, mockSocket) - await new Promise((resolve) => setTimeout(resolve, 0)) - - // Verify listeners are registered - const task = mockTask as any - expect(task._getListenerCount(RooCodeEventName.Message)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskModeSwitched)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskInteractive)).toBe(1) - - // Clean up - await taskChannel.cleanup(mockSocket) - - // Verify all listeners were removed - expect(task._getListenerCount(RooCodeEventName.Message)).toBe(0) - expect(task._getListenerCount(RooCodeEventName.TaskModeSwitched)).toBe(0) - expect(task._getListenerCount(RooCodeEventName.TaskInteractive)).toBe(0) - }) - - it("should handle duplicate listener prevention", async () => { - // Setup channel with task - await taskChannel.onConnect(mockSocket) - - // Subscribe to the same task twice - const channel = taskChannel as any - channel.subscribedTasks.set(taskId, mockTask) - channel.setupTaskListeners(mockTask) - - // Try to setup listeners again (should remove old ones first) - const warnSpy = vi.spyOn(console, "warn") - channel.setupTaskListeners(mockTask) - - // Verify warning was logged - expect(warnSpy).toHaveBeenCalledWith( - `[TaskChannel] Listeners already exist for task, removing old listeners for ${taskId}`, - ) - - // Verify only one set of listeners exists - const task = mockTask as any - expect(task._getListenerCount(RooCodeEventName.Message)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskModeSwitched)).toBe(1) - expect(task._getListenerCount(RooCodeEventName.TaskInteractive)).toBe(1) - - warnSpy.mockRestore() - }) - }) - - describe("Command Handling", () => { - beforeEach(async () => { - // Setup channel with a subscribed task - await taskChannel.onConnect(mockSocket) - const channel = taskChannel as any - channel.subscribedTasks.set(taskId, mockTask) - }) - - it("should handle Message command", async () => { - const command = { - type: TaskBridgeCommandName.Message, - taskId, - timestamp: Date.now(), - payload: { - text: "Hello, world!", - images: ["image1.png"], - }, - } - - await taskChannel.handleCommand(command) - - expect(mockTask.submitUserMessage).toHaveBeenCalledWith( - command.payload.text, - command.payload.images, - undefined, - undefined, - ) - }) - - it("should handle ApproveAsk command", async () => { - const command = { - type: TaskBridgeCommandName.ApproveAsk, - taskId, - timestamp: Date.now(), - payload: { - text: "Approved", - }, - } - - await taskChannel.handleCommand(command) - - expect(mockTask.approveAsk).toHaveBeenCalledWith(command.payload) - }) - - it("should handle DenyAsk command", async () => { - const command = { - type: TaskBridgeCommandName.DenyAsk, - taskId, - timestamp: Date.now(), - payload: { - text: "Denied", - }, - } - - await taskChannel.handleCommand(command) - - expect(mockTask.denyAsk).toHaveBeenCalledWith(command.payload) - }) - - it("should log error for unknown task", async () => { - const errorSpy = vi.spyOn(console, "error") - - const command = { - type: TaskBridgeCommandName.Message, - taskId: "unknown-task", - timestamp: Date.now(), - payload: { - text: "Hello", - }, - } - - await taskChannel.handleCommand(command) - - expect(errorSpy).toHaveBeenCalledWith(`[TaskChannel] Unable to find task unknown-task`) - - errorSpy.mockRestore() - }) - }) -}) diff --git a/packages/cloud/src/bridge/index.ts b/packages/cloud/src/bridge/index.ts deleted file mode 100644 index 94873c09fdf..00000000000 --- a/packages/cloud/src/bridge/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { type BridgeOrchestratorOptions, BridgeOrchestrator } from "./BridgeOrchestrator.js" -export { type SocketTransportOptions, SocketTransport } from "./SocketTransport.js" - -export { BaseChannel } from "./BaseChannel.js" -export { ExtensionChannel } from "./ExtensionChannel.js" -export { TaskChannel } from "./TaskChannel.js" diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 65b92ebc13c..8792176fee0 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -2,7 +2,5 @@ export * from "./config.js" export { CloudService } from "./CloudService.js" -export { BridgeOrchestrator } from "./bridge/index.js" - export { RetryQueue } from "./retry-queue/index.js" export type { QueuedRequest, QueueStats, RetryQueueConfig, RetryQueueEvents } from "./retry-queue/index.js" diff --git a/packages/types/src/__tests__/cloud.test.ts b/packages/types/src/__tests__/cloud.test.ts index 7a6cebd8a51..a8aa2755b2e 100644 --- a/packages/types/src/__tests__/cloud.test.ts +++ b/packages/types/src/__tests__/cloud.test.ts @@ -19,43 +19,9 @@ describe("organizationFeaturesSchema", () => { expect(result.data).toEqual({}) }) - it("should validate with roomoteControlEnabled as true", () => { - const input = { roomoteControlEnabled: true } - const result = organizationFeaturesSchema.safeParse(input) - expect(result.success).toBe(true) - expect(result.data).toEqual(input) - }) - - it("should validate with roomoteControlEnabled as false", () => { - const input = { roomoteControlEnabled: false } - const result = organizationFeaturesSchema.safeParse(input) - expect(result.success).toBe(true) - expect(result.data).toEqual(input) - }) - - it("should reject non-boolean roomoteControlEnabled", () => { - const input = { roomoteControlEnabled: "true" } - const result = organizationFeaturesSchema.safeParse(input) - expect(result.success).toBe(false) - }) - - it("should allow additional properties (for future extensibility)", () => { - const input = { roomoteControlEnabled: true, futureProperty: "test" } - const result = organizationFeaturesSchema.safeParse(input) - expect(result.success).toBe(true) - expect(result.data?.roomoteControlEnabled).toBe(true) - // Note: Additional properties are stripped by Zod, which is expected behavior - }) - it("should have correct TypeScript type", () => { - // Type-only test to ensure TypeScript compilation - const features: OrganizationFeatures = { - roomoteControlEnabled: true, - } - expect(features.roomoteControlEnabled).toBe(true) - const emptyFeatures: OrganizationFeatures = {} - expect(emptyFeatures.roomoteControlEnabled).toBeUndefined() + expect(emptyFeatures).toEqual({}) }) }) @@ -85,43 +51,7 @@ describe("organizationSettingsSchema with features", () => { expect(result.data?.features).toEqual({}) }) - it("should validate with features.roomoteControlEnabled as true", () => { - const input = { - ...validBaseSettings, - features: { - roomoteControlEnabled: true, - }, - } - const result = organizationSettingsSchema.safeParse(input) - expect(result.success).toBe(true) - expect(result.data?.features?.roomoteControlEnabled).toBe(true) - }) - - it("should validate with features.roomoteControlEnabled as false", () => { - const input = { - ...validBaseSettings, - features: { - roomoteControlEnabled: false, - }, - } - const result = organizationSettingsSchema.safeParse(input) - expect(result.success).toBe(true) - expect(result.data?.features?.roomoteControlEnabled).toBe(false) - }) - - it("should reject invalid features object", () => { - const input = { - ...validBaseSettings, - features: { - roomoteControlEnabled: "invalid", - }, - } - const result = organizationSettingsSchema.safeParse(input) - expect(result.success).toBe(false) - }) - it("should have correct TypeScript type for features", () => { - // Type-only test to ensure TypeScript compilation const settings: OrganizationSettings = { version: 1, defaultSettings: {}, @@ -129,11 +59,9 @@ describe("organizationSettingsSchema with features", () => { allowAll: true, providers: {}, }, - features: { - roomoteControlEnabled: true, - }, + features: {}, } - expect(settings.features?.roomoteControlEnabled).toBe(true) + expect(settings.features).toEqual({}) const settingsWithoutFeatures: OrganizationSettings = { version: 1, @@ -163,9 +91,7 @@ describe("organizationSettingsSchema with features", () => { }, }, }, - features: { - roomoteControlEnabled: true, - }, + features: {}, hiddenMcps: ["test-mcp"], hideMarketplaceMcps: true, mcps: [], @@ -413,7 +339,6 @@ describe("organizationCloudSettingsSchema with llmEnhancedFeaturesEnabled", () = describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { it("should validate without llmEnhancedFeaturesEnabled property", () => { const input = { - extensionBridgeEnabled: true, taskSyncEnabled: true, } const result = userSettingsConfigSchema.safeParse(input) @@ -423,7 +348,6 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { it("should validate with llmEnhancedFeaturesEnabled as true", () => { const input = { - extensionBridgeEnabled: true, taskSyncEnabled: true, llmEnhancedFeaturesEnabled: true, } @@ -434,7 +358,6 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { it("should validate with llmEnhancedFeaturesEnabled as false", () => { const input = { - extensionBridgeEnabled: true, taskSyncEnabled: true, llmEnhancedFeaturesEnabled: false, } @@ -454,15 +377,12 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { it("should have correct TypeScript type", () => { // Type-only test to ensure TypeScript compilation const settings: UserSettingsConfig = { - extensionBridgeEnabled: true, taskSyncEnabled: true, llmEnhancedFeaturesEnabled: true, } expect(settings.llmEnhancedFeaturesEnabled).toBe(true) - const settingsWithoutLlmFeatures: UserSettingsConfig = { - extensionBridgeEnabled: false, - } + const settingsWithoutLlmFeatures: UserSettingsConfig = {} expect(settingsWithoutLlmFeatures.llmEnhancedFeaturesEnabled).toBeUndefined() }) diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 206a5647b3e..f81845c782a 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -41,7 +41,6 @@ export interface CloudUserInfo { organizationName?: string organizationRole?: string organizationImageUrl?: string - extensionBridgeEnabled?: boolean } /** @@ -142,9 +141,7 @@ export type OrganizationCloudSettings = z.infer @@ -170,14 +167,11 @@ export type OrganizationSettings = z.infer * User Settings Schemas */ -export const userFeaturesSchema = z.object({ - roomoteControlEnabled: z.boolean().optional(), -}) +export const userFeaturesSchema = z.object({}) export type UserFeatures = z.infer export const userSettingsConfigSchema = z.object({ - extensionBridgeEnabled: z.boolean().optional(), taskSyncEnabled: z.boolean().optional(), llmEnhancedFeaturesEnabled: z.boolean().optional(), }) diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index fa2f04c0e5d..26216b3cc41 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -399,9 +399,7 @@ export type ExtensionState = Pick< mcpServers?: McpServer[] hasSystemPromptOverride?: boolean mdmCompliant?: boolean - remoteControlEnabled: boolean taskSyncEnabled: boolean - featureRoomoteControlEnabled: boolean openAiCodexIsAuthenticated?: boolean debug?: boolean } @@ -498,7 +496,6 @@ export interface WebviewMessage { | "deleteMessageConfirm" | "submitEditedMessage" | "editMessageConfirm" - | "remoteControlEnabled" | "taskSyncEnabled" | "searchCommits" | "setApiConfigPassword" diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 0bdbb26d462..96a9009ccf2 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -51,8 +51,6 @@ vi.mock("fs", () => ({ existsSync: vi.fn().mockReturnValue(false), })) -const mockBridgeOrchestratorDisconnect = vi.fn().mockResolvedValue(undefined) - const mockCloudServiceInstance = { off: vi.fn(), on: vi.fn(), @@ -71,9 +69,6 @@ vi.mock("@roo-code/cloud", () => ({ return mockCloudServiceInstance }, }, - BridgeOrchestrator: { - disconnect: mockBridgeOrchestratorDisconnect, - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) @@ -181,19 +176,13 @@ vi.mock("../i18n", () => ({ t: vi.fn((key) => key), })) -// Mock ClineProvider - remoteControlEnabled must call BridgeOrchestrator.disconnect for the test +// Mock ClineProvider vi.mock("../core/webview/ClineProvider", async () => { - const { BridgeOrchestrator } = await import("@roo-code/cloud") const mockInstance = { resolveWebviewView: vi.fn(), postMessageToWebview: vi.fn(), postStateToWebview: vi.fn(), getState: vi.fn().mockResolvedValue({}), - remoteControlEnabled: vi.fn().mockImplementation(async (enabled: boolean) => { - if (!enabled) { - await BridgeOrchestrator.disconnect() - } - }), initializeCloudProfileSyncWhenReady: vi.fn().mockResolvedValue(undefined), providerSettingsManager: {}, contextProxy: { getGlobalState: vi.fn() }, @@ -229,7 +218,6 @@ describe("extension.ts", () => { beforeEach(() => { vi.clearAllMocks() - mockBridgeOrchestratorDisconnect.mockClear() mockContext = { extensionPath: "/test/path", @@ -273,73 +261,6 @@ describe("extension.ts", () => { expect(dotenvx.config).toHaveBeenCalledTimes(1) }) - test("authStateChangedHandler calls BridgeOrchestrator.disconnect when logged-out event fires", async () => { - const { CloudService, BridgeOrchestrator } = await import("@roo-code/cloud") - - // Capture the auth state changed handler. - vi.mocked(CloudService.createInstance).mockImplementation(async (_context, _logger, handlers) => { - if (handlers?.["auth-state-changed"]) { - authStateChangedHandler = handlers["auth-state-changed"] - } - - return { - off: vi.fn(), - on: vi.fn(), - telemetryClient: null, - hasActiveSession: vi.fn().mockReturnValue(false), - authService: null, - } as any - }) - - // Activate the extension. - const { activate } = await import("../extension") - await activate(mockContext) - - // Verify handler was registered. - expect(authStateChangedHandler).toBeDefined() - - // Trigger logout. - await authStateChangedHandler!({ - state: "logged-out" as AuthState, - previousState: "logged-in" as AuthState, - }) - - // Verify BridgeOrchestrator.disconnect was called - expect(mockBridgeOrchestratorDisconnect).toHaveBeenCalled() - }) - - test("authStateChangedHandler does not call BridgeOrchestrator.disconnect for other states", async () => { - const { CloudService } = await import("@roo-code/cloud") - - // Capture the auth state changed handler. - vi.mocked(CloudService.createInstance).mockImplementation(async (_context, _logger, handlers) => { - if (handlers?.["auth-state-changed"]) { - authStateChangedHandler = handlers["auth-state-changed"] - } - - return { - off: vi.fn(), - on: vi.fn(), - telemetryClient: null, - hasActiveSession: vi.fn().mockReturnValue(false), - authService: null, - } as any - }) - - // Activate the extension. - const { activate } = await import("../extension") - await activate(mockContext) - - // Trigger login. - await authStateChangedHandler!({ - state: "logged-in" as AuthState, - previousState: "logged-out" as AuthState, - }) - - // Verify BridgeOrchestrator.disconnect was NOT called. - expect(mockBridgeOrchestratorDisconnect).not.toHaveBeenCalled() - }) - describe("Roo model cache refresh on auth state change (ROO-202)", () => { beforeEach(() => { vi.resetModules() diff --git a/src/__tests__/single-open-invariant.spec.ts b/src/__tests__/single-open-invariant.spec.ts index 7fac886030f..b7e1b99d7c4 100644 --- a/src/__tests__/single-open-invariant.spec.ts +++ b/src/__tests__/single-open-invariant.spec.ts @@ -13,7 +13,6 @@ vi.mock("../core/task/Task", () => { public parentTask?: any public apiConfiguration: any public rootTask?: any - public enableBridge?: boolean constructor(opts: any) { this.taskId = opts.historyItem?.id ?? `task-${Math.random().toString(36).slice(2, 8)}` this.parentTask = opts.parentTask @@ -49,7 +48,6 @@ describe("Single-open-task invariant", () => { enableCheckpoints: true, checkpointTimeout: 60, cloudUserInfo: null, - remoteControlEnabled: false, }), removeClineFromStack, addClineToStack, diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d5e9aa0cfb6..8d3599b3cde 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -54,7 +54,7 @@ import { countEnabledMcpTools, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" +import { CloudService } from "@roo-code/cloud" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" @@ -144,7 +144,6 @@ export interface TaskOptions extends CreateTaskOptions { apiConfiguration: ProviderSettings enableCheckpoints?: boolean checkpointTimeout?: number - enableBridge?: boolean consecutiveMistakeLimit?: number task?: string images?: string[] @@ -337,9 +336,6 @@ export class Task extends EventEmitter implements TaskLike { checkpointService?: RepoPerTaskCheckpointService checkpointServiceInitializing = false - // Task Bridge - enableBridge: boolean - // Message Queue Service public readonly messageQueueService: MessageQueueService private messageQueueStateChangedHandler: (() => void) | undefined @@ -551,7 +547,6 @@ export class Task extends EventEmitter implements TaskLike { apiConfiguration, enableCheckpoints = true, checkpointTimeout = DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, - enableBridge = false, consecutiveMistakeLimit = DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, task, images, @@ -645,7 +640,6 @@ export class Task extends EventEmitter implements TaskLike { this.diffViewProvider = new DiffViewProvider(this.cwd, this) this.enableCheckpoints = enableCheckpoints this.checkpointTimeout = checkpointTimeout - this.enableBridge = enableBridge this.parentTask = parentTask this.taskNumber = taskNumber @@ -2032,16 +2026,6 @@ export class Task extends EventEmitter implements TaskLike { private async startTask(task?: string, images?: string[]): Promise { try { - if (this.enableBridge) { - try { - await BridgeOrchestrator.subscribeToTask(this) - } catch (error) { - console.error( - `[Task#startTask] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - // `conversationHistory` (for API) and `clineMessages` (for webview) // need to be in sync. // If the extension process were killed, then on restart the @@ -2105,16 +2089,6 @@ export class Task extends EventEmitter implements TaskLike { } private async resumeTaskFromHistory() { - if (this.enableBridge) { - try { - await BridgeOrchestrator.subscribeToTask(this) - } catch (error) { - console.error( - `[Task#resumeTaskFromHistory] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - const modifiedClineMessages = await this.getSavedClineMessages() // Remove any resume messages that may have been added before. @@ -2424,16 +2398,6 @@ export class Task extends EventEmitter implements TaskLike { console.error("Error removing event listeners:", error) } - if (this.enableBridge) { - BridgeOrchestrator.getInstance() - ?.unsubscribeFromTask(this.taskId) - .catch((error) => - console.error( - `[Task#dispose] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}`, - ), - ) - } - // Release any terminals associated with this task. try { // Release any terminals associated with this task. diff --git a/src/core/task/__tests__/grounding-sources.test.ts b/src/core/task/__tests__/grounding-sources.test.ts index f6874a581e4..e80452fec5f 100644 --- a/src/core/task/__tests__/grounding-sources.test.ts +++ b/src/core/task/__tests__/grounding-sources.test.ts @@ -80,9 +80,6 @@ vi.mock("@roo-code/cloud", () => ({ CloudService: { isEnabled: () => false, }, - BridgeOrchestrator: { - subscribeToTask: vi.fn(), - }, })) // Mock delay to prevent actual delays diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index 2a3978e9111..ee06c22a1a8 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -80,9 +80,6 @@ vi.mock("@roo-code/cloud", () => ({ CloudService: { isEnabled: () => false, }, - BridgeOrchestrator: { - subscribeToTask: vi.fn(), - }, })) // Mock delay to prevent actual delays diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 84cc76825f7..20954fc4ec8 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -48,7 +48,7 @@ import { } from "@roo-code/types" import { aggregateTaskCostsRecursive, type AggregatedCosts } from "./aggregateTaskCosts" import { TelemetryService } from "@roo-code/telemetry" -import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud" +import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud" import { Package } from "../../shared/package" import { findLast } from "../../shared/array" @@ -986,7 +986,6 @@ export class ClineProvider workspacePath: historyItem.workspace, onCreated: this.taskCreationCallback, startTask: options?.startTask ?? true, - enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, taskSyncEnabled), // Preserve the status from the history item to avoid overwriting it when the task saves messages initialStatus: historyItem.status, }) @@ -2065,11 +2064,9 @@ export class ClineProvider includeCurrentCost, maxGitStatusFiles, taskSyncEnabled, - remoteControlEnabled, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, - featureRoomoteControlEnabled, isBrowserSessionActive, } = await this.getState() @@ -2227,11 +2224,9 @@ export class ClineProvider includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, taskSyncEnabled, - remoteControlEnabled, imageGenerationProvider, openRouterImageApiKey, openRouterImageGenerationSelectedModel, - featureRoomoteControlEnabled, openAiCodexIsAuthenticated: await (async () => { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") @@ -2459,32 +2454,9 @@ export class ClineProvider includeCurrentCost: stateValues.includeCurrentCost ?? true, maxGitStatusFiles: stateValues.maxGitStatusFiles ?? 0, taskSyncEnabled, - remoteControlEnabled: (() => { - try { - const cloudSettings = CloudService.instance.getUserSettings() - return cloudSettings?.settings?.extensionBridgeEnabled ?? false - } catch (error) { - console.error( - `[getState] failed to get remote control setting from cloud: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - })(), imageGenerationProvider: stateValues.imageGenerationProvider, openRouterImageApiKey: stateValues.openRouterImageApiKey, openRouterImageGenerationSelectedModel: stateValues.openRouterImageGenerationSelectedModel, - featureRoomoteControlEnabled: (() => { - try { - const userSettings = CloudService.instance.getUserSettings() - const hasOrganization = cloudUserInfo?.organizationId != null - return hasOrganization || (userSettings?.features?.roomoteControlEnabled ?? false) - } catch (error) { - console.error( - `[getState] failed to get featureRoomoteControlEnabled: ${error instanceof Error ? error.message : String(error)}`, - ) - return false - } - })(), } } @@ -2656,64 +2628,6 @@ export class ClineProvider return true } - public async remoteControlEnabled(enabled: boolean) { - if (!enabled) { - await BridgeOrchestrator.disconnect() - return - } - - const userInfo = CloudService.instance.getUserInfo() - - if (!userInfo) { - this.log("[ClineProvider#remoteControlEnabled] Failed to get user info, disconnecting") - await BridgeOrchestrator.disconnect() - return - } - - const config = await CloudService.instance.cloudAPI?.bridgeConfig().catch(() => undefined) - - if (!config) { - this.log("[ClineProvider#remoteControlEnabled] Failed to get bridge config") - return - } - - await BridgeOrchestrator.connectOrDisconnect(userInfo, enabled, { - ...config, - provider: this, - sessionId: vscode.env.sessionId, - isCloudAgent: CloudService.instance.isCloudAgent, - }) - - const bridge = BridgeOrchestrator.getInstance() - - if (bridge) { - const currentTask = this.getCurrentTask() - - if (currentTask && !currentTask.enableBridge) { - try { - currentTask.enableBridge = true - await BridgeOrchestrator.subscribeToTask(currentTask) - } catch (error) { - const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}` - this.log(message) - console.error(message) - } - } - } else { - for (const task of this.clineStack) { - if (task.enableBridge) { - try { - await BridgeOrchestrator.getInstance()?.unsubscribeFromTask(task.taskId) - } catch (error) { - const message = `[ClineProvider#remoteControlEnabled] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}` - this.log(message) - console.error(message) - } - } - } - } - } - /** * Gets the CodeIndexManager for the current active workspace * @returns CodeIndexManager instance for the current workspace or the default one @@ -2869,15 +2783,8 @@ export class ClineProvider } } - const { - apiConfiguration, - organizationAllowList, - enableCheckpoints, - checkpointTimeout, - experiments, - cloudUserInfo, - remoteControlEnabled, - } = await this.getState() + const { apiConfiguration, organizationAllowList, enableCheckpoints, checkpointTimeout, experiments } = + await this.getState() // Single-open-task invariant: always enforce for user-initiated top-level tasks if (!parentTask) { @@ -2905,7 +2812,6 @@ export class ClineProvider parentTask, taskNumber: this.clineStack.length + 1, onCreated: this.taskCreationCallback, - enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled), initialTodos: options.initialTodos, ...options, }) diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 04f5d577929..3e18a0a0a43 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -129,9 +129,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index 8533865031e..4bb01347a3d 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -78,9 +78,6 @@ vi.mock("@roo-code/cloud", () => ({ isAuthenticated: vi.fn().mockReturnValue(false), }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://api.roo-code.com"), })) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b65b137597c..43a00f2d569 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -330,9 +330,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) @@ -582,9 +579,7 @@ describe("ClineProvider", () => { diagnosticsEnabled: true, openRouterImageApiKey: undefined, openRouterImageGenerationSelectedModel: undefined, - remoteControlEnabled: false, taskSyncEnabled: false, - featureRoomoteControlEnabled: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, } diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 27aab0b7da2..fe7d9d46eaa 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -112,9 +112,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 80b14746a76..b6a1472f0d9 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -114,9 +114,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index f5e6afa7f06..fbf5e9159eb 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -233,9 +233,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 75f1ce0ff4a..d880be58e7b 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1457,25 +1457,10 @@ export const webviewMessageHandler = async ( } break } - case "remoteControlEnabled": - try { - await CloudService.instance.updateUserSettings({ extensionBridgeEnabled: message.bool ?? false }) - } catch (error) { - provider.log( - `CloudService#updateUserSettings failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - break - case "taskSyncEnabled": const enabled = message.bool ?? false const updatedSettings: Partial = { taskSyncEnabled: enabled } - // If disabling task sync, also disable remote control. - if (!enabled) { - updatedSettings.extensionBridgeEnabled = false - } - try { await CloudService.instance.updateUserSettings(updatedSettings) } catch (error) { diff --git a/src/extension.ts b/src/extension.ts index 262bf623378..569f7aa627d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,7 +18,7 @@ if (fs.existsSync(envPath)) { } import type { CloudUserInfo, AuthState } from "@roo-code/types" -import { CloudService, BridgeOrchestrator } from "@roo-code/cloud" +import { CloudService } from "@roo-code/cloud" import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry" import { customToolRegistry } from "@roo-code/core" @@ -200,16 +200,6 @@ export async function activate(context: vscode.ExtensionContext) { authStateChangedHandler = async (data: { state: AuthState; previousState: AuthState }) => { postStateListener() - if (data.state === "logged-out") { - try { - await provider.remoteControlEnabled(false) - } catch (error) { - cloudLogger( - `[authStateChangedHandler] remoteControlEnabled(false) failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - // Handle Roo models cache based on auth state (ROO-202) const handleRooModelsCache = async () => { try { @@ -265,36 +255,11 @@ export async function activate(context: vscode.ExtensionContext) { } settingsUpdatedHandler = async () => { - const userInfo = CloudService.instance.getUserInfo() - - if (userInfo && CloudService.instance.cloudAPI) { - try { - provider.remoteControlEnabled(CloudService.instance.isTaskSyncEnabled()) - } catch (error) { - cloudLogger( - `[settingsUpdatedHandler] remoteControlEnabled failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } - } - postStateListener() } userInfoHandler = async ({ userInfo }: { userInfo: CloudUserInfo }) => { postStateListener() - - if (!CloudService.instance.cloudAPI) { - cloudLogger("[userInfoHandler] CloudAPI is not initialized") - return - } - - try { - provider.remoteControlEnabled(CloudService.instance.isTaskSyncEnabled()) - } catch (error) { - cloudLogger( - `[userInfoHandler] remoteControlEnabled failed: ${error instanceof Error ? error.message : String(error)}`, - ) - } } cloudService = await CloudService.createInstance(context, cloudLogger, { @@ -481,12 +446,6 @@ export async function deactivate() { } } - const bridge = BridgeOrchestrator.getInstance() - - if (bridge) { - await bridge.disconnect() - } - await McpServerManager.cleanup(extensionContext) TelemetryService.instance.shutdown() TerminalRegistry.cleanup() diff --git a/webview-ui/src/components/chat/CloudTaskButton.tsx b/webview-ui/src/components/chat/CloudTaskButton.tsx index 672bf020bb1..effe57c6d74 100644 --- a/webview-ui/src/components/chat/CloudTaskButton.tsx +++ b/webview-ui/src/components/chat/CloudTaskButton.tsx @@ -78,7 +78,7 @@ export const CloudTaskButton = ({ item, disabled = false }: CloudTaskButtonProps } }, [dialogOpen, canvasElement, generateQRCode]) - if (!cloudUserInfo?.extensionBridgeEnabled || !item?.id) { + if (!cloudUserInfo || !item?.id) { return null } diff --git a/webview-ui/src/components/chat/__tests__/CloudTaskButton.spec.tsx b/webview-ui/src/components/chat/__tests__/CloudTaskButton.spec.tsx index fc2b9f025ec..87382434f5d 100644 --- a/webview-ui/src/components/chat/__tests__/CloudTaskButton.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/CloudTaskButton.spec.tsx @@ -68,7 +68,6 @@ describe("CloudTaskButton", () => { cloudUserInfo: { id: "test-user", email: "test@example.com", - extensionBridgeEnabled: true, }, cloudApiUrl: "https://app.roocode.com", } as any) @@ -87,7 +86,6 @@ describe("CloudTaskButton", () => { cloudUserInfo: { id: "test-user", email: "test@example.com", - extensionBridgeEnabled: false, }, cloudApiUrl: "https://app.roocode.com", } as any) diff --git a/webview-ui/src/components/cloud/CloudView.tsx b/webview-ui/src/components/cloud/CloudView.tsx index e8ed9e163c2..997997ccd0c 100644 --- a/webview-ui/src/components/cloud/CloudView.tsx +++ b/webview-ui/src/components/cloud/CloudView.tsx @@ -9,7 +9,7 @@ import { vscode } from "@src/utils/vscode" import { telemetryClient } from "@src/utils/TelemetryClient" import { ToggleSwitch } from "@/components/ui/toggle-switch" import { renderCloudBenefitsContent } from "./CloudUpsellDialog" -import { ArrowRight, CircleAlert, Info, Lock, TriangleAlert } from "lucide-react" +import { ArrowRight, Info, Lock, TriangleAlert } from "lucide-react" import { cn } from "@/lib/utils" import { Tab, TabContent } from "../common/Tab" import { Button } from "@/components/ui/button" @@ -28,13 +28,7 @@ type CloudViewProps = { export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, organizations = [] }: CloudViewProps) => { const { t } = useAppTranslation() - const { - remoteControlEnabled, - setRemoteControlEnabled, - taskSyncEnabled, - setTaskSyncEnabled, - featureRoomoteControlEnabled, - } = useExtensionState() + const { taskSyncEnabled, setTaskSyncEnabled } = useExtensionState() const wasAuthenticatedRef = useRef(false) const timeoutRef = useRef(null) const manualUrlInputRef = useRef(null) @@ -144,12 +138,6 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, organization } } - const handleRemoteControlToggle = () => { - const newValue = !remoteControlEnabled - setRemoteControlEnabled(newValue) - vscode.postMessage({ type: "remoteControlEnabled", bool: newValue }) - } - const handleTaskSyncToggle = () => { const newValue = !taskSyncEnabled setTaskSyncEnabled(newValue) @@ -219,34 +207,6 @@ export const CloudView = ({ userInfo, isAuthenticated, cloudApiUrl, organization
{t("cloud:taskSyncDescription")}
- - {/* Remote Control Toggle - Only shown when both extensionBridgeEnabled and featureRoomoteControlEnabled are true */} - {userInfo?.extensionBridgeEnabled && featureRoomoteControlEnabled && ( - <> -
- - - {t("cloud:remoteControl")} - -
-
- {t("cloud:remoteControlDescription")} - {!taskSyncEnabled && ( -
- - {t("cloud:remoteControlRequiresTaskSync")} -
- )} -
- - )}
diff --git a/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx b/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx index 87f5da9c651..120579e7327 100644 --- a/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx +++ b/webview-ui/src/components/cloud/__tests__/CloudView.spec.tsx @@ -23,10 +23,6 @@ vi.mock("@src/i18n/TranslationContext", () => ({ "cloud:taskSync": "Task sync", "cloud:taskSyncDescription": "Sync your tasks for viewing and sharing on Roo Code Cloud", "cloud:taskSyncManagedByOrganization": "Task sync is managed by your organization", - "cloud:remoteControl": "Roomote Control", - "cloud:remoteControlDescription": - "Enable following and interacting with tasks in this workspace with Roo Code Cloud", - "cloud:remoteControlRequiresTaskSync": "Task sync must be enabled to use Roomote Control", "cloud:usageMetricsAlwaysReported": "Model usage info is always reported when logged in", "cloud:profilePicture": "Profile picture", "cloud:cloudUrlPillLabel": "Roo Code Cloud URL: ", @@ -52,12 +48,8 @@ vi.mock("@src/utils/TelemetryClient", () => ({ // Mock the extension state context const mockExtensionState = { - remoteControlEnabled: false, - setRemoteControlEnabled: vi.fn(), taskSyncEnabled: true, setTaskSyncEnabled: vi.fn(), - featureRoomoteControlEnabled: true, // Default to true for tests - setFeatureRoomoteControlEnabled: vi.fn(), } vi.mock("@src/context/ExtensionStateContext", () => ({ @@ -116,82 +108,6 @@ describe("CloudView", () => { expect(screen.getByText("test@example.com")).toBeInTheDocument() }) - it("should display remote control toggle when user has extension bridge enabled and roomote control enabled", () => { - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - extensionBridgeEnabled: true, - } - - render() - - // Check that the remote control toggle is displayed - expect(screen.getByTestId("remote-control-toggle")).toBeInTheDocument() - expect(screen.getByText("Roomote Control")).toBeInTheDocument() - expect( - screen.getByText("Enable following and interacting with tasks in this workspace with Roo Code Cloud"), - ).toBeInTheDocument() - }) - - it("should not display remote control toggle when user does not have extension bridge enabled", () => { - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - extensionBridgeEnabled: false, - } - - render() - - // Check that the remote control toggle is NOT displayed - expect(screen.queryByTestId("remote-control-toggle")).not.toBeInTheDocument() - expect(screen.queryByText("Roomote Control")).not.toBeInTheDocument() - }) - - it("should not display remote control toggle when roomote control is disabled", () => { - // Temporarily override the mock for this specific test - const originalFeatureRoomoteControlEnabled = mockExtensionState.featureRoomoteControlEnabled - mockExtensionState.featureRoomoteControlEnabled = false - - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - extensionBridgeEnabled: true, // Bridge enabled but roomote control disabled - } - - render() - - // Check that the remote control toggle is NOT displayed - expect(screen.queryByTestId("remote-control-toggle")).not.toBeInTheDocument() - expect(screen.queryByText("Roomote Control")).not.toBeInTheDocument() - - // Restore the original value - mockExtensionState.featureRoomoteControlEnabled = originalFeatureRoomoteControlEnabled - }) - - it("should display remote control toggle for organization users (simulating backend logic)", () => { - // This test simulates what the ClineProvider would do: - // Organization users are treated as having featureRoomoteControlEnabled true - const originalFeatureRoomoteControlEnabled = mockExtensionState.featureRoomoteControlEnabled - mockExtensionState.featureRoomoteControlEnabled = true // Simulating ClineProvider logic for org users - - const mockUserInfo = { - name: "Test User", - email: "test@example.com", - organizationId: "org-123", // User is in an organization - extensionBridgeEnabled: true, - } - - render() - - // Check that the remote control toggle IS displayed for organization users - // (The ClineProvider would set featureRoomoteControlEnabled to true for org users) - expect(screen.getByTestId("remote-control-toggle")).toBeInTheDocument() - expect(screen.getByText("Roomote Control")).toBeInTheDocument() - - // Restore the original value - mockExtensionState.featureRoomoteControlEnabled = originalFeatureRoomoteControlEnabled - }) - it("should not display cloud URL pill when pointing to production", () => { const mockUserInfo = { name: "Test User", diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 47110d08751..4fd000cd449 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -101,12 +101,8 @@ export interface ExtensionStateContextType extends ExtensionState { setTerminalOutputPreviewSize: (value: "small" | "medium" | "large") => void mcpEnabled: boolean setMcpEnabled: (value: boolean) => void - remoteControlEnabled: boolean - setRemoteControlEnabled: (value: boolean) => void taskSyncEnabled: boolean setTaskSyncEnabled: (value: boolean) => void - featureRoomoteControlEnabled: boolean - setFeatureRoomoteControlEnabled: (value: boolean) => void setCurrentApiConfigName: (value: string) => void setListApiConfigMeta: (value: ProviderSettingsEntry[]) => void mode: Mode @@ -208,9 +204,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode screenshotQuality: 75, terminalShellIntegrationTimeout: 4000, mcpEnabled: true, - remoteControlEnabled: false, taskSyncEnabled: false, - featureRoomoteControlEnabled: false, currentApiConfigName: "default", listApiConfigMeta: [], mode: defaultModeSlug, @@ -501,9 +495,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode profileThresholds: state.profileThresholds ?? {}, alwaysAllowFollowupQuestions, followupAutoApproveTimeoutMs, - remoteControlEnabled: state.remoteControlEnabled ?? false, taskSyncEnabled: state.taskSyncEnabled, - featureRoomoteControlEnabled: state.featureRoomoteControlEnabled ?? false, setExperimentEnabled: (id, enabled) => setState((prevState) => ({ ...prevState, experiments: { ...prevState.experiments, [id]: enabled } })), setApiConfiguration, @@ -545,10 +537,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode setState((prevState) => ({ ...prevState, terminalShellIntegrationDisabled: value })), setTerminalZdotdir: (value) => setState((prevState) => ({ ...prevState, terminalZdotdir: value })), setMcpEnabled: (value) => setState((prevState) => ({ ...prevState, mcpEnabled: value })), - setRemoteControlEnabled: (value) => setState((prevState) => ({ ...prevState, remoteControlEnabled: value })), setTaskSyncEnabled: (value) => setState((prevState) => ({ ...prevState, taskSyncEnabled: value }) as any), - setFeatureRoomoteControlEnabled: (value) => - setState((prevState) => ({ ...prevState, featureRoomoteControlEnabled: value })), setCurrentApiConfigName: (value) => setState((prevState) => ({ ...prevState, currentApiConfigName: value })), setListApiConfigMeta, setMode: (value: Mode) => setState((prevState) => ({ ...prevState, mode: value })), diff --git a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx index bfc60f1ace4..c0c5485be2d 100644 --- a/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx +++ b/webview-ui/src/context/__tests__/ExtensionStateContext.spec.tsx @@ -213,9 +213,7 @@ describe("mergeExtensionState", () => { hasOpenedModeSelector: false, // Add the new required property maxImageFileSize: 5, maxTotalImageSize: 20, - remoteControlEnabled: false, taskSyncEnabled: false, - featureRoomoteControlEnabled: false, isBrowserSessionActive: false, checkpointTimeout: DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, // Add the checkpoint timeout property } diff --git a/webview-ui/src/i18n/locales/ca/cloud.json b/webview-ui/src/i18n/locales/ca/cloud.json index 077cdf42fa3..7c0eeff82b0 100644 --- a/webview-ui/src/i18n/locales/ca/cloud.json +++ b/webview-ui/src/i18n/locales/ca/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visita Roo Code Cloud", "taskSync": "Sincronització de tasques", "taskSyncDescription": "Sincronitza les teves tasques per veure-les i compartir-les a Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Permet controlar tasques des de Roo Code Cloud", - "remoteControlRequiresTaskSync": "La sincronització de tasques ha d'estar habilitada per utilitzar Roomote Control", "taskSyncManagedByOrganization": "La sincronització de tasques la gestiona la teva organització", "usageMetricsAlwaysReported": "La informació d'ús del model sempre es reporta quan s'ha iniciat sessió", "cloudUrlPillLabel": "URL de Roo Code Cloud", diff --git a/webview-ui/src/i18n/locales/de/cloud.json b/webview-ui/src/i18n/locales/de/cloud.json index 70e480cbc4e..7ac165b4972 100644 --- a/webview-ui/src/i18n/locales/de/cloud.json +++ b/webview-ui/src/i18n/locales/de/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Roo Code Cloud besuchen", "taskSync": "Aufgabensynchronisierung", "taskSyncDescription": "Synchronisiere deine Aufgaben zum Anzeigen und Teilen in Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Ermöglicht die Steuerung von Aufgaben über Roo Code Cloud", - "remoteControlRequiresTaskSync": "Die Aufgabensynchronisierung muss aktiviert sein, um Roomote Control zu verwenden", "taskSyncManagedByOrganization": "Die Aufgabensynchronisierung wird von deiner Organisation verwaltet", "usageMetricsAlwaysReported": "Modellnutzungsinformationen werden bei Anmeldung immer gemeldet", "authWaiting": "Warte auf Abschluss der Authentifizierung...", diff --git a/webview-ui/src/i18n/locales/en/cloud.json b/webview-ui/src/i18n/locales/en/cloud.json index 4ba0d5f62d2..5f52afdc6a2 100644 --- a/webview-ui/src/i18n/locales/en/cloud.json +++ b/webview-ui/src/i18n/locales/en/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visit Roo Code Cloud", "taskSync": "Task sync", "taskSyncDescription": "Sync your tasks for viewing and sharing on Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Allow controlling tasks from Roo Code Cloud", - "remoteControlRequiresTaskSync": "Task sync must be enabled to use Roomote Control", "taskSyncManagedByOrganization": "Task sync is managed by your organization", "usageMetricsAlwaysReported": "Model usage info is always reported when logged in", "cloudUrlPillLabel": "Roo Code Cloud URL", diff --git a/webview-ui/src/i18n/locales/es/cloud.json b/webview-ui/src/i18n/locales/es/cloud.json index 1281cccb419..eb0cddc8b98 100644 --- a/webview-ui/src/i18n/locales/es/cloud.json +++ b/webview-ui/src/i18n/locales/es/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visitar Roo Code Cloud", "taskSync": "Sincronización de tareas", "taskSyncDescription": "Sincroniza tus tareas para verlas y compartirlas en Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Permite controlar tareas desde Roo Code Cloud", - "remoteControlRequiresTaskSync": "La sincronización de tareas debe estar habilitada para usar Roomote Control", "taskSyncManagedByOrganization": "La sincronización de tareas es gestionada por tu organización", "usageMetricsAlwaysReported": "La información de uso del modelo siempre se reporta cuando se ha iniciado sesión", "authWaiting": "Esperando que se complete la autenticación...", diff --git a/webview-ui/src/i18n/locales/fr/cloud.json b/webview-ui/src/i18n/locales/fr/cloud.json index 8e031aaad8a..ac8a8d15378 100644 --- a/webview-ui/src/i18n/locales/fr/cloud.json +++ b/webview-ui/src/i18n/locales/fr/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visiter Roo Code Cloud", "taskSync": "Synchronisation des tâches", "taskSyncDescription": "Synchronisez vos tâches pour les visualiser et les partager sur Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Permet de contrôler les tâches depuis Roo Code Cloud", - "remoteControlRequiresTaskSync": "La synchronisation des tâches doit être activée pour utiliser Roomote Control", "taskSyncManagedByOrganization": "La synchronisation des tâches est gérée par votre organisation", "usageMetricsAlwaysReported": "Les informations d'utilisation du modèle sont toujours signalées lors de la connexion", "authWaiting": "En attente de la fin de l'authentification...", diff --git a/webview-ui/src/i18n/locales/hi/cloud.json b/webview-ui/src/i18n/locales/hi/cloud.json index ecf6e1f1010..c573284e1db 100644 --- a/webview-ui/src/i18n/locales/hi/cloud.json +++ b/webview-ui/src/i18n/locales/hi/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Roo Code Cloud पर जाएं", "taskSync": "कार्य सिंक", "taskSyncDescription": "Roo Code Cloud पर देखने और साझा करने के लिए अपने कार्यों को सिंक करें", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Roo Code Cloud से कार्यों को नियंत्रित करने की अनुमति दें", - "remoteControlRequiresTaskSync": "Roomote Control का उपयोग करने के लिए कार्य सिंक सक्षम होना चाहिए", "taskSyncManagedByOrganization": "कार्य सिंक आपके संगठन द्वारा प्रबंधित किया जाता है", "usageMetricsAlwaysReported": "लॉग इन होने पर मॉडल उपयोग जानकारी हमेशा रिपोर्ट की जाती है", "authWaiting": "प्रमाणीकरण पूरा होने की प्रतीक्षा कर रहे हैं...", diff --git a/webview-ui/src/i18n/locales/id/cloud.json b/webview-ui/src/i18n/locales/id/cloud.json index 3e3e293f973..5e8000c7b8c 100644 --- a/webview-ui/src/i18n/locales/id/cloud.json +++ b/webview-ui/src/i18n/locales/id/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Kunjungi Roo Code Cloud", "taskSync": "Sinkronisasi tugas", "taskSyncDescription": "Sinkronkan tugas Anda untuk melihat dan berbagi di Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Izinkan mengontrol tugas dari Roo Code Cloud", - "remoteControlRequiresTaskSync": "Sinkronisasi tugas harus diaktifkan untuk menggunakan Roomote Control", "taskSyncManagedByOrganization": "Sinkronisasi tugas dikelola oleh organisasi Anda", "usageMetricsAlwaysReported": "Informasi penggunaan model selalu dilaporkan saat masuk", "authWaiting": "Menunggu autentikasi selesai...", diff --git a/webview-ui/src/i18n/locales/it/cloud.json b/webview-ui/src/i18n/locales/it/cloud.json index cd575786d5c..b0c0dceec20 100644 --- a/webview-ui/src/i18n/locales/it/cloud.json +++ b/webview-ui/src/i18n/locales/it/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visita Roo Code Cloud", "taskSync": "Sincronizzazione attività", "taskSyncDescription": "Sincronizza le tue attività per visualizzarle e condividerle su Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Consenti il controllo delle attività da Roo Code Cloud", - "remoteControlRequiresTaskSync": "La sincronizzazione delle attività deve essere abilitata per utilizzare Roomote Control", "taskSyncManagedByOrganization": "La sincronizzazione delle attività è gestita dalla tua organizzazione", "usageMetricsAlwaysReported": "Le informazioni sull'utilizzo del modello vengono sempre segnalate quando si è connessi", "authWaiting": "In attesa del completamento dell'autenticazione...", diff --git a/webview-ui/src/i18n/locales/ja/cloud.json b/webview-ui/src/i18n/locales/ja/cloud.json index d25f61b78c8..6474bd8fe00 100644 --- a/webview-ui/src/i18n/locales/ja/cloud.json +++ b/webview-ui/src/i18n/locales/ja/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Roo Code Cloudを訪問", "taskSync": "タスク同期", "taskSyncDescription": "Roo Code Cloudでタスクを表示・共有するために同期", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Roo Code Cloudからタスクを制御できるようにする", - "remoteControlRequiresTaskSync": "Roomote Controlを使用するにはタスク同期を有効にする必要があります", "taskSyncManagedByOrganization": "タスク同期は組織によって管理されます", "usageMetricsAlwaysReported": "ログイン時にはモデル使用情報が常に報告されます", "authWaiting": "認証完了をお待ちください...", diff --git a/webview-ui/src/i18n/locales/ko/cloud.json b/webview-ui/src/i18n/locales/ko/cloud.json index 6aba5f58ba2..c0731e3bf4e 100644 --- a/webview-ui/src/i18n/locales/ko/cloud.json +++ b/webview-ui/src/i18n/locales/ko/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Roo Code Cloud 방문", "taskSync": "작업 동기화", "taskSyncDescription": "Roo Code Cloud에서 보고 공유할 수 있도록 작업을 동기화", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Roo Code Cloud에서 작업을 제어할 수 있도록 허용", - "remoteControlRequiresTaskSync": "Roomote Control을 사용하려면 작업 동기화가 활성화되어야 합니다", "taskSyncManagedByOrganization": "작업 동기화는 조직에서 관리합니다", "usageMetricsAlwaysReported": "로그인 시 모델 사용 정보가 항상 보고됩니다", "authWaiting": "인증 완료를 기다리는 중...", diff --git a/webview-ui/src/i18n/locales/nl/cloud.json b/webview-ui/src/i18n/locales/nl/cloud.json index dad073fdc2f..5a4a5c4db28 100644 --- a/webview-ui/src/i18n/locales/nl/cloud.json +++ b/webview-ui/src/i18n/locales/nl/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Bezoek Roo Code Cloud", "taskSync": "Taaksynchronisatie", "taskSyncDescription": "Synchroniseer je taken om ze te bekijken en delen op Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Sta toe taken te besturen vanuit Roo Code Cloud", - "remoteControlRequiresTaskSync": "Taaksynchronisatie moet ingeschakeld zijn om Roomote Control te gebruiken", "taskSyncManagedByOrganization": "Taaksynchronisatie wordt beheerd door uw organisatie", "usageMetricsAlwaysReported": "Modelgebruiksinformatie wordt altijd gerapporteerd wanneer ingelogd", "authWaiting": "Wachten tot authenticatie voltooid is...", diff --git a/webview-ui/src/i18n/locales/pl/cloud.json b/webview-ui/src/i18n/locales/pl/cloud.json index 827eaa70ef7..3a7eac8cdba 100644 --- a/webview-ui/src/i18n/locales/pl/cloud.json +++ b/webview-ui/src/i18n/locales/pl/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Odwiedź Roo Code Cloud", "taskSync": "Synchronizacja zadań", "taskSyncDescription": "Synchronizuj swoje zadania, aby przeglądać i udostępniać je w Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Pozwól kontrolować zadania z Roo Code Cloud", - "remoteControlRequiresTaskSync": "Synchronizacja zadań musi być włączona, aby używać Roomote Control", "taskSyncManagedByOrganization": "Synchronizacja zadań jest zarządzana przez Twoją organizację", "usageMetricsAlwaysReported": "Informacje o użyciu modelu są zawsze raportowane po zalogowaniu", "authWaiting": "Oczekiwanie na zakończenie uwierzytelniania...", diff --git a/webview-ui/src/i18n/locales/pt-BR/cloud.json b/webview-ui/src/i18n/locales/pt-BR/cloud.json index 1876f1a6669..08ee2f205f0 100644 --- a/webview-ui/src/i18n/locales/pt-BR/cloud.json +++ b/webview-ui/src/i18n/locales/pt-BR/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Visitar Roo Code Cloud", "taskSync": "Sincronização de tarefas", "taskSyncDescription": "Sincronize suas tarefas para visualizar e compartilhar no Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Permite controlar tarefas a partir do Roo Code Cloud", - "remoteControlRequiresTaskSync": "A sincronização de tarefas deve estar habilitada para usar o Roomote Control", "taskSyncManagedByOrganization": "A sincronização de tarefas é gerenciada pela sua organização", "usageMetricsAlwaysReported": "As informações de uso do modelo são sempre reportadas quando conectado", "authWaiting": "Aguardando conclusão da autenticação...", diff --git a/webview-ui/src/i18n/locales/ru/cloud.json b/webview-ui/src/i18n/locales/ru/cloud.json index 438a1a90fd6..20aa6d45069 100644 --- a/webview-ui/src/i18n/locales/ru/cloud.json +++ b/webview-ui/src/i18n/locales/ru/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Посетить Roo Code Cloud", "taskSync": "Синхронизация задач", "taskSyncDescription": "Синхронизируйте свои задачи для просмотра и обмена в Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Разрешить управление задачами из Roo Code Cloud", - "remoteControlRequiresTaskSync": "Для использования Roomote Control должна быть включена синхронизация задач", "taskSyncManagedByOrganization": "Синхронизация задач управляется вашей организацией", "usageMetricsAlwaysReported": "Информация об использовании модели всегда сообщается при входе в систему", "authWaiting": "Ожидание завершения аутентификации...", diff --git a/webview-ui/src/i18n/locales/tr/cloud.json b/webview-ui/src/i18n/locales/tr/cloud.json index 24e767a6c37..8c14c744a7c 100644 --- a/webview-ui/src/i18n/locales/tr/cloud.json +++ b/webview-ui/src/i18n/locales/tr/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Roo Code Cloud'u ziyaret et", "taskSync": "Görev senkronizasyonu", "taskSyncDescription": "Görevlerinizi Roo Code Cloud'da görüntülemek ve paylaşmak için senkronize edin", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Roo Code Cloud'dan görevleri kontrol etmeye izin ver", - "remoteControlRequiresTaskSync": "Roomote Control'ü kullanmak için görev senkronizasyonu etkinleştirilmelidir", "taskSyncManagedByOrganization": "Görev senkronizasyonu kuruluşunuz tarafından yönetilir", "usageMetricsAlwaysReported": "Oturum açıldığında model kullanım bilgileri her zaman raporlanır", "authWaiting": "Kimlik doğrulama tamamlanması bekleniyor...", diff --git a/webview-ui/src/i18n/locales/vi/cloud.json b/webview-ui/src/i18n/locales/vi/cloud.json index e9c4dee7202..45dd092b750 100644 --- a/webview-ui/src/i18n/locales/vi/cloud.json +++ b/webview-ui/src/i18n/locales/vi/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "Truy cập Roo Code Cloud", "taskSync": "Đồng bộ tác vụ", "taskSyncDescription": "Đồng bộ tác vụ của bạn để xem và chia sẻ trên Roo Code Cloud", - "remoteControl": "Roomote Control", - "remoteControlDescription": "Cho phép điều khiển tác vụ từ Roo Code Cloud", - "remoteControlRequiresTaskSync": "Đồng bộ tác vụ phải được bật để sử dụng Roomote Control", "taskSyncManagedByOrganization": "Việc đồng bộ hóa công việc được quản lý bởi tổ chức của bạn", "usageMetricsAlwaysReported": "Thông tin sử dụng mô hình luôn được báo cáo khi đăng nhập", "authWaiting": "Đang chờ hoàn tất xác thực...", diff --git a/webview-ui/src/i18n/locales/zh-CN/cloud.json b/webview-ui/src/i18n/locales/zh-CN/cloud.json index 29b4f98e4c6..560218c56f4 100644 --- a/webview-ui/src/i18n/locales/zh-CN/cloud.json +++ b/webview-ui/src/i18n/locales/zh-CN/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "访问 Roo Code Cloud", "taskSync": "任务同步", "taskSyncDescription": "同步您的任务以在 Roo Code Cloud 上查看和共享", - "remoteControl": "Roomote Control", - "remoteControlDescription": "允许从 Roo Code Cloud 控制任务", - "remoteControlRequiresTaskSync": "必须启用任务同步才能使用 Roomote Control", "taskSyncManagedByOrganization": "任务同步由您的组织管理", "usageMetricsAlwaysReported": "登录时始终报告模型使用信息", "authWaiting": "等待身份验证完成...", diff --git a/webview-ui/src/i18n/locales/zh-TW/cloud.json b/webview-ui/src/i18n/locales/zh-TW/cloud.json index 1ade83d3e34..032c18ed915 100644 --- a/webview-ui/src/i18n/locales/zh-TW/cloud.json +++ b/webview-ui/src/i18n/locales/zh-TW/cloud.json @@ -15,9 +15,6 @@ "visitCloudWebsite": "造訪 Roo Code Cloud", "taskSync": "任務同步", "taskSyncDescription": "同步工作以在 Roo Code Cloud 上檢視和分享", - "remoteControl": "Roomote Control", - "remoteControlDescription": "允許從 Roo Code Cloud 控制工作", - "remoteControlRequiresTaskSync": "必須啟用任務同步才能使用 Roomote Control", "taskSyncManagedByOrganization": "工作同步由您的組織管理", "usageMetricsAlwaysReported": "登入時會一律回報模型使用資訊", "authWaiting": "等待瀏覽器驗證完成...", From 06b25185e21ee9996a2e7a14befd8b10934a6409 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:27:35 -0800 Subject: [PATCH 02/31] feat(cli): update default model from Opus 4.5 to Opus 4.6 (#11273) Co-authored-by: Roo Code --- apps/cli/README.md | 2 +- apps/cli/src/lib/storage/__tests__/settings.test.ts | 4 ++-- apps/cli/src/types/constants.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/cli/README.md b/apps/cli/README.md index 8154a6e6591..7060be6e8f6 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -158,7 +158,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo | `-y, --yes, --dangerously-skip-permissions` | Auto-approve all actions (use with caution) | `false` | | `-k, --api-key ` | API key for the LLM provider | From env var | | `--provider ` | API provider (roo, anthropic, openai, openrouter, etc.) | `openrouter` (or `roo` if authenticated) | -| `-m, --model ` | Model to use | `anthropic/claude-opus-4.5` | +| `-m, --model ` | Model to use | `anthropic/claude-opus-4.6` | | `--mode ` | Mode to start in (code, architect, ask, debug, etc.) | `code` | | `-r, --reasoning-effort ` | Reasoning effort level (unspecified, disabled, none, minimal, low, medium, high, xhigh) | `medium` | | `--ephemeral` | Run without persisting state (uses temporary storage) | `false` | diff --git a/apps/cli/src/lib/storage/__tests__/settings.test.ts b/apps/cli/src/lib/storage/__tests__/settings.test.ts index c133f733b92..d5d520a5975 100644 --- a/apps/cli/src/lib/storage/__tests__/settings.test.ts +++ b/apps/cli/src/lib/storage/__tests__/settings.test.ts @@ -103,7 +103,7 @@ describe("Settings Storage", () => { await saveSettings({ mode: "architect", provider: "anthropic" as const, - model: "claude-opus-4.5", + model: "claude-opus-4.6", reasoningEffort: "medium" as const, }) @@ -112,7 +112,7 @@ describe("Settings Storage", () => { expect(settings.mode).toBe("architect") expect(settings.provider).toBe("anthropic") - expect(settings.model).toBe("claude-opus-4.5") + expect(settings.model).toBe("claude-opus-4.6") expect(settings.reasoningEffort).toBe("medium") }) diff --git a/apps/cli/src/types/constants.ts b/apps/cli/src/types/constants.ts index 5b3dc577786..6c54348a9ca 100644 --- a/apps/cli/src/types/constants.ts +++ b/apps/cli/src/types/constants.ts @@ -3,7 +3,7 @@ import { reasoningEffortsExtended } from "@roo-code/types" export const DEFAULT_FLAGS = { mode: "code", reasoningEffort: "medium" as const, - model: "anthropic/claude-opus-4.5", + model: "anthropic/claude-opus-4.6", } export const REASONING_EFFORTS = [...reasoningEffortsExtended, "unspecified", "disabled"] From a9e9c0207ee3703dca5f27812f1ca2b3804cfb4a Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Fri, 6 Feb 2026 13:50:50 -0800 Subject: [PATCH 03/31] chore(cli): prepare release v0.0.51 (#11274) --- apps/cli/CHANGELOG.md | 6 ++++++ apps/cli/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 811227c5f31..87fcc57add8 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.51] - 2026-02-06 + +### Changed + +- **Default Model Update**: Changed the default model from Opus 4.5 to Opus 4.6 for improved performance and capabilities + ## [0.0.50] - 2026-02-05 ### Added diff --git a/apps/cli/package.json b/apps/cli/package.json index 23d34b8c7fc..b9589d985be 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.0.50", + "version": "0.0.51", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module", From a3a90487416084004ff77026cde7c571004649fc Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:35:41 -0500 Subject: [PATCH 04/31] refactor: migrate io-intelligence provider to AI SDK (#11262) Migrates the IO Intelligence provider from legacy BaseOpenAiCompatibleProvider (direct openai SDK) to OpenAICompatibleHandler (Vercel AI SDK). - Extends OpenAICompatibleHandler instead of BaseOpenAiCompatibleProvider - Uses getModelParams for model parameter resolution - Updates tests to mock ai module's streamText/generateText --- .../__tests__/io-intelligence.spec.ts | 402 +++++++----------- src/api/providers/io-intelligence.ts | 66 +-- 2 files changed, 190 insertions(+), 278 deletions(-) diff --git a/src/api/providers/__tests__/io-intelligence.spec.ts b/src/api/providers/__tests__/io-intelligence.spec.ts index 99dfcefea42..2978ef856cc 100644 --- a/src/api/providers/__tests__/io-intelligence.spec.ts +++ b/src/api/providers/__tests__/io-intelligence.spec.ts @@ -1,303 +1,197 @@ -import { Anthropic } from "@anthropic-ai/sdk" - -import { IOIntelligenceHandler } from "../io-intelligence" -import type { ApiHandlerOptions } from "../../../shared/api" +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -const mockCreate = vi.fn() +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) -// Mock OpenAI -vi.mock("openai", () => ({ - default: class MockOpenAI { - baseURL: string - apiKey: string - chat = { - completions: { - create: vi.fn(), - }, - } - constructor(options: any) { - this.baseURL = options.baseURL - this.apiKey = options.apiKey - this.chat.completions.create = mockCreate - } - }, +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn(() => ({ + modelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", + provider: "IO Intelligence", + })) + }), })) -// Mock the fetcher functions -vi.mock("../fetchers/io-intelligence", () => ({ - getIOIntelligenceModels: vi.fn(), - getCachedIOIntelligenceModels: vi.fn(() => ({ - "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8": { - maxTokens: 8192, - contextWindow: 430000, - description: "Llama 4 Maverick 17B model", - supportsImages: true, - supportsPromptCache: false, - }, - "deepseek-ai/DeepSeek-R1-0528": { - maxTokens: 8192, - contextWindow: 128000, - supportsImages: false, - supportsPromptCache: false, - description: "DeepSeek R1 reasoning model", - }, - "Intel/Qwen3-Coder-480B-A35B-Instruct-int4-mixed-ar": { - maxTokens: 4096, - contextWindow: 106000, - supportsImages: false, - supportsPromptCache: false, - description: "Qwen3 Coder 480B specialized for coding", - }, - "openai/gpt-oss-120b": { - maxTokens: 8192, - contextWindow: 131072, - supportsImages: false, - supportsPromptCache: false, - description: "OpenAI GPT-OSS 120B model", - }, - })), -})) +import type { Anthropic } from "@anthropic-ai/sdk" -// Mock constants -vi.mock("../constants", () => ({ - DEFAULT_HEADERS: { "User-Agent": "roo-cline" }, -})) +import { ioIntelligenceDefaultModelId } from "@roo-code/types" -// Mock transform functions -vi.mock("../../transform/openai-format", () => ({ - convertToOpenAiMessages: vi.fn((messages) => messages), -})) +import type { ApiHandlerOptions } from "../../../shared/api" + +import { IOIntelligenceHandler } from "../io-intelligence" describe("IOIntelligenceHandler", () => { let handler: IOIntelligenceHandler let mockOptions: ApiHandlerOptions beforeEach(() => { - vi.clearAllMocks() mockOptions = { ioIntelligenceApiKey: "test-api-key", - apiModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", + ioIntelligenceModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", modelTemperature: 0.7, - includeMaxTokens: false, modelMaxTokens: undefined, } as ApiHandlerOptions - - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - })) handler = new IOIntelligenceHandler(mockOptions) + vi.clearAllMocks() }) - afterEach(() => { - vi.restoreAllMocks() - }) - - it("should create OpenAI client with correct configuration", () => { - const ioIntelligenceApiKey = "test-io-intelligence-api-key" - const handler = new IOIntelligenceHandler({ ioIntelligenceApiKey }) - // Verify that the handler was created successfully - expect(handler).toBeInstanceOf(IOIntelligenceHandler) - expect(handler["client"]).toBeDefined() - // Verify the client has the expected properties - expect(handler["client"].baseURL).toBe("https://api.intelligence.io.solutions/api/v1") - expect(handler["client"].apiKey).toBe(ioIntelligenceApiKey) - }) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(IOIntelligenceHandler) + expect(handler.getModel().id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8") + }) - it("should initialize with correct configuration", () => { - expect(handler).toBeInstanceOf(IOIntelligenceHandler) - expect(handler["client"]).toBeDefined() - expect(handler["options"]).toEqual({ - ...mockOptions, - apiKey: mockOptions.ioIntelligenceApiKey, + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new IOIntelligenceHandler({ + ...mockOptions, + ioIntelligenceModelId: undefined, + } as ApiHandlerOptions) + expect(handlerWithoutModel.getModel().id).toBe(ioIntelligenceDefaultModelId) }) - }) - it("should throw error when API key is missing", () => { - const optionsWithoutKey = { ...mockOptions } - delete optionsWithoutKey.ioIntelligenceApiKey + it("should throw error when API key is missing", () => { + const optionsWithoutKey = { ...mockOptions } + delete optionsWithoutKey.ioIntelligenceApiKey - expect(() => new IOIntelligenceHandler(optionsWithoutKey)).toThrow("IO Intelligence API key is required") + expect(() => new IOIntelligenceHandler(optionsWithoutKey)).toThrow("IO Intelligence API key is required") + }) }) - it("should handle streaming response correctly", async () => { - const mockStream = [ - { - choices: [{ delta: { content: "Hello" } }], - usage: null, - }, - { - choices: [{ delta: { content: " world" } }], - usage: null, - }, - { - choices: [{ delta: {} }], - usage: { prompt_tokens: 10, completion_tokens: 5 }, - }, - ] - - mockCreate.mockResolvedValue({ - [Symbol.asyncIterator]: async function* () { - for (const chunk of mockStream) { - yield chunk - } - }, + describe("getModel", () => { + it("should return model info for valid model ID", () => { + const model = handler.getModel() + expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8") + expect(model.info).toBeDefined() + expect(model.info.maxTokens).toBe(8192) + expect(model.info.contextWindow).toBe(430000) + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(false) }) - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hello" }] - - const stream = handler.createMessage("System prompt", messages) - const results = [] - - for await (const chunk of stream) { - results.push(chunk) - } - - expect(results).toHaveLength(3) - expect(results[0]).toEqual({ type: "text", text: "Hello" }) - expect(results[1]).toEqual({ type: "text", text: " world" }) - expect(results[2]).toMatchObject({ - type: "usage", - inputTokens: 10, - outputTokens: 5, + it("should return default model info for unknown model ID", () => { + const handlerWithUnknown = new IOIntelligenceHandler({ + ...mockOptions, + ioIntelligenceModelId: "unknown-model", + } as ApiHandlerOptions) + const model = handlerWithUnknown.getModel() + expect(model.id).toBe("unknown-model") + expect(model.info).toBeDefined() + expect(model.info.contextWindow).toBe(handler.getModel().info.contextWindow) }) - }) - it("completePrompt method should return text from IO Intelligence API", async () => { - const expectedResponse = "This is a test response from IO Intelligence" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) + it("should return default model if no model ID is provided", () => { + const handlerWithoutModel = new IOIntelligenceHandler({ + ...mockOptions, + ioIntelligenceModelId: undefined, + } as ApiHandlerOptions) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(ioIntelligenceDefaultModelId) + expect(model.info).toBeDefined() + }) - it("should handle errors in completePrompt", async () => { - const errorMessage = "IO Intelligence API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `IO Intelligence completion error: ${errorMessage}`, - ) + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) }) - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from IO Intelligence stream" + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) - it("createMessage should yield usage data from stream", async () => { - 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 mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: {}, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } - }) - - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() - - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) - }) - it("should return model info from cache when available", () => { - const model = handler.getModel() - expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8") - expect(model.info).toEqual({ - maxTokens: 8192, - contextWindow: 430000, - description: "Llama 4 Maverick 17B model", - supportsImages: true, - supportsPromptCache: false, + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") }) - }) - it("should return fallback model info when not in cache", () => { - const handlerWithUnknownModel = new IOIntelligenceHandler({ - ...mockOptions, - apiModelId: "meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8", - }) - const model = handlerWithUnknownModel.getModel() - expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8") - expect(model.info).toEqual({ - maxTokens: 8192, - contextWindow: 430000, - description: "Llama 4 Maverick 17B model", - supportsImages: true, - supportsPromptCache: false, - }) - }) + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + details: {}, + raw: {}, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - it("should use default model when no model is specified", () => { - const handlerWithoutModel = new IOIntelligenceHandler({ - ...mockOptions, - apiModelId: undefined, + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(5) }) - const model = handlerWithoutModel.getModel() - expect(model.id).toBe("meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8") }) - it("should handle empty response from completePrompt", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [{ message: { content: null } }], - }) + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") - }) + const result = await handler.completePrompt("Test prompt") - it("should handle missing choices in completePrompt response", async () => { - mockCreate.mockResolvedValueOnce({ - choices: [], + expect(result).toBe("Test completion") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) }) - - const result = await handler.completePrompt("Test prompt") - expect(result).toBe("") }) }) diff --git a/src/api/providers/io-intelligence.ts b/src/api/providers/io-intelligence.ts index ef1c60a6a2c..11b8afe5c4e 100644 --- a/src/api/providers/io-intelligence.ts +++ b/src/api/providers/io-intelligence.ts @@ -1,44 +1,62 @@ -import { ioIntelligenceDefaultModelId, ioIntelligenceModels, type IOIntelligenceModelId } from "@roo-code/types" +import { + ioIntelligenceDefaultModelId, + ioIntelligenceModels, + type IOIntelligenceModelId, + type ModelInfo, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" -export class IOIntelligenceHandler extends BaseOpenAiCompatibleProvider { +import { getModelParams } from "../transform/model-params" + +import { OpenAICompatibleHandler, type OpenAICompatibleConfig } from "./openai-compatible" + +export class IOIntelligenceHandler extends OpenAICompatibleHandler { constructor(options: ApiHandlerOptions) { if (!options.ioIntelligenceApiKey) { throw new Error("IO Intelligence API key is required") } - super({ - ...options, + const modelId = options.ioIntelligenceModelId ?? ioIntelligenceDefaultModelId + const modelInfo: ModelInfo = ioIntelligenceModels[modelId as IOIntelligenceModelId] ?? + ioIntelligenceModels[ioIntelligenceDefaultModelId] ?? { + maxTokens: 8192, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + } + + const config: OpenAICompatibleConfig = { providerName: "IO Intelligence", baseURL: "https://api.intelligence.io.solutions/api/v1", - defaultProviderModelId: ioIntelligenceDefaultModelId, - providerModels: ioIntelligenceModels, - defaultTemperature: 0.7, apiKey: options.ioIntelligenceApiKey, - }) + modelId, + modelInfo, + modelMaxTokens: options.modelMaxTokens ?? undefined, + temperature: options.modelTemperature ?? 0.7, + } + + super(options, config) } override getModel() { - const modelId = this.options.ioIntelligenceModelId || (ioIntelligenceDefaultModelId as IOIntelligenceModelId) - - const modelInfo = - this.providerModels[modelId as IOIntelligenceModelId] ?? this.providerModels[ioIntelligenceDefaultModelId] - - if (modelInfo) { - return { id: modelId as IOIntelligenceModelId, info: modelInfo } - } - - // Return the requested model ID even if not found, with fallback info. - return { - id: modelId as IOIntelligenceModelId, - info: { + const modelId = this.options.ioIntelligenceModelId ?? ioIntelligenceDefaultModelId + const modelInfo: ModelInfo = ioIntelligenceModels[modelId as IOIntelligenceModelId] ?? + ioIntelligenceModels[ioIntelligenceDefaultModelId] ?? { maxTokens: 8192, contextWindow: 128000, supportsImages: false, supportsPromptCache: false, - }, - } + } + + const params = getModelParams({ + format: "openai", + modelId, + model: modelInfo, + settings: this.options, + defaultTemperature: 0.7, + }) + + return { id: modelId, info: modelInfo, ...params } } } From 36a986db1d35f7b01a727213748603991954a5e7 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:36:50 -0500 Subject: [PATCH 05/31] refactor: migrate featherless provider to AI SDK (#11265) * refactor: migrate featherless provider to AI SDK * fix: merge consecutive same-role messages in featherless R1 path convertToAiSdkMessages does not merge consecutive same-role messages like convertToR1Format did. When the system prompt is prepended as a user message and the conversation already starts with a user message, DeepSeek R1 can reject the request. Add mergeConsecutiveSameRoleMessages helper that collapses adjacent Anthropic messages sharing the same role before AI SDK conversion. Includes a test that verifies no two successive messages share a role. --- .../providers/__tests__/featherless.spec.ts | 511 +++++++++++------- src/api/providers/featherless.ts | 163 +++--- 2 files changed, 399 insertions(+), 275 deletions(-) diff --git a/src/api/providers/__tests__/featherless.spec.ts b/src/api/providers/__tests__/featherless.spec.ts index 936c10fcd09..0223da9a63c 100644 --- a/src/api/providers/__tests__/featherless.spec.ts +++ b/src/api/providers/__tests__/featherless.spec.ts @@ -1,259 +1,356 @@ // npx vitest run api/providers/__tests__/featherless.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -import { type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types" +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) -import { FeatherlessHandler } from "../featherless" +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn(() => ({ + modelId: "featherless-model", + provider: "Featherless", + })) + }), +})) -// Create mock functions -const mockCreate = vi.fn() +import type { Anthropic } from "@anthropic-ai/sdk" -// Mock OpenAI module -vi.mock("openai", () => ({ - default: vi.fn(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), -})) +import { type FeatherlessModelId, featherlessDefaultModelId, featherlessModels } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { FeatherlessHandler } from "../featherless" describe("FeatherlessHandler", () => { let handler: FeatherlessHandler + let mockOptions: ApiHandlerOptions beforeEach(() => { + mockOptions = { + featherlessApiKey: "test-api-key", + } + handler = new FeatherlessHandler(mockOptions) vi.clearAllMocks() - // Set up default mock implementation - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - })) - handler = new FeatherlessHandler({ featherlessApiKey: "test-key" }) }) - afterEach(() => { - vi.restoreAllMocks() - }) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(FeatherlessHandler) + expect(handler.getModel().id).toBe(featherlessDefaultModelId) + }) - it("should use the correct Featherless base URL", () => { - new FeatherlessHandler({ featherlessApiKey: "test-featherless-api-key" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.featherless.ai/v1" })) + it("should use specified model ID when provided", () => { + const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" + const handlerWithModel = new FeatherlessHandler({ + apiModelId: testModelId, + featherlessApiKey: "test-api-key", + }) + expect(handlerWithModel.getModel().id).toBe(testModelId) + }) }) - it("should use the provided API key", () => { - const featherlessApiKey = "test-featherless-api-key" - new FeatherlessHandler({ featherlessApiKey }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: featherlessApiKey })) - }) + describe("getModel", () => { + it("should return default model when no model is specified", () => { + const model = handler.getModel() + expect(model.id).toBe(featherlessDefaultModelId) + expect(model.info).toEqual(expect.objectContaining(featherlessModels[featherlessDefaultModelId])) + }) - it("should handle reasoning format from models that use tags", async () => { - // Override the mock for this specific test - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Thinking..." }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: { content: "Hello" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5 }, - } - }, - })) + it("should return specified model when valid model is provided", () => { + const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" + const handlerWithModel = new FeatherlessHandler({ + apiModelId: testModelId, + featherlessApiKey: "test-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(expect.objectContaining(featherlessModels[testModelId])) + }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - vi.spyOn(handler, "getModel").mockReturnValue({ - id: "some-reasoning-model", - info: { maxTokens: 1024, temperature: 0.7 }, - } as any) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + it("should use default temperature for non-DeepSeek models", () => { + const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" + const handlerWithModel = new FeatherlessHandler({ + apiModelId: testModelId, + featherlessApiKey: "test-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.temperature).toBe(0.5) + }) - expect(chunks[0]).toEqual({ type: "reasoning", text: "Thinking..." }) - expect(chunks[1]).toEqual({ type: "text", text: "Hello" }) - expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) }) - it("should fall back to base provider for non-DeepSeek models", async () => { - // Use default mock implementation which returns text content + describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - vi.spyOn(handler, "getModel").mockReturnValue({ - id: "some-other-model", - info: { maxTokens: 1024, temperature: 0.7 }, - } as any) - - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] - expect(chunks[0]).toEqual({ type: "text", text: "Test response" }) - expect(chunks[1]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) - }) + it("should handle streaming responses for non-R1 models", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - it("should return default model when no model is specified", () => { - const model = handler.getModel() - expect(model.id).toBe(featherlessDefaultModelId) - expect(model.info).toEqual(expect.objectContaining(featherlessModels[featherlessDefaultModelId])) - }) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - it("should return specified model when valid model is provided", () => { - const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" - const handlerWithModel = new FeatherlessHandler({ - apiModelId: testModelId, - featherlessApiKey: "test-featherless-api-key", + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response") }) - const model = handlerWithModel.getModel() - expect(model.id).toBe(testModelId) - expect(model.info).toEqual(expect.objectContaining(featherlessModels[testModelId])) - }) - it("completePrompt method should return text from Featherless API", async () => { - const expectedResponse = "This is a test response from Featherless" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - it("should handle errors in completePrompt", async () => { - const errorMessage = "Featherless API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `Featherless completion error: ${errorMessage}`, - ) - }) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from Featherless stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(5) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should handle reasoning format from DeepSeek-R1 models using TagMatcher", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Thinking..." } + yield { type: "text-delta", text: "Hello" } + } - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + vi.spyOn(handler, "getModel").mockReturnValue({ + id: "some-DeepSeek-R1-model", + info: { maxTokens: 1024, temperature: 0.6 }, + maxTokens: 1024, + temperature: 0.6, + } as any) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks[0]).toEqual({ type: "reasoning", text: "Thinking..." }) + expect(chunks[1]).toEqual({ type: "text", text: "Hello" }) + expect(chunks[2]).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 5 }) + }) + + it("should delegate to super.createMessage for non-DeepSeek-R1 models", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Standard response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 15, + outputTokens: 8, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + vi.spyOn(handler, "getModel").mockReturnValue({ + id: "some-other-model", + info: { maxTokens: 1024, temperature: 0.5 }, + maxTokens: 1024, + temperature: 0.5, + } as any) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks[0]).toEqual({ type: "text", text: "Standard response" }) + expect(chunks[1]).toMatchObject({ type: "usage", inputTokens: 15, outputTokens: 8 }) + }) - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: {} }], usage: { prompt_tokens: 10, completion_tokens: 20 } }, - }) - .mockResolvedValueOnce({ done: true }), + it("should pass correct model to streamText for R1 path", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + vi.spyOn(handler, "getModel").mockReturnValue({ + id: "some-DeepSeek-R1-model", + info: { maxTokens: 2048, temperature: 0.6 }, + maxTokens: 2048, + temperature: 0.6, + } as any) + + const stream = handler.createMessage(systemPrompt, messages) + // Consume stream + for await (const _ of stream) { + // drain + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.6, }), + ) + }) + + it("should not pass system prompt to streamText for R1 path", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + vi.spyOn(handler, "getModel").mockReturnValue({ + id: "some-DeepSeek-R1-model", + info: { maxTokens: 2048, temperature: 0.6 }, + maxTokens: 2048, + temperature: 0.6, + } as any) + + const stream = handler.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // drain } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.system).toBeUndefined() + expect(callArgs.messages).toBeDefined() }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should merge consecutive user messages in R1 path to avoid DeepSeek rejection", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) - }) + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + vi.spyOn(handler, "getModel").mockReturnValue({ + id: "some-DeepSeek-R1-model", + info: { maxTokens: 2048, temperature: 0.6 }, + maxTokens: 2048, + temperature: 0.6, + } as any) + + // messages starts with a user message, so after prepending the system + // prompt as a user message we'd have two consecutive user messages. + const userFirstMessages: Anthropic.Messages.MessageParam[] = [ + { role: "user", content: "Hello!" }, + { role: "assistant", content: "Hi there" }, + { role: "user", content: "Follow-up" }, + ] + + const stream = handler.createMessage(systemPrompt, userFirstMessages) + for await (const _ of stream) { + // drain + } - it("createMessage should pass correct parameters to Featherless client", async () => { - const modelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" + const callArgs = mockStreamText.mock.calls[0][0] + const passedMessages = callArgs.messages - // Clear previous mocks and set up new implementation - mockCreate.mockClear() - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - // Empty stream for this test - }, - })) + // Verify no two consecutive messages share the same role + for (let i = 1; i < passedMessages.length; i++) { + expect(passedMessages[i].role).not.toBe(passedMessages[i - 1].role) + } - const handlerWithModel = new FeatherlessHandler({ - apiModelId: modelId, - featherlessApiKey: "test-featherless-api-key", + // The system prompt and first user message should be merged into a single user message + expect(passedMessages[0].role).toBe("user") + expect(passedMessages[1].role).toBe("assistant") + expect(passedMessages[2].role).toBe("user") + expect(passedMessages).toHaveLength(3) }) + }) - const systemPrompt = "Test system prompt for Featherless" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Featherless" }] + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from Featherless", + }) - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() + const result = await handler.completePrompt("Test prompt") - expect(mockCreate).toHaveBeenCalled() - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.model).toBe(modelId) + expect(result).toBe("Test completion from Featherless") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) }) - it("should use default temperature for non-DeepSeek models", () => { - const testModelId: FeatherlessModelId = "moonshotai/Kimi-K2-Instruct" - const handlerWithModel = new FeatherlessHandler({ - apiModelId: testModelId, - featherlessApiKey: "test-featherless-api-key", + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) }) - const model = handlerWithModel.getModel() - expect(model.info.temperature).toBe(0.5) }) }) diff --git a/src/api/providers/featherless.ts b/src/api/providers/featherless.ts index 6a94fce9835..a3aca6538f7 100644 --- a/src/api/providers/featherless.ts +++ b/src/api/providers/featherless.ts @@ -1,53 +1,86 @@ -import { - DEEP_SEEK_DEFAULT_TEMPERATURE, - type FeatherlessModelId, - featherlessDefaultModelId, - featherlessModels, -} from "@roo-code/types" import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { streamText } from "ai" + +import { DEEP_SEEK_DEFAULT_TEMPERATURE, featherlessDefaultModelId, featherlessModels } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { TagMatcher } from "../../utils/tag-matcher" -import { convertToR1Format } from "../transform/r1-format" -import { convertToOpenAiMessages } from "../transform/openai-format" +import { convertToAiSdkMessages, handleAiSdkError } from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" +import { getModelParams } from "../transform/model-params" import type { ApiHandlerCreateMessageMetadata } from "../index" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" +import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" + +/** + * Merge consecutive Anthropic messages that share the same role. + * DeepSeek R1 does not support successive messages with the same role, + * so this is needed when the system prompt is injected as a user message + * before the existing conversation (which may also start with a user message). + */ +function mergeConsecutiveSameRoleMessages( + messages: Anthropic.Messages.MessageParam[], +): Anthropic.Messages.MessageParam[] { + if (messages.length <= 1) { + return messages + } + + const merged: Anthropic.Messages.MessageParam[] = [] + + for (const msg of messages) { + const prev = merged[merged.length - 1] + + if (prev && prev.role === msg.role) { + const prevBlocks: Anthropic.Messages.ContentBlockParam[] = + typeof prev.content === "string" ? [{ type: "text", text: prev.content }] : prev.content + const currBlocks: Anthropic.Messages.ContentBlockParam[] = + typeof msg.content === "string" ? [{ type: "text", text: msg.content }] : msg.content + + merged[merged.length - 1] = { + role: prev.role, + content: [...prevBlocks, ...currBlocks], + } + } else { + merged.push(msg) + } + } + + return merged +} -export class FeatherlessHandler extends BaseOpenAiCompatibleProvider { +export class FeatherlessHandler extends OpenAICompatibleHandler { constructor(options: ApiHandlerOptions) { - super({ - ...options, + const modelId = options.apiModelId ?? featherlessDefaultModelId + const modelInfo = + featherlessModels[modelId as keyof typeof featherlessModels] || featherlessModels[featherlessDefaultModelId] + + const config: OpenAICompatibleConfig = { providerName: "Featherless", baseURL: "https://api.featherless.ai/v1", - apiKey: options.featherlessApiKey, - defaultProviderModelId: featherlessDefaultModelId, - providerModels: featherlessModels, - defaultTemperature: 0.5, - }) + apiKey: options.featherlessApiKey ?? "not-provided", + modelId, + modelInfo, + modelMaxTokens: options.modelMaxTokens ?? undefined, + temperature: options.modelTemperature ?? undefined, + } + + super(options, config) } - private getCompletionParams( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - ): OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming { - const { - id: model, - info: { maxTokens: max_tokens }, - } = this.getModel() - - const temperature = this.options.modelTemperature ?? this.getModel().info.temperature - - return { - model, - max_tokens, - temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: true }, - } + override getModel() { + const id = this.options.apiModelId ?? featherlessDefaultModelId + const info = + featherlessModels[id as keyof typeof featherlessModels] || featherlessModels[featherlessDefaultModelId] + const isDeepSeekR1 = id.includes("DeepSeek-R1") + const defaultTemp = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5 + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: defaultTemp, + }) + return { id, info, ...params } } override async *createMessage( @@ -58,9 +91,17 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider tags. + // mergeConsecutiveSameRoleMessages ensures no two successive messages share the + // same role (e.g. the injected system-as-user + original first user message). + const r1Messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: systemPrompt }, ...messages] + const aiSdkMessages = convertToAiSdkMessages(mergeConsecutiveSameRoleMessages(r1Messages)) + + const result = streamText({ + model: this.getLanguageModel(), + messages: aiSdkMessages, + temperature: model.temperature ?? 0, + maxOutputTokens: this.getMaxOutputTokens(), }) const matcher = new TagMatcher( @@ -72,42 +113,28 @@ export class FeatherlessHandler extends BaseOpenAiCompatibleProvider Date: Fri, 6 Feb 2026 18:37:36 -0500 Subject: [PATCH 06/31] refactor: migrate chutes provider to AI SDK (#11267) --- src/api/providers/__tests__/chutes.spec.ts | 696 +++++++++++++-------- src/api/providers/chutes.ts | 333 +++++----- 2 files changed, 608 insertions(+), 421 deletions(-) diff --git a/src/api/providers/__tests__/chutes.spec.ts b/src/api/providers/__tests__/chutes.spec.ts index c89ccb79907..22da3500034 100644 --- a/src/api/providers/__tests__/chutes.spec.ts +++ b/src/api/providers/__tests__/chutes.spec.ts @@ -1,336 +1,490 @@ // npx vitest run api/providers/__tests__/chutes.spec.ts -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +const { mockStreamText, mockGenerateText, mockGetModels, mockGetModelsFromCache } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockGetModels: vi.fn(), + mockGetModelsFromCache: vi.fn(), +})) -import { chutesDefaultModelId, chutesDefaultModelInfo, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) -import { ChutesHandler } from "../chutes" +vi.mock("@ai-sdk/openai-compatible", () => ({ + createOpenAICompatible: vi.fn(() => { + return vi.fn((modelId: string) => ({ + modelId, + provider: "chutes", + })) + }), +})) -// Create mock functions -const mockCreate = vi.fn() -const mockFetchModel = vi.fn() - -// Mock OpenAI module -vi.mock("openai", () => ({ - default: vi.fn(() => ({ - chat: { - completions: { - create: mockCreate, - }, - }, - })), +vi.mock("../fetchers/modelCache", () => ({ + getModels: mockGetModels, + getModelsFromCache: mockGetModelsFromCache, })) +import type { Anthropic } from "@anthropic-ai/sdk" + +import { chutesDefaultModelId, chutesDefaultModelInfo, DEEP_SEEK_DEFAULT_TEMPERATURE } from "@roo-code/types" + +import { ChutesHandler } from "../chutes" + describe("ChutesHandler", () => { let handler: ChutesHandler beforeEach(() => { vi.clearAllMocks() - // Set up default mock implementation - mockCreate.mockImplementation(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Test response" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { - prompt_tokens: 10, - completion_tokens: 5, - total_tokens: 15, - }, - } - }, - })) - handler = new ChutesHandler({ chutesApiKey: "test-key" }) - // Mock fetchModel to return default model - mockFetchModel.mockResolvedValue({ - id: chutesDefaultModelId, - info: chutesDefaultModelInfo, + mockGetModels.mockResolvedValue({ + [chutesDefaultModelId]: chutesDefaultModelInfo, }) - handler.fetchModel = mockFetchModel + mockGetModelsFromCache.mockReturnValue(undefined) + handler = new ChutesHandler({ chutesApiKey: "test-key" }) }) afterEach(() => { vi.restoreAllMocks() }) - it("should use the correct Chutes base URL", () => { - new ChutesHandler({ chutesApiKey: "test-chutes-api-key" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://llm.chutes.ai/v1" })) - }) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(ChutesHandler) + }) - it("should use the provided API key", () => { - const chutesApiKey = "test-chutes-api-key" - new ChutesHandler({ chutesApiKey }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: chutesApiKey })) + it("should use default model when no model ID is provided", () => { + const model = handler.getModel() + expect(model.id).toBe(chutesDefaultModelId) + }) }) - it("should handle DeepSeek R1 reasoning format", async () => { - // Override the mock for this specific test - mockCreate.mockImplementationOnce(async () => ({ - [Symbol.asyncIterator]: async function* () { - yield { - choices: [ - { - delta: { content: "Thinking..." }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: { content: "Hello" }, - index: 0, - }, - ], - usage: null, - } - yield { - choices: [ - { - delta: {}, - index: 0, - }, - ], - usage: { prompt_tokens: 10, completion_tokens: 5 }, - } - }, - })) + describe("getModel", () => { + it("should return default model when no model is specified and no cache", () => { + const model = handler.getModel() + expect(model.id).toBe(chutesDefaultModelId) + expect(model.info).toEqual( + expect.objectContaining({ + ...chutesDefaultModelInfo, + }), + ) + }) - const systemPrompt = "You are a helpful assistant." - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - mockFetchModel.mockResolvedValueOnce({ - id: "deepseek-ai/DeepSeek-R1-0528", - info: { maxTokens: 1024, temperature: 0.7 }, + it("should return model info from fetched models", async () => { + const testModelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + } + mockGetModels.mockResolvedValue({ + "some-model": testModelInfo, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "some-model", + chutesApiKey: "test-key", + }) + const model = await handlerWithModel.fetchModel() + expect(model.id).toBe("some-model") + expect(model.info).toEqual(expect.objectContaining(testModelInfo)) + }) + + it("should fall back to global cache when instance models are empty", () => { + const cachedInfo = { + maxTokens: 2048, + contextWindow: 64000, + supportsImages: false, + supportsPromptCache: false, + } + mockGetModelsFromCache.mockReturnValue({ + "cached-model": cachedInfo, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "cached-model", + chutesApiKey: "test-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe("cached-model") + expect(model.info).toEqual(expect.objectContaining(cachedInfo)) + }) + + it("should apply DeepSeek default temperature for R1 models", () => { + const r1Info = { + maxTokens: 32768, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + } + mockGetModelsFromCache.mockReturnValue({ + "deepseek-ai/DeepSeek-R1-0528": r1Info, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "deepseek-ai/DeepSeek-R1-0528", + chutesApiKey: "test-key", + }) + const model = handlerWithModel.getModel() + expect(model.info.defaultTemperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) + expect(model.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) + }) + + it("should use default temperature for non-DeepSeek models", () => { + const modelInfo = { + maxTokens: 4096, + contextWindow: 128000, + supportsImages: false, + supportsPromptCache: false, + } + mockGetModelsFromCache.mockReturnValue({ + "unsloth/Llama-3.3-70B-Instruct": modelInfo, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "unsloth/Llama-3.3-70B-Instruct", + chutesApiKey: "test-key", + }) + const model = handlerWithModel.getModel() + expect(model.info.defaultTemperature).toBe(0.5) + expect(model.temperature).toBe(0.5) }) + }) - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks).toEqual([ - { type: "reasoning", text: "Thinking..." }, - { type: "text", text: "Hello" }, - { type: "usage", inputTokens: 10, outputTokens: 5 }, - ]) + describe("fetchModel", () => { + it("should fetch models and return the resolved model", async () => { + const model = await handler.fetchModel() + expect(mockGetModels).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "chutes", + }), + ) + expect(model.id).toBe(chutesDefaultModelId) + }) }) - it("should handle non-DeepSeek models", async () => { - // Use default mock implementation which returns text content + describe("createMessage", () => { const systemPrompt = "You are a helpful assistant." const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Hi" }] - mockFetchModel.mockResolvedValueOnce({ - id: "some-other-model", - info: { maxTokens: 1024, temperature: 0.7 }, - }) - const stream = handler.createMessage(systemPrompt, messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } + it("should handle non-DeepSeek models with standard streaming", async () => { + mockGetModels.mockResolvedValue({ + "some-other-model": { maxTokens: 1024, contextWindow: 8192, supportsPromptCache: false }, + }) - expect(chunks).toEqual([ - { type: "text", text: "Test response" }, - { type: "usage", inputTokens: 10, outputTokens: 5 }, - ]) - }) + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } - it("should return default model when no model is specified", async () => { - const model = await handler.fetchModel() - expect(model.id).toBe(chutesDefaultModelId) - expect(model.info).toEqual(expect.objectContaining(chutesDefaultModelInfo)) - }) + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "some-other-model", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - it("should return specified model when valid model is provided", async () => { - const testModelId = "deepseek-ai/DeepSeek-R1" - const handlerWithModel = new ChutesHandler({ - apiModelId: testModelId, - chutesApiKey: "test-chutes-api-key", - }) - // Mock fetchModel for this handler to return the test model from dynamic fetch - handlerWithModel.fetchModel = vi.fn().mockResolvedValue({ - id: testModelId, - info: { maxTokens: 32768, contextWindow: 163840, supportsImages: false, supportsPromptCache: false }, + expect(chunks).toEqual([ + { type: "text", text: "Test response" }, + { + type: "usage", + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: undefined, + reasoningTokens: undefined, + }, + ]) }) - const model = await handlerWithModel.fetchModel() - expect(model.id).toBe(testModelId) - }) - it("completePrompt method should return text from Chutes API", async () => { - const expectedResponse = "This is a test response from Chutes" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) + it("should handle DeepSeek R1 reasoning format with TagMatcher", async () => { + mockGetModels.mockResolvedValue({ + "deepseek-ai/DeepSeek-R1-0528": { + maxTokens: 32768, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + }, + }) - it("should handle errors in completePrompt", async () => { - const errorMessage = "Chutes API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow(`Chutes completion error: ${errorMessage}`) - }) + async function* mockFullStream() { + yield { type: "text-delta", text: "Thinking..." } + yield { type: "text-delta", text: "Hello" } + } - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from Chutes stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "deepseek-ai/DeepSeek-R1-0528", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } + + expect(chunks).toEqual([ + { type: "reasoning", text: "Thinking..." }, + { type: "text", text: "Hello" }, + { + type: "usage", + inputTokens: 10, + outputTokens: 5, + cacheReadTokens: undefined, + reasoningTokens: undefined, + }, + ]) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should handle tool calls in R1 path", async () => { + mockGetModels.mockResolvedValue({ + "deepseek-ai/DeepSeek-R1-0528": { + maxTokens: 32768, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + }, + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) - }) + async function* mockFullStream() { + yield { type: "text-delta", text: "Let me help" } + yield { + type: "tool-input-start", + id: "call_123", + toolName: "test_tool", + } + yield { + type: "tool-input-delta", + id: "call_123", + delta: '{"arg":"value"}', + } + yield { + type: "tool-input-end", + id: "call_123", + } + } - it("createMessage should yield usage data from stream", async () => { - 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 mockUsage = Promise.resolve({ + inputTokens: 15, + outputTokens: 10, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "deepseek-ai/DeepSeek-R1-0528", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) } + + expect(chunks).toContainEqual({ type: "text", text: "Let me help" }) + expect(chunks).toContainEqual({ + type: "tool_call_start", + id: "call_123", + name: "test_tool", + }) + expect(chunks).toContainEqual({ + type: "tool_call_delta", + id: "call_123", + delta: '{"arg":"value"}', + }) + expect(chunks).toContainEqual({ + type: "tool_call_end", + id: "call_123", + }) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should merge system prompt into first user message for R1 path", async () => { + mockGetModels.mockResolvedValue({ + "deepseek-ai/DeepSeek-R1-0528": { + maxTokens: 32768, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + }, + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20 }) - }) + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } - it("createMessage should yield tool_call_partial from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [ - { - delta: { - tool_calls: [ - { - index: 0, - id: "call_123", - function: { name: "test_tool", arguments: '{"arg":"value"}' }, - }, - ], - }, - }, - ], - }, - }) - .mockResolvedValueOnce({ done: true }), + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }), + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "deepseek-ai/DeepSeek-R1-0528", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + messages: expect.any(Array), }), + ) + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.system).toBeUndefined() + }) + + it("should pass system prompt separately for non-R1 path", async () => { + mockGetModels.mockResolvedValue({ + "some-model": { maxTokens: 1024, contextWindow: 8192, supportsPromptCache: false }, + }) + + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 3 }), + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "some-model", + chutesApiKey: "test-key", + }) + + const stream = handlerWithModel.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + system: systemPrompt, + }), + ) }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + it("should include usage information from stream", async () => { + mockGetModels.mockResolvedValue({ + "some-model": { maxTokens: 1024, contextWindow: 8192, supportsPromptCache: false }, + }) + + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ + inputTokens: 20, + outputTokens: 10, + }), + }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "some-model", + chutesApiKey: "test-key", + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ - type: "tool_call_partial", - index: 0, - id: "call_123", - name: "test_tool", - arguments: '{"arg":"value"}', + const stream = handlerWithModel.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(20) + expect(usageChunks[0].outputTokens).toBe(10) }) }) - it("createMessage should pass tools and tool_choice to API", async () => { - const tools = [ - { - type: "function" as const, - function: { - name: "test_tool", - description: "A test tool", - parameters: { type: "object", properties: {} }, - }, - }, - ] - const tool_choice = "auto" as const - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vi.fn().mockResolvedValueOnce({ done: true }), + describe("completePrompt", () => { + it("should return text from generateText", async () => { + const expectedResponse = "This is a test response from Chutes" + mockGenerateText.mockResolvedValue({ text: expectedResponse }) + + const result = await handler.completePrompt("test prompt") + expect(result).toBe(expectedResponse) + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "test prompt", }), - } + ) }) - const stream = handler.createMessage("system prompt", [], { tools, tool_choice, taskId: "test-task-id" }) - // Consume stream - for await (const _ of stream) { - // noop - } - - expect(mockCreate).toHaveBeenCalledWith( - expect.objectContaining({ - tools, - tool_choice, - }), - ) - }) + it("should handle errors in completePrompt", async () => { + const errorMessage = "Chutes API error" + mockGenerateText.mockRejectedValue(new Error(errorMessage)) + await expect(handler.completePrompt("test prompt")).rejects.toThrow( + `Chutes completion error: ${errorMessage}`, + ) + }) - it("should apply DeepSeek default temperature for R1 models", () => { - const testModelId = "deepseek-ai/DeepSeek-R1" - const handlerWithModel = new ChutesHandler({ - apiModelId: testModelId, - chutesApiKey: "test-chutes-api-key", + it("should pass temperature for R1 models in completePrompt", async () => { + mockGetModels.mockResolvedValue({ + "deepseek-ai/DeepSeek-R1-0528": { + maxTokens: 32768, + contextWindow: 163840, + supportsImages: false, + supportsPromptCache: false, + }, + }) + + mockGenerateText.mockResolvedValue({ text: "response" }) + + const handlerWithModel = new ChutesHandler({ + apiModelId: "deepseek-ai/DeepSeek-R1-0528", + chutesApiKey: "test-key", + }) + + await handlerWithModel.completePrompt("test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: DEEP_SEEK_DEFAULT_TEMPERATURE, + }), + ) }) - const model = handlerWithModel.getModel() - expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) }) - it("should use default temperature for non-DeepSeek models", () => { - const testModelId = "unsloth/Llama-3.3-70B-Instruct" - const handlerWithModel = new ChutesHandler({ - apiModelId: testModelId, - chutesApiKey: "test-chutes-api-key", + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) }) - // Note: getModel() returns fallback default without calling fetchModel - // Since we haven't called fetchModel, it returns the default chutesDefaultModelId - // which is DeepSeek-R1-0528, therefore temperature will be DEEP_SEEK_DEFAULT_TEMPERATURE - const model = handlerWithModel.getModel() - // The default model is DeepSeek-R1, so it returns DEEP_SEEK_DEFAULT_TEMPERATURE - expect(model.info.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) }) }) diff --git a/src/api/providers/chutes.ts b/src/api/providers/chutes.ts index 6b040834cd8..66e1d6c9879 100644 --- a/src/api/providers/chutes.ts +++ b/src/api/providers/chutes.ts @@ -1,62 +1,110 @@ -import { DEEP_SEEK_DEFAULT_TEMPERATURE, chutesDefaultModelId, chutesDefaultModelInfo } from "@roo-code/types" import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { streamText, generateText, LanguageModel, ToolSet } from "ai" + +import { + DEEP_SEEK_DEFAULT_TEMPERATURE, + chutesDefaultModelId, + chutesDefaultModelInfo, + type ModelInfo, + type ModelRecord, +} from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" import { getModelMaxOutputTokens } from "../../shared/api" import { TagMatcher } from "../../utils/tag-matcher" -import { convertToR1Format } from "../transform/r1-format" -import { convertToOpenAiMessages } from "../transform/openai-format" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" import { ApiStream } from "../transform/stream" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -import { RouterProvider } from "./router-provider" +import { OpenAICompatibleHandler, OpenAICompatibleConfig } from "./openai-compatible" +import { getModels, getModelsFromCache } from "./fetchers/modelCache" + +export class ChutesHandler extends OpenAICompatibleHandler implements SingleCompletionHandler { + private models: ModelRecord = {} -export class ChutesHandler extends RouterProvider implements SingleCompletionHandler { constructor(options: ApiHandlerOptions) { - super({ - options, - name: "chutes", + const modelId = options.apiModelId ?? chutesDefaultModelId + + const config: OpenAICompatibleConfig = { + providerName: "chutes", baseURL: "https://llm.chutes.ai/v1", - apiKey: options.chutesApiKey, - modelId: options.apiModelId, - defaultModelId: chutesDefaultModelId, - defaultModelInfo: chutesDefaultModelInfo, - }) + apiKey: options.chutesApiKey ?? "not-provided", + modelId, + modelInfo: chutesDefaultModelInfo, + } + + super(options, config) } - private getCompletionParams( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming { - const { id: model, info } = this.getModel() + async fetchModel() { + this.models = await getModels({ provider: "chutes", apiKey: this.config.apiKey, baseUrl: this.config.baseURL }) + return this.getModel() + } + + override getModel(): { id: string; info: ModelInfo; temperature?: number } { + const id = this.options.apiModelId ?? chutesDefaultModelId + + let info: ModelInfo | undefined = this.models[id] - // Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply) - const max_tokens = + if (!info) { + const cachedModels = getModelsFromCache("chutes") + if (cachedModels?.[id]) { + this.models = cachedModels + info = cachedModels[id] + } + } + + if (!info) { + const isDeepSeekR1 = chutesDefaultModelId.includes("DeepSeek-R1") + const defaultTemp = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5 + return { + id: chutesDefaultModelId, + info: { + ...chutesDefaultModelInfo, + defaultTemperature: defaultTemp, + }, + temperature: this.options.modelTemperature ?? defaultTemp, + } + } + + const isDeepSeekR1 = id.includes("DeepSeek-R1") + const defaultTemp = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5 + + return { + id, + info: { + ...info, + defaultTemperature: defaultTemp, + }, + temperature: this.supportsTemperature(id) ? (this.options.modelTemperature ?? defaultTemp) : undefined, + } + } + + protected override getLanguageModel(): LanguageModel { + const { id } = this.getModel() + return this.provider(id) + } + + protected override getMaxOutputTokens(): number | undefined { + const { id, info } = this.getModel() + return ( getModelMaxOutputTokens({ - modelId: model, + modelId: id, model: info, settings: this.options, format: "openai", }) ?? undefined + ) + } - const params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = { - model, - max_tokens, - messages: [{ role: "system", content: systemPrompt }, ...convertToOpenAiMessages(messages)], - stream: true, - stream_options: { include_usage: true }, - tools: metadata?.tools, - tool_choice: metadata?.tool_choice, - } - - // Only add temperature if model supports it - if (this.supportsTemperature(model)) { - params.temperature = this.options.modelTemperature ?? info.temperature - } - - return params + private supportsTemperature(modelId: string): boolean { + return !modelId.startsWith("openai/o3-mini") } override async *createMessage( @@ -67,125 +115,123 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan const model = await this.fetchModel() if (model.id.includes("DeepSeek-R1")) { - const stream = await this.client.chat.completions.create({ - ...this.getCompletionParams(systemPrompt, messages, metadata), - messages: convertToR1Format([{ role: "user", content: systemPrompt }, ...messages]), - }) + yield* this.createR1Message(systemPrompt, messages, model, metadata) + } else { + yield* super.createMessage(systemPrompt, messages, metadata) + } + } - const matcher = new TagMatcher( - "think", - (chunk) => - ({ - type: chunk.matched ? "reasoning" : "text", - text: chunk.data, - }) as const, - ) + private async *createR1Message( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + model: { id: string; info: ModelInfo }, + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const languageModel = this.getLanguageModel() + + const modifiedMessages = [...messages] as Anthropic.Messages.MessageParam[] + + if (modifiedMessages.length > 0 && modifiedMessages[0].role === "user") { + const first = modifiedMessages[0] + if (typeof first.content === "string") { + modifiedMessages[0] = { role: "user", content: `${systemPrompt}\n\n${first.content}` } + } else { + modifiedMessages[0] = { + role: "user", + content: [{ type: "text", text: systemPrompt }, ...first.content], + } + } + } else { + modifiedMessages.unshift({ role: "user", content: systemPrompt }) + } - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta + const aiSdkMessages = convertToAiSdkMessages(modifiedMessages) - if (delta?.content) { - for (const processedChunk of matcher.update(delta.content)) { - yield processedChunk - } - } + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined - // Emit raw tool call chunks - NativeToolCallParser handles state management - if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } + const maxOutputTokens = + getModelMaxOutputTokens({ + modelId: model.id, + model: model.info, + settings: this.options, + format: "openai", + }) ?? undefined + + const temperature = this.supportsTemperature(model.id) + ? (this.options.modelTemperature ?? model.info.defaultTemperature) + : undefined + + const result = streamText({ + model: languageModel, + messages: aiSdkMessages, + temperature, + maxOutputTokens, + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + }) + + const matcher = new TagMatcher( + "think", + (chunk) => + ({ + type: chunk.matched ? "reasoning" : "text", + text: chunk.data, + }) as const, + ) - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, + try { + for await (const part of result.fullStream) { + if (part.type === "text-delta") { + for (const processedChunk of matcher.update(part.text)) { + yield processedChunk + } + } else { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } } } - // Process any remaining content for (const processedChunk of matcher.final()) { yield processedChunk } - } else { - // For non-DeepSeek-R1 models, use standard OpenAI streaming - const stream = await this.client.chat.completions.create( - this.getCompletionParams(systemPrompt, messages, metadata), - ) - - for await (const chunk of stream) { - const delta = chunk.choices[0]?.delta - - if (delta?.content) { - yield { type: "text", text: delta.content } - } - - if (delta && "reasoning_content" in delta && delta.reasoning_content) { - yield { type: "reasoning", text: (delta.reasoning_content as string | undefined) || "" } - } - - // Emit raw tool call chunks - NativeToolCallParser handles state management - if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) { - for (const toolCall of delta.tool_calls) { - yield { - type: "tool_call_partial", - index: toolCall.index, - id: toolCall.id, - name: toolCall.function?.name, - arguments: toolCall.function?.arguments, - } - } - } - if (chunk.usage) { - yield { - type: "usage", - inputTokens: chunk.usage.prompt_tokens || 0, - outputTokens: chunk.usage.completion_tokens || 0, - } - } + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) } + } catch (error) { + throw handleAiSdkError(error, "chutes") } } - async completePrompt(prompt: string): Promise { + override async completePrompt(prompt: string): Promise { const model = await this.fetchModel() - const { id: modelId, info } = model + const languageModel = this.getLanguageModel() - try { - // Centralized cap: clamp to 20% of the context window (unless provider-specific exceptions apply) - const max_tokens = - getModelMaxOutputTokens({ - modelId, - model: info, - settings: this.options, - format: "openai", - }) ?? undefined - - const requestParams: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming = { - model: modelId, - messages: [{ role: "user", content: prompt }], - max_tokens, - } + const maxOutputTokens = + getModelMaxOutputTokens({ + modelId: model.id, + model: model.info, + settings: this.options, + format: "openai", + }) ?? undefined - // Only add temperature if model supports it - if (this.supportsTemperature(modelId)) { - const isDeepSeekR1 = modelId.includes("DeepSeek-R1") - const defaultTemperature = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5 - requestParams.temperature = this.options.modelTemperature ?? defaultTemperature - } + const isDeepSeekR1 = model.id.includes("DeepSeek-R1") + const defaultTemperature = isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5 + const temperature = this.supportsTemperature(model.id) + ? (this.options.modelTemperature ?? defaultTemperature) + : undefined - const response = await this.client.chat.completions.create(requestParams) - return response.choices[0]?.message.content || "" + try { + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens, + temperature, + }) + return text } catch (error) { if (error instanceof Error) { throw new Error(`Chutes completion error: ${error.message}`) @@ -193,17 +239,4 @@ export class ChutesHandler extends RouterProvider implements SingleCompletionHan throw error } } - - override getModel() { - const model = super.getModel() - const isDeepSeekR1 = model.id.includes("DeepSeek-R1") - - return { - ...model, - info: { - ...model.info, - temperature: isDeepSeekR1 ? DEEP_SEEK_DEFAULT_TEMPERATURE : 0.5, - }, - } - } } From 0e5407aa769f991ebc6e94064981098a08e02d67 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:38:31 -0500 Subject: [PATCH 07/31] fix: make defaultTemperature required in getModelParams to prevent silent temperature overrides (#11218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: DeepSeek temperature defaulting to 0 instead of 0.3 Pass defaultTemperature: DEEP_SEEK_DEFAULT_TEMPERATURE to getModelParams() in DeepSeekHandler.getModel() to ensure the correct default temperature (0.3) is used when no user configuration is provided. Closes #11194 * refactor: make defaultTemperature required in getModelParams Make the defaultTemperature parameter required in getModelParams() instead of defaulting to 0. This prevents providers with their own non-zero default temperature (like DeepSeek's 0.3) from being silently overridden by the implicit 0 default. Every provider now explicitly declares its temperature default, making the temperature resolution chain clear: user setting → model default → provider default --------- Co-authored-by: Roo Code Co-authored-by: daniel-lxs --- src/api/providers/__tests__/deepseek.spec.ts | 16 +++++++++++++++- src/api/providers/anthropic-vertex.ts | 8 +++++++- src/api/providers/anthropic.ts | 1 + src/api/providers/cerebras.ts | 8 +++++++- src/api/providers/deepinfra.ts | 1 + src/api/providers/deepseek.ts | 8 +++++++- src/api/providers/doubao.ts | 8 +++++++- src/api/providers/mistral.ts | 8 +++++++- src/api/providers/moonshot.ts | 8 +++++++- src/api/providers/openai.ts | 8 +++++++- src/api/providers/requesty.ts | 1 + src/api/providers/unbound.ts | 1 + src/api/transform/__tests__/model-params.spec.ts | 10 +++++++++- src/api/transform/model-params.ts | 4 ++-- 14 files changed, 79 insertions(+), 11 deletions(-) diff --git a/src/api/providers/__tests__/deepseek.spec.ts b/src/api/providers/__tests__/deepseek.spec.ts index ece03c068eb..32bd3a029a1 100644 --- a/src/api/providers/__tests__/deepseek.spec.ts +++ b/src/api/providers/__tests__/deepseek.spec.ts @@ -25,7 +25,7 @@ vi.mock("@ai-sdk/deepseek", () => ({ import type { Anthropic } from "@anthropic-ai/sdk" -import { deepSeekDefaultModelId, type ModelInfo } from "@roo-code/types" +import { deepSeekDefaultModelId, DEEP_SEEK_DEFAULT_TEMPERATURE, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../../shared/api" @@ -155,6 +155,20 @@ describe("DeepSeekHandler", () => { expect(model).toHaveProperty("temperature") expect(model).toHaveProperty("maxTokens") }) + + it("should use DEEP_SEEK_DEFAULT_TEMPERATURE as the default temperature", () => { + const model = handler.getModel() + expect(model.temperature).toBe(DEEP_SEEK_DEFAULT_TEMPERATURE) + }) + + it("should respect user-provided temperature over DEEP_SEEK_DEFAULT_TEMPERATURE", () => { + const handlerWithTemp = new DeepSeekHandler({ + ...mockOptions, + modelTemperature: 0.9, + }) + const model = handlerWithTemp.getModel() + expect(model.temperature).toBe(0.9) + }) }) describe("createMessage", () => { diff --git a/src/api/providers/anthropic-vertex.ts b/src/api/providers/anthropic-vertex.ts index 63daf8a3aac..3ed5dd45cce 100644 --- a/src/api/providers/anthropic-vertex.ts +++ b/src/api/providers/anthropic-vertex.ts @@ -231,7 +231,13 @@ export class AnthropicVertexHandler extends BaseProvider implements SingleComple } } - const params = getModelParams({ format: "anthropic", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "anthropic", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) // Build betas array for request headers const betas: string[] = [] diff --git a/src/api/providers/anthropic.ts b/src/api/providers/anthropic.ts index fc6cc048c7e..b2b158f0956 100644 --- a/src/api/providers/anthropic.ts +++ b/src/api/providers/anthropic.ts @@ -358,6 +358,7 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa modelId: id, model: info, settings: this.options, + defaultTemperature: 0, }) // The `:thinking` suffix indicates that the model is a "Hybrid" diff --git a/src/api/providers/cerebras.ts b/src/api/providers/cerebras.ts index 0ca8b200662..f6c516b7a2c 100644 --- a/src/api/providers/cerebras.ts +++ b/src/api/providers/cerebras.ts @@ -49,7 +49,13 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { const id = (this.options.apiModelId ?? cerebrasDefaultModelId) as CerebrasModelId const info = cerebrasModels[id as keyof typeof cerebrasModels] || cerebrasModels[cerebrasDefaultModelId] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: CEREBRAS_DEFAULT_TEMPERATURE, + }) return { id, info, ...params } } diff --git a/src/api/providers/deepinfra.ts b/src/api/providers/deepinfra.ts index e5b10e4e445..3dc20683721 100644 --- a/src/api/providers/deepinfra.ts +++ b/src/api/providers/deepinfra.ts @@ -47,6 +47,7 @@ export class DeepInfraHandler extends RouterProvider implements SingleCompletion modelId: id, model: info, settings: this.options, + defaultTemperature: 0, }) return { id, info, ...params } diff --git a/src/api/providers/deepseek.ts b/src/api/providers/deepseek.ts index 635759b5191..aa1af804eaf 100644 --- a/src/api/providers/deepseek.ts +++ b/src/api/providers/deepseek.ts @@ -43,7 +43,13 @@ export class DeepSeekHandler extends BaseProvider implements SingleCompletionHan override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { const id = this.options.apiModelId ?? deepSeekDefaultModelId const info = deepSeekModels[id as keyof typeof deepSeekModels] || deepSeekModels[deepSeekDefaultModelId] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: DEEP_SEEK_DEFAULT_TEMPERATURE, + }) return { id, info, ...params } } diff --git a/src/api/providers/doubao.ts b/src/api/providers/doubao.ts index a1337ed558a..6490e422085 100644 --- a/src/api/providers/doubao.ts +++ b/src/api/providers/doubao.ts @@ -64,7 +64,13 @@ export class DoubaoHandler extends OpenAiHandler { override getModel() { const id = this.options.apiModelId ?? doubaoDefaultModelId const info = doubaoModels[id as keyof typeof doubaoModels] || doubaoModels[doubaoDefaultModelId] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) return { id, info, ...params } } diff --git a/src/api/providers/mistral.ts b/src/api/providers/mistral.ts index 77d2b9f572b..be6665e3244 100644 --- a/src/api/providers/mistral.ts +++ b/src/api/providers/mistral.ts @@ -55,7 +55,13 @@ export class MistralHandler extends BaseProvider implements SingleCompletionHand override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { const id = (this.options.apiModelId ?? mistralDefaultModelId) as MistralModelId const info = mistralModels[id as keyof typeof mistralModels] || mistralModels[mistralDefaultModelId] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) return { id, info, ...params } } diff --git a/src/api/providers/moonshot.ts b/src/api/providers/moonshot.ts index 3c732662727..3e90e48f7aa 100644 --- a/src/api/providers/moonshot.ts +++ b/src/api/providers/moonshot.ts @@ -29,7 +29,13 @@ export class MoonshotHandler extends OpenAICompatibleHandler { override getModel() { const id = this.options.apiModelId ?? moonshotDefaultModelId const info = moonshotModels[id as keyof typeof moonshotModels] || moonshotModels[moonshotDefaultModelId] - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) return { id, info, ...params } } diff --git a/src/api/providers/openai.ts b/src/api/providers/openai.ts index 611b391ce9a..33b29abcafe 100644 --- a/src/api/providers/openai.ts +++ b/src/api/providers/openai.ts @@ -282,7 +282,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl override getModel() { const id = this.options.openAiModelId ?? "" const info: ModelInfo = this.options.openAiCustomModelInfo ?? openAiModelInfoSaneDefaults - const params = getModelParams({ format: "openai", modelId: id, model: info, settings: this.options }) + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: 0, + }) return { id, info, ...params } } diff --git a/src/api/providers/requesty.ts b/src/api/providers/requesty.ts index c3b5accbc38..b241c347b08 100644 --- a/src/api/providers/requesty.ts +++ b/src/api/providers/requesty.ts @@ -89,6 +89,7 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan modelId: id, model: info, settings: this.options, + defaultTemperature: 0, }) return { id, info, ...params } diff --git a/src/api/providers/unbound.ts b/src/api/providers/unbound.ts index 76dd60d976c..ba144f6e1b7 100644 --- a/src/api/providers/unbound.ts +++ b/src/api/providers/unbound.ts @@ -70,6 +70,7 @@ export class UnboundHandler extends RouterProvider implements SingleCompletionHa modelId: id, model: info, settings: this.options, + defaultTemperature: 0, }) return { id, info, ...params } diff --git a/src/api/transform/__tests__/model-params.spec.ts b/src/api/transform/__tests__/model-params.spec.ts index 75b5c50c592..a50f1291bef 100644 --- a/src/api/transform/__tests__/model-params.spec.ts +++ b/src/api/transform/__tests__/model-params.spec.ts @@ -17,16 +17,19 @@ describe("getModelParams", () => { const anthropicParams = { modelId: "test", format: "anthropic" as const, + defaultTemperature: 0, } const openaiParams = { modelId: "test", format: "openai" as const, + defaultTemperature: 0, } const openrouterParams = { modelId: "test", format: "openrouter" as const, + defaultTemperature: 0, } describe("Basic functionality", () => { @@ -48,11 +51,12 @@ describe("getModelParams", () => { }) }) - it("should use default temperature of 0 when no defaultTemperature is provided", () => { + it("should use the provided defaultTemperature when no user or model temperature is set", () => { const result = getModelParams({ ...anthropicParams, settings: {}, model: baseModel, + defaultTemperature: 0, }) expect(result.temperature).toBe(0) @@ -193,6 +197,7 @@ describe("getModelParams", () => { format: "openrouter" as const, settings: {}, model: baseModel, + defaultTemperature: 0, }) expect(result.maxTokens).toBe(ANTHROPIC_DEFAULT_MAX_TOKENS) @@ -214,6 +219,7 @@ describe("getModelParams", () => { format: "openrouter" as const, settings: {}, model: baseModel, + defaultTemperature: 0, }) expect(result.maxTokens).toBeUndefined() @@ -374,6 +380,7 @@ describe("getModelParams", () => { format: "gemini" as const, settings: { modelMaxTokens: 2000, modelMaxThinkingTokens: 50 }, model, + defaultTemperature: 0, }), ).toEqual({ format: "gemini", @@ -400,6 +407,7 @@ describe("getModelParams", () => { format: "openrouter" as const, settings: { modelMaxTokens: 4000 }, model, + defaultTemperature: 0, }), ).toEqual({ format: "openrouter", diff --git a/src/api/transform/model-params.ts b/src/api/transform/model-params.ts index e862c5cf5ed..ac04bce37de 100644 --- a/src/api/transform/model-params.ts +++ b/src/api/transform/model-params.ts @@ -33,7 +33,7 @@ type GetModelParamsOptions = { modelId: string model: ModelInfo settings: ProviderSettings - defaultTemperature?: number + defaultTemperature: number } type BaseModelParams = { @@ -77,7 +77,7 @@ export function getModelParams({ modelId, model, settings, - defaultTemperature = 0, + defaultTemperature, }: GetModelParamsOptions): ModelParams { const { modelMaxTokens: customMaxTokens, From ca7e3b6161435792af0e666d18d04e2f9d96076a Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Fri, 6 Feb 2026 16:39:17 -0700 Subject: [PATCH 08/31] feat: migrate Bedrock provider to AI SDK (#11243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: migrate Bedrock provider to AI SDK Replace the raw AWS SDK (@aws-sdk/client-bedrock-runtime) Bedrock handler with the Vercel AI SDK (@ai-sdk/amazon-bedrock). Reduces provider from 1,633 lines to 575 lines (65% reduction). Key changes: - Use streamText()/generateText() instead of ConverseStreamCommand/ConverseCommand - Use createAmazonBedrock() with native auth (access key, secret, session, profile via credentialProvider, API key, VPC endpoint as baseURL) - Reasoning config via providerOptions.bedrock.reasoningConfig - Anthropic beta headers via providerOptions.bedrock.anthropicBeta - Thinking signature captured from providerMetadata.bedrock.signature on reasoning-delta stream events - Thinking signature round-tripped via providerOptions.bedrock.signature on reasoning parts in convertToAiSdkMessages() - Redacted thinking captured from providerMetadata.bedrock.redactedData - isAiSdkProvider() returns true for reasoning block preservation - Keep: getModel, ARN parsing, cross-region inference, cost calculation, service tier pricing, 1M context beta Tests: 83 tests skipped (mock old AWS SDK internals, need rewrite for AI SDK mocking). 106 tests pass. 0 tests fail. * fix: address review feedback for Bedrock AI SDK migration - Wire usePromptCache into AI SDK via providerOptions.bedrock.cachePoint on system prompt and last two user messages - Remove debug logger.info that fires on every stream event with providerMetadata - Tighten isThrottlingError to match 'rate limit' instead of broad 'rate'/'limit' substrings that false-positive on context length errors - Use shared handleAiSdkError utility for consistent error handling with status code preservation for retry logic * fix: bedrock AI SDK migration - fix usage metrics, rewrite tests, remove dead code - Fix reasoningTokens always 0 (usage.details?.reasoningTokens → usage.reasoningTokens) - Fix cacheReadInputTokens always 0 (read from usage.inputTokenDetails instead of providerMetadata) - Fix invokedModelId not extracted for prompt router cost calculation - Rewrite all 6 skipped bedrock test suites for AI SDK mocking pattern (140 tests pass) - Remove dead code: bedrock-converse-format.ts, cache-strategy/ (6 files, ~2700 lines) * chore: remove dead @anthropic-ai/bedrock-sdk dep and stale AWS SDK mocks * chore: update pnpm-lock.yaml after removing @anthropic-ai/bedrock-sdk * fix: compute cache point indices from original Anthropic messages before AI SDK conversion The previous approach naively targeted the last 2 user messages in the post-conversion AI SDK array, but convertToAiSdkMessages() splits user messages containing tool_results into separate tool + user messages, causing cache points to land on the wrong messages (tiny text fragments instead of the intended meaty user turns). Now we identify the last 2 user messages in the original Anthropic message array (matching the Anthropic provider's caching strategy) and build a parallel-walk mapping to apply cachePoint to the correct corresponding AI SDK message. * perf: optimize prompt caching with 3-point message strategy + anchor for 20-block window Previous approach only cached the last 2 user messages (using 2 of 4 available cache checkpoints for messages). This left significant cache savings on the table for longer conversations. New strategy uses up to 3 message cache points (+ 1 system = 4 total): - Last user message: write to cache for next request - Second-to-last user message: read from cache for current request - Anchor message at ~1/3 position: ensures the 20-block lookback window from the second-to-last breakpoint hits a stable cache entry, covering all assistant/tool messages in the middle of the conversation Also extracted the parallel-walk mapping logic into a reusable applyCachePointsToAiSdkMessages() helper method. Industry benchmarks show 70-95% token cache rates are achievable; this change should significantly improve our 39% baseline for longer multi-turn conversations. * chore: remove stale bedrock-sdk external, fix arnInfo property name, remove unused exports --------- Co-authored-by: daniel-lxs --- apps/cli/tsup.config.ts | 1 - pnpm-lock.yaml | 432 +---- .../__tests__/bedrock-custom-arn.spec.ts | 36 +- .../__tests__/bedrock-error-handling.spec.ts | 692 ++++---- .../bedrock-inference-profiles.spec.ts | 12 - .../__tests__/bedrock-invokedModelId.spec.ts | 362 ++-- .../__tests__/bedrock-native-tools.spec.ts | 805 +++++---- .../__tests__/bedrock-reasoning.spec.ts | 360 ++-- .../__tests__/bedrock-vpc-endpoint.spec.ts | 118 +- src/api/providers/__tests__/bedrock.spec.ts | 437 ++--- src/api/providers/bedrock.ts | 1572 +++++------------ src/api/transform/__tests__/ai-sdk.spec.ts | 9 +- .../__tests__/bedrock-converse-format.spec.ts | 694 -------- src/api/transform/ai-sdk.ts | 32 +- src/api/transform/bedrock-converse-format.ts | 249 --- .../__tests__/cache-strategy.spec.ts | 1112 ------------ .../transform/cache-strategy/base-strategy.ts | 172 -- .../cache-strategy/multi-point-strategy.ts | 314 ---- src/api/transform/cache-strategy/types.ts | 68 - src/package.json | 4 +- 20 files changed, 1781 insertions(+), 5700 deletions(-) delete mode 100644 src/api/transform/__tests__/bedrock-converse-format.spec.ts delete mode 100644 src/api/transform/bedrock-converse-format.ts delete mode 100644 src/api/transform/cache-strategy/__tests__/cache-strategy.spec.ts delete mode 100644 src/api/transform/cache-strategy/base-strategy.ts delete mode 100644 src/api/transform/cache-strategy/multi-point-strategy.ts delete mode 100644 src/api/transform/cache-strategy/types.ts diff --git a/apps/cli/tsup.config.ts b/apps/cli/tsup.config.ts index eff2c14e2c9..3ad1234d995 100644 --- a/apps/cli/tsup.config.ts +++ b/apps/cli/tsup.config.ts @@ -16,7 +16,6 @@ export default defineConfig({ external: [ // Keep native modules external "@anthropic-ai/sdk", - "@anthropic-ai/bedrock-sdk", "@anthropic-ai/vertex-sdk", // Keep @vscode/ripgrep external - we bundle the binary separately "@vscode/ripgrep", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a47ae416ae..58f6354f62d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -746,6 +746,9 @@ importers: src: dependencies: + '@ai-sdk/amazon-bedrock': + specifier: ^4.0.50 + version: 4.0.50(zod@3.25.76) '@ai-sdk/cerebras': specifier: ^1.0.0 version: 1.0.35(zod@3.25.76) @@ -770,9 +773,6 @@ importers: '@ai-sdk/xai': specifier: ^3.0.46 version: 3.0.46(zod@3.25.76) - '@anthropic-ai/bedrock-sdk': - specifier: ^0.10.2 - version: 0.10.4 '@anthropic-ai/sdk': specifier: ^0.37.0 version: 0.37.0 @@ -1417,12 +1417,24 @@ packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} + '@ai-sdk/amazon-bedrock@4.0.50': + resolution: {integrity: sha512-DsIxaUHPbDUY0DfxYMz6GL9tO/z7ISiwACSiYupcYImqrcdLtIGFujPgszOf92ed3olfhjdkhTwKBHaf6Yh6Qw==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/anthropic@2.0.58': resolution: {integrity: sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 + '@ai-sdk/anthropic@3.0.37': + resolution: {integrity: sha512-tEgcJPw+a6obbF+SHrEiZsx3DNxOHqeY8bK4IpiNsZ8YPZD141R34g3lEAaQnmNN5mGsEJ8SXoEDabuzi8wFJQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/cerebras@1.0.35': resolution: {integrity: sha512-JrNdMYptrOUjNthibgBeAcBjZ/H+fXb49sSrWhOx5Aq8eUcrYvwQ2DtSAi8VraHssZu78NAnBMrgFWSUOTXFxw==} engines: {node: '>=18'} @@ -1575,9 +1587,6 @@ packages: '@antfu/utils@8.1.1': resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==} - '@anthropic-ai/bedrock-sdk@0.10.4': - resolution: {integrity: sha512-szduEHbMli6XL934xrraYg5cFuKL/1oMyj/iZuEVjtddQ7eD5cXObzWobsv5mTLWijQmSzMfFD+JAUHDPHlQ/Q==} - '@anthropic-ai/sdk@0.37.0': resolution: {integrity: sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==} @@ -1587,9 +1596,6 @@ packages: '@asamuzakjp/css-color@3.2.0': resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} - '@aws-crypto/crc32@3.0.0': - resolution: {integrity: sha512-IzSgsrxUcsrejQbPVilIKy16kAT52EwB6zSaI+M3xxIhKh5+aldEyvI+z6erM7TCLB2BJsFrtHjp6/4/sr+3dA==} - '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1597,9 +1603,6 @@ packages: '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - '@aws-crypto/sha256-js@4.0.0': - resolution: {integrity: sha512-MHGJyjE7TX9aaqXj7zk2ppnFUOhaDs5sP+HtNS0evOxn72c+5njUmyJmpGd7TfyoDznZlHMmdo/xGUdu2NIjNQ==} - '@aws-crypto/sha256-js@5.2.0': resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} engines: {node: '>=16.0.0'} @@ -1607,12 +1610,6 @@ packages: '@aws-crypto/supports-web-crypto@5.2.0': resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - '@aws-crypto/util@3.0.0': - resolution: {integrity: sha512-2OJlpeJpCR48CC8r+uKVChzs9Iungj9wkZrl8Z041DWEWvyIHILYKCPNzJghKsivj+S3mLo6BVc7mBNzdxA46w==} - - '@aws-crypto/util@4.0.0': - resolution: {integrity: sha512-2EnmPy2gsFZ6m8bwUQN4jq+IyXV3quHAcwPOS6ZA3k+geujiqI8aRokO2kFJe+idJ/P3v4qWI186rVMo0+zLDQ==} - '@aws-crypto/util@5.2.0': resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} @@ -1708,14 +1705,6 @@ packages: resolution: {integrity: sha512-/inmPnjZE0ZBE16zaCowAvouSx05FJ7p6BQYuzlJ8vxEU0sS0Hf8fvhuiRnN9V9eDUPIBY+/5EjbMWygXL4wlQ==} engines: {node: '>=18.0.0'} - '@aws-sdk/types@3.804.0': - resolution: {integrity: sha512-A9qnsy9zQ8G89vrPPlNG9d1d8QcKRGqJKqwyGgS0dclJpwy6d1EWgQLIolKPl6vcFpLoe6avLOLxr+h8ur5wpg==} - engines: {node: '>=18.0.0'} - - '@aws-sdk/types@3.840.0': - resolution: {integrity: sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==} - engines: {node: '>=18.0.0'} - '@aws-sdk/types@3.922.0': resolution: {integrity: sha512-eLA6XjVobAUAMivvM7DBL79mnHyrm+32TkXNWZua5mnxF+6kQCfblKKJvxMZLGosO53/Ex46ogim8IY5Nbqv2w==} engines: {node: '>=18.0.0'} @@ -1744,9 +1733,6 @@ packages: aws-crt: optional: true - '@aws-sdk/util-utf8-browser@3.259.0': - resolution: {integrity: sha512-UvFa/vR+e19XookZF8RzFZBrw2EUkQWxiBW0yYQAhvk3C+QVGl0H3ouca8LDBlBfQKXwmW3huo/59H8rwb1wJw==} - '@aws-sdk/xml-builder@3.921.0': resolution: {integrity: sha512-LVHg0jgjyicKKvpNIEMXIMr1EBViESxcPkqfOlT+X1FkmUMTNZEEVF18tOJg4m4hV5vxtkWcqtr4IEeWa1C41Q==} engines: {node: '>=18.0.0'} @@ -3879,10 +3865,6 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/abort-controller@2.2.0': - resolution: {integrity: sha512-wRlta7GuLWpTqtFfGo+nZyOO1vEvewdNR1R4rTxpC8XU6vG/NDyrFBhwLZsqg1NUoR1noVaXJPC/7ZK47QCySw==} - engines: {node: '>=14.0.0'} - '@smithy/abort-controller@4.2.4': resolution: {integrity: sha512-Z4DUr/AkgyFf1bOThW2HwzREagee0sB5ycl+hDiSZOfRLW8ZgrOjDi6g8mHH19yyU5E2A/64W3z6SMIf5XiUSQ==} engines: {node: '>=18.0.0'} @@ -3899,9 +3881,6 @@ packages: resolution: {integrity: sha512-YVNMjhdz2pVto5bRdux7GMs0x1m0Afz3OcQy/4Yf9DH4fWOtroGH7uLvs7ZmDyoBJzLdegtIPpXrpJOZWvUXdw==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@2.2.0': - resolution: {integrity: sha512-8janZoJw85nJmQZc4L8TuePp2pk1nxLgkxIR0TUjKJ5Dkj5oelB9WtiSSGXCQvNsJl0VSTvK/2ueMXxvpa9GVw==} - '@smithy/eventstream-codec@4.2.4': resolution: {integrity: sha512-aV8blR9RBDKrOlZVgjOdmOibTC2sBXNiT7WA558b4MPdsLTV6sbyc1WIE9QiIuYMJjYtnPLciefoqSW8Gi+MZQ==} engines: {node: '>=18.0.0'} @@ -3914,25 +3893,14 @@ packages: resolution: {integrity: sha512-lxfDT0UuSc1HqltOGsTEAlZ6H29gpfDSdEPTapD5G63RbnYToZ+ezjzdonCCH90j5tRRCw3aLXVbiZaBW3VRVg==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@2.2.0': - resolution: {integrity: sha512-zpQMtJVqCUMn+pCSFcl9K/RPNtQE0NuMh8sKpCdEHafhwRsjP50Oq/4kMmvxSRy6d8Jslqd8BLvDngrUtmN9iA==} - engines: {node: '>=14.0.0'} - '@smithy/eventstream-serde-node@4.2.4': resolution: {integrity: sha512-TPhiGByWnYyzcpU/K3pO5V7QgtXYpE0NaJPEZBCa1Y5jlw5SjqzMSbFiLb+ZkJhqoQc0ImGyVINqnq1ze0ZRcQ==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@2.2.0': - resolution: {integrity: sha512-pvoe/vvJY0mOpuF84BEtyZoYfbehiFj8KKWk1ds2AT0mTLYFVs+7sBJZmioOFdBXKd48lfrx1vumdPdmGlCLxA==} - engines: {node: '>=14.0.0'} - '@smithy/eventstream-serde-universal@4.2.4': resolution: {integrity: sha512-GNI/IXaY/XBB1SkGBFmbW033uWA0tj085eCxYih0eccUe/PFR7+UBQv9HNDk2fD9TJu7UVsCWsH99TkpEPSOzQ==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@2.5.0': - resolution: {integrity: sha512-BOWEBeppWhLn/no/JxUL/ghTfANTjT7kg3Ww2rPqTUY9R4yHPXxJ9JhMe3Z03LN3aPwiwlpDIUcVw1xDyHqEhw==} - '@smithy/fetch-http-handler@5.3.5': resolution: {integrity: sha512-mg83SM3FLI8Sa2ooTJbsh5MFfyMTyNRwxqpKHmE0ICRIa66Aodv80DMsTQI02xBLVJ0hckwqTRr5IGAbbWuFLQ==} engines: {node: '>=18.0.0'} @@ -3949,10 +3917,6 @@ packages: resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@3.0.0': - resolution: {integrity: sha512-+Fsu6Q6C4RSJiy81Y8eApjEB5gVtM+oFKTffg+jSuwtvomJJrhUJBu2zS8wjXSgH/g1MKEWrzyChTBe6clb5FQ==} - engines: {node: '>=16.0.0'} - '@smithy/is-array-buffer@4.2.0': resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} engines: {node: '>=18.0.0'} @@ -3961,10 +3925,6 @@ packages: resolution: {integrity: sha512-hJRZuFS9UsElX4DJSJfoX4M1qXRH+VFiLMUnhsWvtOOUWRNvvOfDaUSdlNbjwv1IkpVjj/Rd/O59Jl3nhAcxow==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@2.5.1': - resolution: {integrity: sha512-1/8kFp6Fl4OsSIVTWHnNjLnTL8IqpIb/D3sTSczrKFnrE9VMNWxnrRKNvpUHOJ6zpGD5f62TPm7+17ilTJpiCQ==} - engines: {node: '>=14.0.0'} - '@smithy/middleware-endpoint@4.3.6': resolution: {integrity: sha512-PXehXofGMFpDqr933rxD8RGOcZ0QBAWtuzTgYRAHAL2BnKawHDEdf/TnGpcmfPJGwonhginaaeJIKluEojiF/w==} engines: {node: '>=18.0.0'} @@ -3973,66 +3933,34 @@ packages: resolution: {integrity: sha512-OhLx131znrEDxZPAvH/OYufR9d1nB2CQADyYFN4C3V/NQS7Mg4V6uvxHC/Dr96ZQW8IlHJTJ+vAhKt6oxWRndA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@2.3.0': - resolution: {integrity: sha512-sIADe7ojwqTyvEQBe1nc/GXB9wdHhi9UwyX0lTyttmUWDJLP655ZYE1WngnNyXREme8I27KCaUhyhZWRXL0q7Q==} - engines: {node: '>=14.0.0'} - '@smithy/middleware-serde@4.2.4': resolution: {integrity: sha512-jUr3x2CDhV15TOX2/Uoz4gfgeqLrRoTQbYAuhLS7lcVKNev7FeYSJ1ebEfjk+l9kbb7k7LfzIR/irgxys5ZTOg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@2.2.0': - resolution: {integrity: sha512-Qntc3jrtwwrsAC+X8wms8zhrTr0sFXnyEGhZd9sLtsJ/6gGQKFzNB+wWbOcpJd7BR8ThNCoKt76BuQahfMvpeA==} - engines: {node: '>=14.0.0'} - '@smithy/middleware-stack@4.2.4': resolution: {integrity: sha512-Gy3TKCOnm9JwpFooldwAboazw+EFYlC+Bb+1QBsSi5xI0W5lX81j/P5+CXvD/9ZjtYKRgxq+kkqd/KOHflzvgA==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@2.3.0': - resolution: {integrity: sha512-0elK5/03a1JPWMDPaS726Iw6LpQg80gFut1tNpPfxFuChEEklo2yL823V94SpTZTxmKlXFtFgsP55uh3dErnIg==} - engines: {node: '>=14.0.0'} - '@smithy/node-config-provider@4.3.4': resolution: {integrity: sha512-3X3w7qzmo4XNNdPKNS4nbJcGSwiEMsNsRSunMA92S4DJLLIrH5g1AyuOA2XKM9PAPi8mIWfqC+fnfKNsI4KvHw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@2.5.0': - resolution: {integrity: sha512-mVGyPBzkkGQsPoxQUbxlEfRjrj6FPyA3u3u2VXGr9hT8wilsoQdZdvKpMBFMB8Crfhv5dNkKHIW0Yyuc7eABqA==} - engines: {node: '>=14.0.0'} - '@smithy/node-http-handler@4.4.4': resolution: {integrity: sha512-VXHGfzCXLZeKnFp6QXjAdy+U8JF9etfpUXD1FAbzY1GzsFJiDQRQIt2CnMUvUdz3/YaHNqT3RphVWMUpXTIODA==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@2.2.0': - resolution: {integrity: sha512-+xiil2lFhtTRzXkx8F053AV46QnIw6e7MV8od5Mi68E1ICOjCeCHw2XfLnDEUHnT9WGUIkwcqavXjfwuJbGlpg==} - engines: {node: '>=14.0.0'} - '@smithy/property-provider@4.2.4': resolution: {integrity: sha512-g2DHo08IhxV5GdY3Cpt/jr0mkTlAD39EJKN27Jb5N8Fb5qt8KG39wVKTXiTRCmHHou7lbXR8nKVU14/aRUf86w==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@3.3.0': - resolution: {integrity: sha512-Xy5XK1AFWW2nlY/biWZXu6/krgbaf2dg0q492D8M5qthsnU2H+UgFeZLbM76FnH7s6RO/xhQRkj+T6KBO3JzgQ==} - engines: {node: '>=14.0.0'} - '@smithy/protocol-http@5.3.4': resolution: {integrity: sha512-3sfFd2MAzVt0Q/klOmjFi3oIkxczHs0avbwrfn1aBqtc23WqQSmjvk77MBw9WkEQcwbOYIX5/2z4ULj8DuxSsw==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@2.2.0': - resolution: {integrity: sha512-L1kSeviUWL+emq3CUVSgdogoM/D9QMFaqxL/dd0X7PCNWmPXqt+ExtrBjqT0V7HLN03Vs9SuiLrG3zy3JGnE5A==} - engines: {node: '>=14.0.0'} - '@smithy/querystring-builder@4.2.4': resolution: {integrity: sha512-KQ1gFXXC+WsbPFnk7pzskzOpn4s+KheWgO3dzkIEmnb6NskAIGp/dGdbKisTPJdtov28qNDohQrgDUKzXZBLig==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@2.2.0': - resolution: {integrity: sha512-BvHCDrKfbG5Yhbpj4vsbuPV2GgcpHiAkLeIlcA1LtfpMz3jrqizP1+OguSNSj1MwBHEiN+jwNisXLGdajGDQJA==} - engines: {node: '>=14.0.0'} - '@smithy/querystring-parser@4.2.4': resolution: {integrity: sha512-aHb5cqXZocdzEkZ/CvhVjdw5l4r1aU/9iMEyoKzH4eXMowT6M0YjBpp7W/+XjkBnY8Xh0kVd55GKjnPKlCwinQ==} engines: {node: '>=18.0.0'} @@ -4041,57 +3969,26 @@ packages: resolution: {integrity: sha512-fdWuhEx4+jHLGeew9/IvqVU/fxT/ot70tpRGuOLxE3HzZOyKeTQfYeV1oaBXpzi93WOk668hjMuuagJ2/Qs7ng==} engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@2.4.0': - resolution: {integrity: sha512-WyujUJL8e1B6Z4PBfAqC/aGY1+C7T0w20Gih3yrvJSk97gpiVfB+y7c46T4Nunk+ZngLq0rOIdeVeIklk0R3OA==} - engines: {node: '>=14.0.0'} - '@smithy/shared-ini-file-loader@4.3.4': resolution: {integrity: sha512-y5ozxeQ9omVjbnJo9dtTsdXj9BEvGx2X8xvRgKnV+/7wLBuYJQL6dOa/qMY6omyHi7yjt1OA97jZLoVRYi8lxA==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@3.1.2': - resolution: {integrity: sha512-3BcPylEsYtD0esM4Hoyml/+s7WP2LFhcM3J2AGdcL2vx9O60TtfpDOL72gjb4lU8NeRPeKAwR77YNyyGvMbuEA==} - engines: {node: '>=16.0.0'} - '@smithy/signature-v4@5.3.4': resolution: {integrity: sha512-ScDCpasxH7w1HXHYbtk3jcivjvdA1VICyAdgvVqKhKKwxi+MTwZEqFw0minE+oZ7F07oF25xh4FGJxgqgShz0A==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@2.5.1': - resolution: {integrity: sha512-jrbSQrYCho0yDaaf92qWgd+7nAeap5LtHTI51KXqmpIFCceKU3K9+vIVTUH72bOJngBMqa4kyu1VJhRcSrk/CQ==} - engines: {node: '>=14.0.0'} - '@smithy/smithy-client@4.9.2': resolution: {integrity: sha512-gZU4uAFcdrSi3io8U99Qs/FvVdRxPvIMToi+MFfsy/DN9UqtknJ1ais+2M9yR8e0ASQpNmFYEKeIKVcMjQg3rg==} engines: {node: '>=18.0.0'} - '@smithy/types@2.12.0': - resolution: {integrity: sha512-QwYgloJ0sVNBeBuBs65cIkTbfzV/Q6ZNPCJ99EICFEdJYG50nGIY/uYXp+TbsdJReIuPr0a0kXmCvren3MbRRw==} - engines: {node: '>=14.0.0'} - - '@smithy/types@3.7.2': - resolution: {integrity: sha512-bNwBYYmN8Eh9RyjS1p2gW6MIhSO2rl7X9QeLM8iTdcGRP+eDiIWDt66c9IysCc22gefKszZv+ubV9qZc7hdESg==} - engines: {node: '>=16.0.0'} - - '@smithy/types@4.3.1': - resolution: {integrity: sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==} - engines: {node: '>=18.0.0'} - '@smithy/types@4.8.1': resolution: {integrity: sha512-N0Zn0OT1zc+NA+UVfkYqQzviRh5ucWwO7mBV3TmHHprMnfcJNfhlPicDkBHi0ewbh+y3evR6cNAW0Raxvb01NA==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@2.2.0': - resolution: {integrity: sha512-hoA4zm61q1mNTpksiSWp2nEl1dt3j726HdRhiNgVJQMj7mLp7dprtF57mOB6JvEk/x9d2bsuL5hlqZbBuHQylQ==} - '@smithy/url-parser@4.2.4': resolution: {integrity: sha512-w/N/Iw0/PTwJ36PDqU9PzAwVElo4qXxCC0eCTlUtIz/Z5V/2j/cViMHi0hPukSBHp4DVwvUlUhLgCzqSJ6plrg==} engines: {node: '>=18.0.0'} - '@smithy/util-base64@2.3.0': - resolution: {integrity: sha512-s3+eVwNeJuXUwuMbusncZNViuhv2LjVJ1nMwTqSA0XAC7gjKhqqxRdJPhR8+YrkoZ9IiIbFk/yK6ACe/xlF+hw==} - engines: {node: '>=14.0.0'} - '@smithy/util-base64@4.3.0': resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} engines: {node: '>=18.0.0'} @@ -4108,10 +4005,6 @@ packages: resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@3.0.0': - resolution: {integrity: sha512-aEOHCgq5RWFbP+UDPvPot26EJHjOC+bRgse5A8V3FSShqd5E5UN4qc7zkwsvJPPAVsf73QwYcHN1/gt/rtLwQA==} - engines: {node: '>=16.0.0'} - '@smithy/util-buffer-from@4.2.0': resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} engines: {node: '>=18.0.0'} @@ -4132,26 +4025,10 @@ packages: resolution: {integrity: sha512-f+nBDhgYRCmUEDKEQb6q0aCcOTXRDqH5wWaFHJxt4anB4pKHlgGoYP3xtioKXH64e37ANUkzWf6p4Mnv1M5/Vg==} engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@2.2.0': - resolution: {integrity: sha512-7iKXR+/4TpLK194pVjKiasIyqMtTYJsgKgM242Y9uzt5dhHnUDvMNb+3xIhRJ9QhvqGii/5cRUt4fJn3dtXNHQ==} - engines: {node: '>=14.0.0'} - - '@smithy/util-hex-encoding@3.0.0': - resolution: {integrity: sha512-eFndh1WEK5YMUYvy3lPlVmYY/fZcQE1D8oSf41Id2vCeIkKJXPcYDCZD+4+xViI6b1XSd7tE+s5AmXzz5ilabQ==} - engines: {node: '>=16.0.0'} - '@smithy/util-hex-encoding@4.2.0': resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} engines: {node: '>=18.0.0'} - '@smithy/util-middleware@2.2.0': - resolution: {integrity: sha512-L1qpleXf9QD6LwLCJ5jddGkgWyuSvWBkJwWAZ6kFkdifdso+sk3L3O1HdmPvCdnCK3IS4qWyPxev01QMnfHSBw==} - engines: {node: '>=14.0.0'} - - '@smithy/util-middleware@3.0.11': - resolution: {integrity: sha512-dWpyc1e1R6VoXrwLoLDd57U1z6CwNSdkM69Ie4+6uYh2GC7Vg51Qtan7ITzczuVpqezdDTKJGJB95fFvvjU/ow==} - engines: {node: '>=16.0.0'} - '@smithy/util-middleware@4.2.4': resolution: {integrity: sha512-fKGQAPAn8sgV0plRikRVo6g6aR0KyKvgzNrPuM74RZKy/wWVzx3BMk+ZWEueyN3L5v5EDg+P582mKU+sH5OAsg==} engines: {node: '>=18.0.0'} @@ -4160,22 +4037,10 @@ packages: resolution: {integrity: sha512-yQncJmj4dtv/isTXxRb4AamZHy4QFr4ew8GxS6XLWt7sCIxkPxPzINWd7WLISEFPsIan14zrKgvyAF+/yzfwoA==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@2.2.0': - resolution: {integrity: sha512-17faEXbYWIRst1aU9SvPZyMdWmqIrduZjVOqCPMIsWFNxs5yQQgFrJL6b2SdiCzyW9mJoDjFtgi53xx7EH+BXA==} - engines: {node: '>=14.0.0'} - '@smithy/util-stream@4.5.5': resolution: {integrity: sha512-7M5aVFjT+HPilPOKbOmQfCIPchZe4DSBc1wf1+NvHvSoFTiFtauZzT+onZvCj70xhXd0AEmYnZYmdJIuwxOo4w==} engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@2.2.0': - resolution: {integrity: sha512-jtmJMyt1xMD/d8OtbVJ2gFZOSKc+ueYJZPW20ULW1GOp/q/YIM0wNh+u8ZFao9UaIGz4WoPW8hC64qlWLIfoDA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-uri-escape@3.0.0': - resolution: {integrity: sha512-LqR7qYLgZTD7nWLBecUi4aqolw8Mhza9ArpNEQ881MJJIU2sE5iHCK6TdyqqzcDLy0OPe10IY4T8ctVdtynubg==} - engines: {node: '>=16.0.0'} - '@smithy/util-uri-escape@4.2.0': resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} engines: {node: '>=18.0.0'} @@ -4184,10 +4049,6 @@ packages: resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@3.0.0': - resolution: {integrity: sha512-rUeT12bxFnplYDe815GXbq/oixEGHfRFFtcTF3YdDi/JaENIM6aSYYLJydG83UNzLXeRI5K8abYd/8Sp/QM0kA==} - engines: {node: '>=16.0.0'} - '@smithy/util-utf8@4.2.0': resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} engines: {node: '>=18.0.0'} @@ -5146,6 +5007,9 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + aws4fetch@1.0.20: + resolution: {integrity: sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==} + axios@1.12.0: resolution: {integrity: sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==} @@ -11130,12 +10994,28 @@ snapshots: '@adobe/css-tools@4.4.2': {} + '@ai-sdk/amazon-bedrock@4.0.50(zod@3.25.76)': + dependencies: + '@ai-sdk/anthropic': 3.0.37(zod@3.25.76) + '@ai-sdk/provider': 3.0.7 + '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) + '@smithy/eventstream-codec': 4.2.4 + '@smithy/util-utf8': 4.2.0 + aws4fetch: 1.0.20 + zod: 3.25.76 + '@ai-sdk/anthropic@2.0.58(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.1 '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/anthropic@3.0.37(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.7 + '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/cerebras@1.0.35(zod@3.25.76)': dependencies: '@ai-sdk/openai-compatible': 1.0.31(zod@3.25.76) @@ -11304,23 +11184,6 @@ snapshots: '@antfu/utils@8.1.1': {} - '@anthropic-ai/bedrock-sdk@0.10.4': - dependencies: - '@anthropic-ai/sdk': 0.37.0 - '@aws-crypto/sha256-js': 4.0.0 - '@aws-sdk/client-bedrock-runtime': 3.922.0 - '@aws-sdk/credential-providers': 3.922.0 - '@smithy/eventstream-serde-node': 2.2.0 - '@smithy/fetch-http-handler': 2.5.0 - '@smithy/protocol-http': 3.3.0 - '@smithy/signature-v4': 3.1.2 - '@smithy/smithy-client': 2.5.1 - '@smithy/types': 2.12.0 - '@smithy/util-base64': 2.3.0 - transitivePeerDependencies: - - aws-crt - - encoding - '@anthropic-ai/sdk@0.37.0': dependencies: '@types/node': 18.19.100 @@ -11349,12 +11212,6 @@ snapshots: '@csstools/css-tokenizer': 3.0.4 lru-cache: 10.4.3 - '@aws-crypto/crc32@3.0.0': - dependencies: - '@aws-crypto/util': 3.0.0 - '@aws-sdk/types': 3.840.0 - tslib: 1.14.1 - '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -11371,12 +11228,6 @@ snapshots: '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 - '@aws-crypto/sha256-js@4.0.0': - dependencies: - '@aws-crypto/util': 4.0.0 - '@aws-sdk/types': 3.804.0 - tslib: 1.14.1 - '@aws-crypto/sha256-js@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -11387,18 +11238,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@aws-crypto/util@3.0.0': - dependencies: - '@aws-sdk/types': 3.840.0 - '@aws-sdk/util-utf8-browser': 3.259.0 - tslib: 1.14.1 - - '@aws-crypto/util@4.0.0': - dependencies: - '@aws-sdk/types': 3.840.0 - '@aws-sdk/util-utf8-browser': 3.259.0 - tslib: 1.14.1 - '@aws-crypto/util@5.2.0': dependencies: '@aws-sdk/types': 3.922.0 @@ -11806,16 +11645,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/types@3.804.0': - dependencies: - '@smithy/types': 4.3.1 - tslib: 2.8.1 - - '@aws-sdk/types@3.840.0': - dependencies: - '@smithy/types': 4.3.1 - tslib: 2.8.1 - '@aws-sdk/types@3.922.0': dependencies: '@smithy/types': 4.8.1 @@ -11855,10 +11684,6 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@aws-sdk/util-utf8-browser@3.259.0': - dependencies: - tslib: 2.8.1 - '@aws-sdk/xml-builder@3.921.0': dependencies: '@smithy/types': 4.8.1 @@ -14041,11 +13866,6 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/abort-controller@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/abort-controller@4.2.4': dependencies: '@smithy/types': 4.8.1 @@ -14081,13 +13901,6 @@ snapshots: '@smithy/url-parser': 4.2.4 tslib: 2.8.1 - '@smithy/eventstream-codec@2.2.0': - dependencies: - '@aws-crypto/crc32': 3.0.0 - '@smithy/types': 2.12.0 - '@smithy/util-hex-encoding': 2.2.0 - tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.4': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -14106,38 +13919,18 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/eventstream-serde-node@2.2.0': - dependencies: - '@smithy/eventstream-serde-universal': 2.2.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.4': dependencies: '@smithy/eventstream-serde-universal': 4.2.4 '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/eventstream-serde-universal@2.2.0': - dependencies: - '@smithy/eventstream-codec': 2.2.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.4': dependencies: '@smithy/eventstream-codec': 4.2.4 '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/fetch-http-handler@2.5.0': - dependencies: - '@smithy/protocol-http': 3.3.0 - '@smithy/querystring-builder': 2.2.0 - '@smithy/types': 2.12.0 - '@smithy/util-base64': 2.3.0 - tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.5': dependencies: '@smithy/protocol-http': 5.3.4 @@ -14162,10 +13955,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@3.0.0': - dependencies: - tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.0': dependencies: tslib: 2.8.1 @@ -14176,16 +13965,6 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/middleware-endpoint@2.5.1': - dependencies: - '@smithy/middleware-serde': 2.3.0 - '@smithy/node-config-provider': 2.3.0 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/types': 2.12.0 - '@smithy/url-parser': 2.2.0 - '@smithy/util-middleware': 2.2.0 - tslib: 2.8.1 - '@smithy/middleware-endpoint@4.3.6': dependencies: '@smithy/core': 3.17.2 @@ -14209,34 +13988,17 @@ snapshots: '@smithy/uuid': 1.1.0 tslib: 2.8.1 - '@smithy/middleware-serde@2.3.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/middleware-serde@4.2.4': dependencies: '@smithy/protocol-http': 5.3.4 '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/middleware-stack@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/middleware-stack@4.2.4': dependencies: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/node-config-provider@2.3.0': - dependencies: - '@smithy/property-provider': 2.2.0 - '@smithy/shared-ini-file-loader': 2.4.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/node-config-provider@4.3.4': dependencies: '@smithy/property-provider': 4.2.4 @@ -14244,14 +14006,6 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/node-http-handler@2.5.0': - dependencies: - '@smithy/abort-controller': 2.2.0 - '@smithy/protocol-http': 3.3.0 - '@smithy/querystring-builder': 2.2.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/node-http-handler@4.4.4': dependencies: '@smithy/abort-controller': 4.2.4 @@ -14260,43 +14014,22 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/property-provider@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/property-provider@4.2.4': dependencies: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/protocol-http@3.3.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/protocol-http@5.3.4': dependencies: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/querystring-builder@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - '@smithy/util-uri-escape': 2.2.0 - tslib: 2.8.1 - '@smithy/querystring-builder@4.2.4': dependencies: '@smithy/types': 4.8.1 '@smithy/util-uri-escape': 4.2.0 tslib: 2.8.1 - '@smithy/querystring-parser@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/querystring-parser@4.2.4': dependencies: '@smithy/types': 4.8.1 @@ -14306,26 +14039,11 @@ snapshots: dependencies: '@smithy/types': 4.8.1 - '@smithy/shared-ini-file-loader@2.4.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/shared-ini-file-loader@4.3.4': dependencies: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/signature-v4@3.1.2': - dependencies: - '@smithy/is-array-buffer': 3.0.0 - '@smithy/types': 3.7.2 - '@smithy/util-hex-encoding': 3.0.0 - '@smithy/util-middleware': 3.0.11 - '@smithy/util-uri-escape': 3.0.0 - '@smithy/util-utf8': 3.0.0 - tslib: 2.8.1 - '@smithy/signature-v4@5.3.4': dependencies: '@smithy/is-array-buffer': 4.2.0 @@ -14337,15 +14055,6 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/smithy-client@2.5.1': - dependencies: - '@smithy/middleware-endpoint': 2.5.1 - '@smithy/middleware-stack': 2.2.0 - '@smithy/protocol-http': 3.3.0 - '@smithy/types': 2.12.0 - '@smithy/util-stream': 2.2.0 - tslib: 2.8.1 - '@smithy/smithy-client@4.9.2': dependencies: '@smithy/core': 3.17.2 @@ -14356,40 +14065,16 @@ snapshots: '@smithy/util-stream': 4.5.5 tslib: 2.8.1 - '@smithy/types@2.12.0': - dependencies: - tslib: 2.8.1 - - '@smithy/types@3.7.2': - dependencies: - tslib: 2.8.1 - - '@smithy/types@4.3.1': - dependencies: - tslib: 2.8.1 - '@smithy/types@4.8.1': dependencies: tslib: 2.8.1 - '@smithy/url-parser@2.2.0': - dependencies: - '@smithy/querystring-parser': 2.2.0 - '@smithy/types': 2.12.0 - tslib: 2.8.1 - '@smithy/url-parser@4.2.4': dependencies: '@smithy/querystring-parser': 4.2.4 '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/util-base64@2.3.0': - dependencies: - '@smithy/util-buffer-from': 2.2.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - '@smithy/util-base64@4.3.0': dependencies: '@smithy/util-buffer-from': 4.2.0 @@ -14409,11 +14094,6 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@3.0.0': - dependencies: - '@smithy/is-array-buffer': 3.0.0 - tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.0': dependencies: '@smithy/is-array-buffer': 4.2.0 @@ -14446,28 +14126,10 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/util-hex-encoding@2.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-hex-encoding@3.0.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.0': dependencies: tslib: 2.8.1 - '@smithy/util-middleware@2.2.0': - dependencies: - '@smithy/types': 2.12.0 - tslib: 2.8.1 - - '@smithy/util-middleware@3.0.11': - dependencies: - '@smithy/types': 3.7.2 - tslib: 2.8.1 - '@smithy/util-middleware@4.2.4': dependencies: '@smithy/types': 4.8.1 @@ -14479,17 +14141,6 @@ snapshots: '@smithy/types': 4.8.1 tslib: 2.8.1 - '@smithy/util-stream@2.2.0': - dependencies: - '@smithy/fetch-http-handler': 2.5.0 - '@smithy/node-http-handler': 2.5.0 - '@smithy/types': 2.12.0 - '@smithy/util-base64': 2.3.0 - '@smithy/util-buffer-from': 2.2.0 - '@smithy/util-hex-encoding': 2.2.0 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - '@smithy/util-stream@4.5.5': dependencies: '@smithy/fetch-http-handler': 5.3.5 @@ -14501,14 +14152,6 @@ snapshots: '@smithy/util-utf8': 4.2.0 tslib: 2.8.1 - '@smithy/util-uri-escape@2.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-uri-escape@3.0.0': - dependencies: - tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.0': dependencies: tslib: 2.8.1 @@ -14518,11 +14161,6 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@3.0.0': - dependencies: - '@smithy/util-buffer-from': 3.0.0 - tslib: 2.8.1 - '@smithy/util-utf8@4.2.0': dependencies: '@smithy/util-buffer-from': 4.2.0 @@ -15627,6 +15265,8 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 + aws4fetch@1.0.20: {} + axios@1.12.0: dependencies: follow-redirects: 1.15.11 diff --git a/src/api/providers/__tests__/bedrock-custom-arn.spec.ts b/src/api/providers/__tests__/bedrock-custom-arn.spec.ts index dfad54c1fd0..75cc27c89da 100644 --- a/src/api/providers/__tests__/bedrock-custom-arn.spec.ts +++ b/src/api/providers/__tests__/bedrock-custom-arn.spec.ts @@ -22,38 +22,6 @@ vitest.mock("../../../utils/logging", () => ({ }, })) -// Mock AWS SDK -vitest.mock("@aws-sdk/client-bedrock-runtime", () => { - const mockModule = { - lastCommandInput: null as Record | null, - mockSend: vitest.fn().mockImplementation(async function () { - return { - output: new TextEncoder().encode(JSON.stringify({ content: "Test response" })), - } - }), - mockConverseCommand: vitest.fn(function (input) { - mockModule.lastCommandInput = input - return { input } - }), - MockBedrockRuntimeClient: class { - public config: any - public send: any - - constructor(config: { region?: string }) { - this.config = config - this.send = mockModule.mockSend - } - }, - } - - return { - BedrockRuntimeClient: mockModule.MockBedrockRuntimeClient, - ConverseCommand: mockModule.mockConverseCommand, - ConverseStreamCommand: vitest.fn(), - __mock: mockModule, // Expose mock internals for testing - } -}) - describe("Bedrock ARN Handling", () => { // Helper function to create a handler with specific options const createHandler = (options: Partial = {}) => { @@ -224,8 +192,8 @@ describe("Bedrock ARN Handling", () => { "arn:aws:bedrock:eu-west-1:123456789012:inference-profile/anthropic.claude-3-sonnet-20240229-v1:0", }) - // Verify the client was created with the ARN region, not the provided region - expect((handler as any).client.config.region).toBe("eu-west-1") + // Verify the handler's options were updated with the ARN region + expect((handler as any).options.awsRegion).toBe("eu-west-1") }) it("should log region mismatch warning when ARN region differs from provided region", () => { diff --git a/src/api/providers/__tests__/bedrock-error-handling.spec.ts b/src/api/providers/__tests__/bedrock-error-handling.spec.ts index 2041dde4577..d217984c8da 100644 --- a/src/api/providers/__tests__/bedrock-error-handling.spec.ts +++ b/src/api/providers/__tests__/bedrock-error-handling.spec.ts @@ -8,9 +8,6 @@ vi.mock("@roo-code/telemetry", () => ({ }, })) -// Mock BedrockRuntimeClient and commands -const mockSend = vi.fn() - // Mock AWS SDK credential providers vi.mock("@aws-sdk/credential-providers", () => { return { @@ -21,16 +18,27 @@ vi.mock("@aws-sdk/credential-providers", () => { } }) -vi.mock("@aws-sdk/client-bedrock-runtime", () => ({ - BedrockRuntimeClient: vi.fn().mockImplementation(() => ({ - send: mockSend, - })), - ConverseStreamCommand: vi.fn(), - ConverseCommand: vi.fn(), +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), })) import { AwsBedrockHandler } from "../bedrock" -import { Anthropic } from "@anthropic-ai/sdk" +import type { Anthropic } from "@anthropic-ai/sdk" describe("AwsBedrockHandler Error Handling", () => { let handler: AwsBedrockHandler @@ -46,6 +54,10 @@ describe("AwsBedrockHandler Error Handling", () => { }) }) + /** + * Helper: create an Error with optional extra properties that + * the production code inspects (status, name, $metadata, __type). + */ const createMockError = (options: { message?: string name?: string @@ -56,505 +68,481 @@ describe("AwsBedrockHandler Error Handling", () => { requestId?: string extendedRequestId?: string cfId?: string - [key: string]: any // Allow additional properties + [key: string]: unknown } }): Error => { const error = new Error(options.message || "Test error") as any if (options.name) error.name = options.name - if (options.status) error.status = options.status + if (options.status !== undefined) error.status = options.status if (options.__type) error.__type = options.__type if (options.$metadata) error.$metadata = options.$metadata return error } - describe("Throttling Error Detection", () => { - it("should detect throttling from HTTP 429 status code", async () => { + // ----------------------------------------------------------------------- + // Throttling Detection — completePrompt path + // + // Production flow: generateText throws → catch → isThrottlingError() is + // NOT called in completePrompt (only in createMessage), so it falls + // through to handleAiSdkError which wraps with "Bedrock: ". + // + // For createMessage: streamText throws → catch → isThrottlingError() + // returns true → re-throws original error. + // ----------------------------------------------------------------------- + + describe("Throttling Error Detection (createMessage)", () => { + it("should re-throw throttling errors with status 429 for retry", async () => { const throttleError = createMockError({ message: "Request failed", status: 429, }) - mockSend.mockRejectedValueOnce(throttleError) + mockStreamText.mockImplementation(() => { + throw throttleError + }) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Request failed") }) - it("should detect throttling from AWS SDK $metadata.httpStatusCode", async () => { + it("should re-throw throttling errors detected via $metadata.httpStatusCode", async () => { const throttleError = createMockError({ message: "Request failed", $metadata: { httpStatusCode: 429 }, }) - mockSend.mockRejectedValueOnce(throttleError) + mockStreamText.mockImplementation(() => { + throw throttleError + }) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Request failed") }) - it("should detect throttling from ThrottlingException name", async () => { + it("should re-throw ThrottlingException by name", async () => { const throttleError = createMockError({ message: "Request failed", name: "ThrottlingException", }) - mockSend.mockRejectedValueOnce(throttleError) - - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } - }) - - it("should detect throttling from __type field", async () => { - const throttleError = createMockError({ - message: "Request failed", - __type: "ThrottlingException", + mockStreamText.mockImplementation(() => { + throw throttleError }) - mockSend.mockRejectedValueOnce(throttleError) + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Request failed") }) - it("should detect throttling from 'Bedrock is unable to process your request' message", async () => { + it("should re-throw 'Bedrock is unable to process your request' as throttling", async () => { const throttleError = createMockError({ message: "Bedrock is unable to process your request", }) - mockSend.mockRejectedValueOnce(throttleError) + mockStreamText.mockImplementation(() => { + throw throttleError + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toMatch(/throttled or rate limited/) - } + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Bedrock is unable to process your request") }) it("should detect throttling from various message patterns", async () => { - const throttlingMessages = [ - "Request throttled", - "Rate limit exceeded", - "Too many requests", - "Service unavailable due to high demand", - "Server is overloaded", - "System is busy", - "Please wait and try again", - ] + const throttlingMessages = ["Request throttled", "Rate limit exceeded", "Too many requests"] for (const message of throttlingMessages) { + vi.clearAllMocks() const throttleError = createMockError({ message }) - mockSend.mockRejectedValueOnce(throttleError) - - try { - await handler.completePrompt("test") - // Should not reach here as completePrompt should throw - throw new Error("Expected error to be thrown") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } + + mockStreamText.mockImplementation(() => { + throw throttleError + }) + + const localHandler = new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + }) + + const generator = localHandler.createMessage("system", [{ role: "user", content: "test" }]) + + // Throttling errors are re-thrown with original message for retry + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow(message) } }) - it("should display appropriate error information for throttling errors", async () => { - const throttlingError = createMockError({ - message: "Bedrock is unable to process your request", - name: "ThrottlingException", + it("should prioritize HTTP status 429 over message content for throttling", async () => { + const mixedError = createMockError({ + message: "Some generic error message", status: 429, - $metadata: { - httpStatusCode: 429, - requestId: "12345-abcde-67890", - extendedRequestId: "extended-12345", - cfId: "cf-12345", - }, }) - mockSend.mockRejectedValueOnce(throttlingError) + mockStreamText.mockImplementation(() => { + throw mixedError + }) - try { - await handler.completePrompt("test") - throw new Error("Expected error to be thrown") - } catch (error) { - // Should contain the main error message - expect(error.message).toContain("throttled or rate limited") - } + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + // Because status=429, it's throttling → re-throws original error + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Some generic error message") }) - }) - describe("Service Quota Exceeded Detection", () => { - it("should detect service quota exceeded errors", async () => { - const quotaError = createMockError({ - message: "Service quota exceeded for model requests", + it("should prioritize ThrottlingException name over message for throttling", async () => { + const specificError = createMockError({ + message: "Some other error occurred", + name: "ThrottlingException", }) - mockSend.mockRejectedValueOnce(quotaError) + mockStreamText.mockImplementation(() => { + throw specificError + }) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("Service quota exceeded") - } catch (error) { - expect(error.message).toContain("Service quota exceeded") - } + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + // ThrottlingException → re-throws original for retry + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Some other error occurred") }) }) - describe("Model Not Ready Detection", () => { - it("should detect model not ready errors", async () => { - const modelError = createMockError({ - message: "Model is not ready, please try again later", + // ----------------------------------------------------------------------- + // Non-throttling errors (createMessage) are wrapped by handleAiSdkError + // ----------------------------------------------------------------------- + + describe("Non-throttling errors (createMessage)", () => { + it("should wrap non-throttling errors with provider name via handleAiSdkError", async () => { + const genericError = createMockError({ + message: "Something completely unexpected happened", }) - mockSend.mockRejectedValueOnce(modelError) + mockStreamText.mockImplementation(() => { + throw genericError + }) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("Model is not ready") - } catch (error) { - expect(error.message).toContain("Model is not ready") - } + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw + } + }).rejects.toThrow("Bedrock: Something completely unexpected happened") }) - }) - describe("Internal Server Error Detection", () => { - it("should detect internal server errors", async () => { - const serverError = createMockError({ + it("should preserve status code from non-throttling API errors", async () => { + const apiError = createMockError({ message: "Internal server error occurred", + status: 500, }) - mockSend.mockRejectedValueOnce(serverError) + mockStreamText.mockImplementation(() => { + throw apiError + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) try { - const result = await handler.completePrompt("test") - expect(result).toContain("internal server error") - } catch (error) { - expect(error.message).toContain("internal server error") + for await (const _chunk of generator) { + // should throw + } + throw new Error("Expected error to be thrown") + } catch (error: any) { + expect(error.message).toContain("Bedrock:") + expect(error.message).toContain("Internal server error occurred") } }) - }) - describe("Token Limit Detection", () => { - it("should detect enhanced token limit errors", async () => { - const tokenErrors = [ - "Too many tokens in request", - "Token limit exceeded", - "Maximum context length reached", - "Context length exceeds limit", - ] - - for (const message of tokenErrors) { - const tokenError = createMockError({ message }) - mockSend.mockRejectedValueOnce(tokenError) - - try { - await handler.completePrompt("test") - // Should not reach here as completePrompt should throw - throw new Error("Expected error to be thrown") - } catch (error) { - // Either "Too many tokens" for token-specific errors or "throttled" for limit-related errors - expect(error.message).toMatch(/Too many tokens|throttled or rate limited/) + it("should handle validation errors (token limits) as non-throttling", async () => { + const tokenError = createMockError({ + message: "Too many tokens in request", + name: "ValidationException", + }) + + mockStreamText.mockImplementation(() => { + throw tokenError + }) + + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) + + await expect(async () => { + for await (const _chunk of generator) { + // should throw } - } + }).rejects.toThrow("Bedrock: Too many tokens in request") }) }) + // ----------------------------------------------------------------------- + // Streaming context: errors mid-stream + // ----------------------------------------------------------------------- + describe("Streaming Context Error Handling", () => { - it("should handle throttling errors in streaming context", async () => { + it("should re-throw throttling errors that occur mid-stream", async () => { const throttleError = createMockError({ message: "Bedrock is unable to process your request", status: 429, }) - const mockStream = { - [Symbol.asyncIterator]() { - return { - async next() { - throw throttleError - }, - } - }, + // Mock streamText to return an object whose fullStream throws mid-iteration + async function* failingStream() { + yield { type: "text-delta" as const, textDelta: "partial" } + throw throttleError } - mockSend.mockResolvedValueOnce({ stream: mockStream }) + mockStreamText.mockReturnValue({ + fullStream: failingStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - // For throttling errors, it should throw immediately without yielding chunks - // This allows the retry mechanism to catch and handle it await expect(async () => { - for await (const chunk of generator) { - // Should not yield any chunks for throttling errors + for await (const _chunk of generator) { + // may yield partial text before throwing } }).rejects.toThrow("Bedrock is unable to process your request") }) - it("should yield error chunks for non-throttling errors in streaming context", async () => { + it("should wrap non-throttling errors that occur mid-stream via handleAiSdkError", async () => { const genericError = createMockError({ message: "Some other error", status: 500, }) - const mockStream = { - [Symbol.asyncIterator]() { - return { - async next() { - throw genericError - }, - } - }, + async function* failingStream() { + yield { type: "text-delta" as const, textDelta: "partial" } + throw genericError } - mockSend.mockResolvedValueOnce({ stream: mockStream }) + mockStreamText.mockReturnValue({ + fullStream: failingStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - const chunks: any[] = [] - try { - for await (const chunk of generator) { - chunks.push(chunk) + await expect(async () => { + for await (const _chunk of generator) { + // should throw } - } catch (error) { - // Expected to throw after yielding chunks - } - - // Should have yielded error chunks before throwing for non-throttling errors - expect( - chunks.some((chunk) => chunk.type === "text" && chunk.text && chunk.text.includes("Some other error")), - ).toBe(true) + }).rejects.toThrow("Bedrock: Some other error") }) }) - describe("Error Priority and Specificity", () => { - it("should prioritize HTTP status codes over message patterns", async () => { - // Error with both 429 status and generic message should be detected as throttling - const mixedError = createMockError({ - message: "Some generic error message", - status: 429, - }) + // ----------------------------------------------------------------------- + // completePrompt errors — all go through handleAiSdkError (no throttle check) + // ----------------------------------------------------------------------- - mockSend.mockRejectedValueOnce(mixedError) + describe("completePrompt error handling", () => { + it("should wrap errors with provider name for completePrompt", async () => { + mockGenerateText.mockRejectedValueOnce(new Error("Bedrock API failure")) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } + await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: Bedrock API failure") }) - it("should prioritize AWS error types over message patterns", async () => { - // Error with ThrottlingException name but different message should still be throttling - const specificError = createMockError({ - message: "Some other error occurred", - name: "ThrottlingException", + it("should wrap throttling-pattern errors with provider name for completePrompt", async () => { + const throttleError = createMockError({ + message: "Bedrock is unable to process your request", + status: 429, }) - mockSend.mockRejectedValueOnce(specificError) + mockGenerateText.mockRejectedValueOnce(throttleError) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("throttled or rate limited") - } catch (error) { - expect(error.message).toContain("throttled or rate limited") - } + // completePrompt does NOT have the throttle-rethrow path; it always uses handleAiSdkError + await expect(handler.completePrompt("test")).rejects.toThrow( + "Bedrock: Bedrock is unable to process your request", + ) }) - }) - describe("Unknown Error Fallback", () => { - it("should still show unknown error for truly unrecognized errors", async () => { - const unknownError = createMockError({ - message: "Something completely unexpected happened", - }) + it("should handle concurrent generateText failures", async () => { + const error = new Error("API failure") + mockGenerateText.mockRejectedValue(error) - mockSend.mockRejectedValueOnce(unknownError) + const promises = Array.from({ length: 5 }, () => handler.completePrompt("test")) + const results = await Promise.allSettled(promises) - try { - const result = await handler.completePrompt("test") - expect(result).toContain("Unknown Error") - } catch (error) { - expect(error.message).toContain("Unknown Error") - } + results.forEach((result) => { + expect(result.status).toBe("rejected") + if (result.status === "rejected") { + expect(result.reason.message).toContain("Bedrock:") + } + }) }) - }) - describe("Enhanced Error Throw for Retry System", () => { - it("should throw enhanced error messages for completePrompt to display in retry system", async () => { - const throttlingError = createMockError({ - message: "Too many tokens, rate limited", - status: 429, - $metadata: { - httpStatusCode: 429, - requestId: "test-request-id-12345", - }, + it("should preserve status code from API call errors in completePrompt", async () => { + const apiError = createMockError({ + message: "Service unavailable", + status: 503, }) - mockSend.mockRejectedValueOnce(throttlingError) + + mockGenerateText.mockRejectedValueOnce(apiError) try { await handler.completePrompt("test") throw new Error("Expected error to be thrown") - } catch (error) { - // Should contain the verbose message template - expect(error.message).toContain("Request was throttled or rate limited") - // Should preserve original error properties - expect((error as any).status).toBe(429) - expect((error as any).$metadata.requestId).toBe("test-request-id-12345") + } catch (error: any) { + expect(error.message).toContain("Bedrock:") + expect(error.message).toContain("Service unavailable") } }) + }) - it("should throw enhanced error messages for createMessage streaming to display in retry system", async () => { - const tokenError = createMockError({ - message: "Too many tokens in request", - name: "ValidationException", - $metadata: { - httpStatusCode: 400, - requestId: "token-error-id-67890", - extendedRequestId: "extended-12345", - }, - }) - - const mockStream = { - [Symbol.asyncIterator]() { - return { - async next() { - throw tokenError - }, - } - }, - } + // ----------------------------------------------------------------------- + // Telemetry + // ----------------------------------------------------------------------- + + describe("Error telemetry", () => { + it("should capture telemetry for createMessage errors", async () => { + mockStreamText.mockImplementation(() => { + throw new Error("Stream failure") + }) - mockSend.mockResolvedValueOnce({ stream: mockStream }) + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - try { - const stream = handler.createMessage("system", [{ role: "user", content: "test" }]) - for await (const chunk of stream) { - // Should not reach here as it should throw an error + await expect(async () => { + for await (const _chunk of generator) { + // should throw } - throw new Error("Expected error to be thrown") - } catch (error) { - // Should contain error codes (note: this will be caught by the non-throttling error path) - expect(error.message).toContain("Too many tokens") - // Should preserve original error properties - expect(error.name).toBe("ValidationException") - expect((error as any).$metadata.requestId).toBe("token-error-id-67890") - } + }).rejects.toThrow() + + expect(mockCaptureException).toHaveBeenCalled() }) - }) - describe("Edge Case Test Coverage", () => { - it("should handle concurrent throttling errors correctly", async () => { - const throttlingError = createMockError({ - message: "Bedrock is unable to process your request", + it("should capture telemetry for completePrompt errors", async () => { + mockGenerateText.mockRejectedValueOnce(new Error("Generate failure")) + + await expect(handler.completePrompt("test")).rejects.toThrow() + + expect(mockCaptureException).toHaveBeenCalled() + }) + + it("should capture telemetry for throttling errors too", async () => { + const throttleError = createMockError({ + message: "Rate limit exceeded", status: 429, }) - // Setup multiple concurrent requests that will all fail with throttling - mockSend.mockRejectedValue(throttlingError) - - // Execute multiple concurrent requests - const promises = Array.from({ length: 5 }, () => handler.completePrompt("test")) + mockStreamText.mockImplementation(() => { + throw throttleError + }) - // All should throw with throttling error - const results = await Promise.allSettled(promises) + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - results.forEach((result) => { - expect(result.status).toBe("rejected") - if (result.status === "rejected") { - expect(result.reason.message).toContain("throttled or rate limited") + await expect(async () => { + for await (const _chunk of generator) { + // should throw } - }) + }).rejects.toThrow() + + // Telemetry is captured even for throttling errors + expect(mockCaptureException).toHaveBeenCalled() }) + }) - it("should handle mixed error scenarios with both throttling and other indicators", async () => { - // Error with both 429 status (throttling) and validation error message - const mixedError = createMockError({ - message: "ValidationException: Your input is invalid, but also rate limited", - name: "ValidationException", - status: 429, - $metadata: { - httpStatusCode: 429, - requestId: "mixed-error-id", - }, - }) + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- - mockSend.mockRejectedValueOnce(mixedError) + describe("Edge Case Test Coverage", () => { + it("should handle non-Error objects thrown by generateText", async () => { + mockGenerateText.mockRejectedValueOnce("string error") - try { - await handler.completePrompt("test") - } catch (error) { - // Should be treated as throttling due to 429 status taking priority - expect(error.message).toContain("throttled or rate limited") - // Should still preserve metadata - expect((error as any).$metadata?.requestId).toBe("mixed-error-id") - } + await expect(handler.completePrompt("test")).rejects.toThrow("Bedrock: string error") }) - it("should handle rapid successive retries in streaming context", async () => { - const throttlingError = createMockError({ - message: "ThrottlingException", - name: "ThrottlingException", + it("should handle non-Error objects thrown by streamText", async () => { + mockStreamText.mockImplementation(() => { + throw "string error" }) - // Mock stream that throws immediately - const mockStream = { - // eslint-disable-next-line require-yield - [Symbol.asyncIterator]: async function* () { - throw throttlingError - }, - } - - mockSend.mockResolvedValueOnce({ stream: mockStream }) - - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "test" }] + const generator = handler.createMessage("system", [{ role: "user", content: "test" }]) - try { - // Should throw immediately without yielding any chunks - const stream = handler.createMessage("", messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) + // Non-Error values are not detected as throttling → handleAiSdkError path + await expect(async () => { + for await (const _chunk of generator) { + // should throw } - // Should not reach here - expect(chunks).toHaveLength(0) - } catch (error) { - // Error should be thrown immediately for retry mechanism - // The error might be a TypeError if the stream iterator fails - expect(error).toBeDefined() - // The important thing is that it throws immediately without yielding chunks - } + }).rejects.toThrow("Bedrock: string error") }) - it("should validate error properties exist before accessing them", async () => { - // Error with unusual structure - const unusualError = { - message: "Error with unusual structure", - // Missing typical properties like name, status, etc. - } - - mockSend.mockRejectedValueOnce(unusualError) + it("should handle errors with unusual structure gracefully", async () => { + const unusualError = { message: "Error with unusual structure" } + mockGenerateText.mockRejectedValueOnce(unusualError) try { await handler.completePrompt("test") - } catch (error) { - // Should handle gracefully without accessing undefined properties - expect(error.message).toContain("Unknown Error") - // Should not have undefined values in the error message + throw new Error("Expected error to be thrown") + } catch (error: any) { + // handleAiSdkError wraps with "Bedrock: ..." + expect(error.message).toContain("Bedrock:") expect(error.message).not.toContain("undefined") } }) + + it("should handle concurrent throttling errors in streaming context", async () => { + const throttlingError = createMockError({ + message: "Bedrock is unable to process your request", + status: 429, + }) + + mockStreamText.mockImplementation(() => { + throw throttlingError + }) + + // Execute multiple concurrent streaming requests + const promises = Array.from({ length: 3 }, async () => { + const localHandler = new AwsBedrockHandler({ + apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + awsAccessKey: "test-access-key", + awsSecretKey: "test-secret-key", + awsRegion: "us-east-1", + }) + const gen = localHandler.createMessage("system", [{ role: "user", content: "test" }]) + for await (const _chunk of gen) { + // should throw + } + }) + + const results = await Promise.allSettled(promises) + results.forEach((result) => { + expect(result.status).toBe("rejected") + if (result.status === "rejected") { + // Throttling errors are re-thrown with original message + expect(result.reason.message).toBe("Bedrock is unable to process your request") + } + }) + }) }) }) diff --git a/src/api/providers/__tests__/bedrock-inference-profiles.spec.ts b/src/api/providers/__tests__/bedrock-inference-profiles.spec.ts index dee3af3b916..131e462f09a 100644 --- a/src/api/providers/__tests__/bedrock-inference-profiles.spec.ts +++ b/src/api/providers/__tests__/bedrock-inference-profiles.spec.ts @@ -4,18 +4,6 @@ import { AWS_INFERENCE_PROFILE_MAPPING } from "@roo-code/types" import { AwsBedrockHandler } from "../bedrock" import { ApiHandlerOptions } from "../../../shared/api" -// Mock AWS SDK -vitest.mock("@aws-sdk/client-bedrock-runtime", () => { - return { - BedrockRuntimeClient: vitest.fn().mockImplementation(() => ({ - send: vitest.fn(), - config: { region: "us-east-1" }, - })), - ConverseCommand: vitest.fn(), - ConverseStreamCommand: vitest.fn(), - } -}) - describe("Amazon Bedrock Inference Profiles", () => { // Helper function to create a handler with specific options const createHandler = (options: Partial = {}) => { diff --git a/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts b/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts index fe16ea89eb6..63322d988ed 100644 --- a/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts +++ b/src/api/providers/__tests__/bedrock-invokedModelId.spec.ts @@ -1,350 +1,198 @@ // npx vitest run src/api/providers/__tests__/bedrock-invokedModelId.spec.ts -import { ApiHandlerOptions } from "../../../shared/api" - -import { AwsBedrockHandler, StreamEvent } from "../bedrock" +// Mock TelemetryService before other imports +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vi.fn(), + }, + }, +})) -// Mock AWS SDK credential providers and Bedrock client -vitest.mock("@aws-sdk/credential-providers", () => ({ - fromIni: vitest.fn().mockReturnValue({ +// Mock AWS SDK credential providers +vi.mock("@aws-sdk/credential-providers", () => ({ + fromIni: vi.fn().mockReturnValue({ accessKeyId: "profile-access-key", secretAccessKey: "profile-secret-key", }), })) -// Mock Smithy client -vitest.mock("@smithy/smithy-client", () => ({ - throwDefaultError: vitest.fn(), +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), })) -// Create a mock send function that we can reference -const mockSend = vitest.fn().mockImplementation(async () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - $metadata: { - httpStatusCode: 200, - requestId: "mock-request-id", - }, - stream: { - [Symbol.asyncIterator]: async function* () { - yield { - metadata: { - usage: { - inputTokens: 100, - outputTokens: 200, - }, - }, - } - }, - }, + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -// Mock AWS SDK modules -vitest.mock("@aws-sdk/client-bedrock-runtime", () => { - return { - BedrockRuntimeClient: vitest.fn().mockImplementation(() => ({ - send: mockSend, - config: { region: "us-east-1" }, - middlewareStack: { - clone: () => ({ resolve: () => {} }), - use: () => {}, - }, - })), - ConverseStreamCommand: vitest.fn((params) => ({ - ...params, - input: params, - middlewareStack: { - clone: () => ({ resolve: () => {} }), - use: () => {}, - }, - })), - ConverseCommand: vitest.fn((params) => ({ - ...params, - input: params, - middlewareStack: { - clone: () => ({ resolve: () => {} }), - use: () => {}, - }, - })), - } -}) +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), +})) + +import { AwsBedrockHandler } from "../bedrock" +import { bedrockModels } from "@roo-code/types" describe("AwsBedrockHandler with invokedModelId", () => { beforeEach(() => { - vitest.clearAllMocks() + vi.clearAllMocks() }) - // Helper function to create a mock async iterable stream - function createMockStream(events: StreamEvent[]) { - return { - [Symbol.asyncIterator]: async function* () { - for (const event of events) { - yield event - } - // Always yield a metadata event at the end - yield { - metadata: { - usage: { - inputTokens: 100, - outputTokens: 200, + /** + * Helper: set up mockStreamText to return a stream whose resolved + * `providerMetadata` contains the given `invokedModelId` in the + * `bedrock.trace.promptRouter` path. + */ + function setupMockStreamWithInvokedModelId(invokedModelId?: string) { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: ", world!" } + } + + const providerMetadata = invokedModelId + ? { + bedrock: { + trace: { + promptRouter: { + invokedModelId, + }, }, }, } - }, - } + : {} + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 200 }), + providerMetadata: Promise.resolve(providerMetadata), + }) } - it("should update costModelConfig when invokedModelId is present in the stream", async () => { - // Create a handler with a custom ARN - const mockOptions: ApiHandlerOptions = { + it("should update costModelConfig when invokedModelId is present in providerMetadata", async () => { + // Create a handler with a custom ARN (prompt router) + const handler = new AwsBedrockHandler({ awsAccessKey: "test-access-key", awsSecretKey: "test-secret-key", awsRegion: "us-east-1", awsCustomArn: "arn:aws:bedrock:us-west-2:123456789:default-prompt-router/anthropic.claude:1", - } - - const handler = new AwsBedrockHandler(mockOptions) + }) - // Verify that getModel returns the updated model info + // The default prompt router model should use sonnet pricing (inputPrice: 3) const initialModel = handler.getModel() - //the default prompt router model has an input price of 3. After the stream is handled it should be updated to 8 expect(initialModel.info.inputPrice).toBe(3) - // Create a spy on the getModel - const getModelByIdSpy = vitest.spyOn(handler, "getModelById") - - // Mock the stream to include an event with invokedModelId and usage metadata - mockSend.mockImplementationOnce(async () => { - return { - stream: createMockStream([ - // First event with invokedModelId and usage metadata - { - trace: { - promptRouter: { - invokedModelId: - "arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-3-opus-20240229-v1:0", - usage: { - inputTokens: 150, - outputTokens: 250, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }, - }, - }, - }, - { - contentBlockStart: { - start: { - text: "Hello", - }, - contentBlockIndex: 0, - }, - }, - { - contentBlockDelta: { - delta: { - text: ", world!", - }, - contentBlockIndex: 0, - }, - }, - ]), - } - }) + // Spy on getModelById to verify the invoked model is looked up + const getModelByIdSpy = vi.spyOn(handler, "getModelById") - // Create a message generator - const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }]) + // Set up stream to include an invokedModelId pointing to Claude 3 Opus + setupMockStreamWithInvokedModelId( + "arn:aws:bedrock:us-west-2:699475926481:inference-profile/us.anthropic.claude-3-opus-20240229-v1:0", + ) - // Collect all yielded events to verify usage events + // Consume the generator const events = [] - for await (const event of messageGenerator) { + for await (const event of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) { events.push(event) } - // Verify that getModelById was called with the id, not the full arn + // Verify that getModelById was called with the parsed model id and type expect(getModelByIdSpy).toHaveBeenCalledWith("anthropic.claude-3-opus-20240229-v1:0", "inference-profile") - // Verify that getModel returns the updated model info + // After processing, getModel should return the invoked model's pricing (Opus: inputPrice 15) const costModel = handler.getModel() - //expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20240620-v1:0") expect(costModel.info.inputPrice).toBe(15) - // Verify that a usage event was emitted after updating the costModelConfig - const usageEvents = events.filter((event) => event.type === "usage") + // Verify that a usage event was emitted + const usageEvents = events.filter((e: any) => e.type === "usage") expect(usageEvents.length).toBeGreaterThanOrEqual(1) - // The last usage event should have the token counts from the metadata - const lastUsageEvent = usageEvents[usageEvents.length - 1] - // Expect the usage event to include all token information + // The usage event should contain the token counts + const lastUsageEvent = usageEvents[usageEvents.length - 1] as any expect(lastUsageEvent).toMatchObject({ type: "usage", inputTokens: 100, outputTokens: 200, - // Cache tokens may be present with default values - cacheReadTokens: expect.any(Number), - cacheWriteTokens: expect.any(Number), }) }) it("should not update costModelConfig when invokedModelId is not present", async () => { - // Create a handler with default settings - const mockOptions: ApiHandlerOptions = { + const handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", awsSecretKey: "test-secret-key", awsRegion: "us-east-1", - } - - const handler = new AwsBedrockHandler(mockOptions) + }) - // Store the initial model configuration const initialModelConfig = handler.getModel() expect(initialModelConfig.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0") - // Mock the stream without an invokedModelId event - mockSend.mockImplementationOnce(async () => { - return { - stream: createMockStream([ - // Some content events but no invokedModelId - { - contentBlockStart: { - start: { - text: "Hello", - }, - contentBlockIndex: 0, - }, - }, - { - contentBlockDelta: { - delta: { - text: ", world!", - }, - contentBlockIndex: 0, - }, - }, - ]), - } - }) - - // Create a message generator - const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }]) + // Set up stream WITHOUT an invokedModelId + setupMockStreamWithInvokedModelId(undefined) // Consume the generator - for await (const _ of messageGenerator) { - // Just consume the messages + for await (const _ of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) { + // Just consume } - // Verify that getModel returns the original model info (unchanged) + // Model should remain unchanged const costModel = handler.getModel() expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0") - expect(costModel).toEqual(initialModelConfig) + expect(costModel.info.inputPrice).toBe(initialModelConfig.info.inputPrice) }) it("should handle invalid invokedModelId format gracefully", async () => { - // Create a handler with default settings - const mockOptions: ApiHandlerOptions = { + const handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", awsSecretKey: "test-secret-key", awsRegion: "us-east-1", - } - - const handler = new AwsBedrockHandler(mockOptions) - - // Mock the stream with an invalid invokedModelId - mockSend.mockImplementationOnce(async () => { - return { - stream: createMockStream([ - // Event with invalid invokedModelId format - { - trace: { - promptRouter: { - invokedModelId: "invalid-format-not-an-arn", - }, - }, - }, - // Some content events - { - contentBlockStart: { - start: { - text: "Hello", - }, - contentBlockIndex: 0, - }, - }, - ]), - } }) - // Create a message generator - const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }]) + // Set up stream with an invalid (non-ARN) invokedModelId + setupMockStreamWithInvokedModelId("invalid-format-not-an-arn") - // Consume the generator - for await (const _ of messageGenerator) { - // Just consume the messages + // Consume the generator — should not throw + for await (const _ of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) { + // Just consume } - // Verify that getModel returns the original model info + // Model should remain unchanged (the parseArn call should fail gracefully) const costModel = handler.getModel() expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0") }) - it("should handle errors during invokedModelId processing", async () => { - // Create a handler with default settings - const mockOptions: ApiHandlerOptions = { - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", + it("should use the invoked model's pricing for totalCost calculation", async () => { + const handler = new AwsBedrockHandler({ awsAccessKey: "test-access-key", awsSecretKey: "test-secret-key", awsRegion: "us-east-1", - } - - const handler = new AwsBedrockHandler(mockOptions) - - // Mock the stream with a valid invokedModelId - mockSend.mockImplementationOnce(async () => { - return { - stream: createMockStream([ - // Event with valid invokedModelId - { - trace: { - promptRouter: { - invokedModelId: - "arn:aws:bedrock:us-east-1:123456789:foundation-model/anthropic.claude-3-sonnet-20240229-v1:0", - }, - }, - }, - ]), - } - }) - - // Mock getModel to throw an error when called with the model name - vitest.spyOn(handler, "getModel").mockImplementation((modelName?: string) => { - if (modelName === "anthropic.claude-3-sonnet-20240229-v1:0") { - throw new Error("Test error during model lookup") - } - - // Default return value for initial call - return { - id: "anthropic.claude-3-5-sonnet-20241022-v2:0", - info: { - maxTokens: 4096, - contextWindow: 128_000, - supportsPromptCache: false, - supportsImages: true, - }, - } + awsCustomArn: "arn:aws:bedrock:us-west-2:123456789:default-prompt-router/anthropic.claude:1", }) - // Create a message generator - const messageGenerator = handler.createMessage("system prompt", [{ role: "user", content: "user message" }]) + // Set up stream to include Opus as the invoked model + setupMockStreamWithInvokedModelId( + "arn:aws:bedrock:us-west-2:699475926481:foundation-model/anthropic.claude-3-opus-20240229-v1:0", + ) - // Consume the generator - for await (const _ of messageGenerator) { - // Just consume the messages + const events = [] + for await (const event of handler.createMessage("system prompt", [{ role: "user", content: "user message" }])) { + events.push(event) } - // Verify that getModel returns the original model info - const costModel = handler.getModel() - expect(costModel.id).toBe("anthropic.claude-3-5-sonnet-20241022-v2:0") + const usageEvent = events.find((e: any) => e.type === "usage") as any + expect(usageEvent).toBeDefined() + + // Calculate expected cost based on Opus pricing ($15 / 1M input, $75 / 1M output) + const opusInfo = bedrockModels["anthropic.claude-3-opus-20240229-v1:0"] + const expectedCost = + (100 * (opusInfo.inputPrice ?? 0)) / 1_000_000 + (200 * (opusInfo.outputPrice ?? 0)) / 1_000_000 + + expect(usageEvent.totalCost).toBeCloseTo(expectedCost, 10) }) }) diff --git a/src/api/providers/__tests__/bedrock-native-tools.spec.ts b/src/api/providers/__tests__/bedrock-native-tools.spec.ts index e95b2c34b69..74439f00d5b 100644 --- a/src/api/providers/__tests__/bedrock-native-tools.spec.ts +++ b/src/api/providers/__tests__/bedrock-native-tools.spec.ts @@ -1,3 +1,12 @@ +// Mock TelemetryService before other imports +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + instance: { + captureException: vi.fn(), + }, + }, +})) + // Mock AWS SDK credential providers vi.mock("@aws-sdk/credential-providers", () => { const mockFromIni = vi.fn().mockReturnValue({ @@ -7,29 +16,28 @@ vi.mock("@aws-sdk/credential-providers", () => { return { fromIni: mockFromIni } }) -// Mock BedrockRuntimeClient and ConverseStreamCommand -const mockSend = vi.fn() +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) -vi.mock("@aws-sdk/client-bedrock-runtime", () => { +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - BedrockRuntimeClient: vi.fn().mockImplementation(() => ({ - send: mockSend, - config: { region: "us-east-1" }, - })), - ConverseStreamCommand: vi.fn((params) => ({ - ...params, - input: params, - })), - ConverseCommand: vi.fn(), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), +})) + import { AwsBedrockHandler } from "../bedrock" -import { ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime" import type { ApiHandlerCreateMessageMetadata } from "../../index" -const mockConverseStreamCommand = vi.mocked(ConverseStreamCommand) - // Test tool definitions in OpenAI format const testTools = [ { @@ -63,542 +71,529 @@ const testTools = [ }, ] -describe("AwsBedrockHandler Native Tool Calling", () => { +/** + * Helper: set up mockStreamText to return a simple text-delta stream. + */ +function setupMockStreamText() { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response text" } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), + }) +} + +/** + * Helper: set up mockStreamText to return a stream with tool-call events. + */ +function setupMockStreamTextWithToolCall() { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-123", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-123", + delta: '{"path": "/test.txt"}', + } + yield { + type: "tool-input-end", + id: "tool-123", + } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), + }) +} + +describe("AwsBedrockHandler Native Tool Calling (AI SDK)", () => { let handler: AwsBedrockHandler beforeEach(() => { vi.clearAllMocks() - // Create handler with a model that supports native tools handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", awsSecretKey: "test-secret-key", awsRegion: "us-east-1", }) - - // Mock the stream response - mockSend.mockResolvedValue({ - stream: [], - }) }) - describe("convertToolsForBedrock", () => { - it("should convert OpenAI tools to Bedrock format", () => { - // Access private method - const convertToolsForBedrock = (handler as any).convertToolsForBedrock.bind(handler) - - const bedrockTools = convertToolsForBedrock(testTools) - - expect(bedrockTools).toHaveLength(2) - - // Check structure and key properties (normalizeToolSchema adds additionalProperties: false) - const tool = bedrockTools[0] - expect(tool.toolSpec.name).toBe("read_file") - expect(tool.toolSpec.description).toBe("Read a file from the filesystem") - expect(tool.toolSpec.inputSchema.json.type).toBe("object") - expect(tool.toolSpec.inputSchema.json.properties.path.type).toBe("string") - expect(tool.toolSpec.inputSchema.json.properties.path.description).toBe("The path to the file") - expect(tool.toolSpec.inputSchema.json.required).toEqual(["path"]) - // normalizeToolSchema adds additionalProperties: false by default - expect(tool.toolSpec.inputSchema.json.additionalProperties).toBe(false) + describe("tools passed to streamText", () => { + it("should pass converted tools to streamText when tools are provided", async () => { + setupMockStreamText() + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + } + + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read the file at /test.txt" }], + metadata, + ) + + // Drain the generator + for await (const _chunk of generator) { + /* consume */ + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // tools should be defined and contain AI SDK tool objects keyed by name + expect(callArgs.tools).toBeDefined() + expect(callArgs.tools.read_file).toBeDefined() + expect(callArgs.tools.write_file).toBeDefined() }) - it("should transform type arrays to anyOf for JSON Schema 2020-12 compliance", () => { - const convertToolsForBedrock = (handler as any).convertToolsForBedrock.bind(handler) - - // Tools with type: ["string", "null"] syntax (valid in draft-07 but not 2020-12) - const toolsWithNullableTypes = [ - { - type: "function" as const, - function: { - name: "execute_command", - description: "Execute a command", - parameters: { - type: "object", - properties: { - command: { type: "string", description: "The command to execute" }, - cwd: { - type: ["string", "null"], - description: "Working directory (optional)", - }, - }, - required: ["command", "cwd"], - }, - }, - }, - { - type: "function" as const, - function: { - name: "read_file", - description: "Read files", - parameters: { - type: "object", - properties: { - path: { type: "string" }, - indentation: { - type: ["object", "null"], - properties: { - anchor_line: { - type: ["integer", "null"], - description: "Optional anchor line", - }, - }, - }, - }, - required: ["path"], - }, - }, - }, - ] + it("should pass undefined tools when no tools are provided in metadata", async () => { + setupMockStreamText() + + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + // No tools + } - const bedrockTools = convertToolsForBedrock(toolsWithNullableTypes) + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Hello" }], + metadata, + ) - expect(bedrockTools).toHaveLength(2) + for await (const _chunk of generator) { + /* consume */ + } - // First tool: cwd should be transformed from type: ["string", "null"] to anyOf - const executeCommandSchema = bedrockTools[0].toolSpec.inputSchema.json as any - expect(executeCommandSchema.properties.cwd.anyOf).toEqual([{ type: "string" }, { type: "null" }]) - expect(executeCommandSchema.properties.cwd.type).toBeUndefined() - expect(executeCommandSchema.properties.cwd.description).toBe("Working directory (optional)") + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Second tool: nested nullable object should be transformed from type: ["object", "null"] to anyOf - const readFileSchema = bedrockTools[1].toolSpec.inputSchema.json as any - const indentation = readFileSchema.properties.indentation - expect(indentation.anyOf).toBeDefined() - expect(indentation.type).toBeUndefined() - // Object-level schema properties are preserved at the root, not inside the anyOf object variant - expect(indentation.additionalProperties).toBe(false) - expect(indentation.properties.anchor_line.anyOf).toEqual([{ type: "integer" }, { type: "null" }]) + // When no tools are provided, tools should be undefined + expect(callArgs.tools).toBeUndefined() }) - it("should filter non-function tools", () => { - const convertToolsForBedrock = (handler as any).convertToolsForBedrock.bind(handler) + it("should filter non-function tools before passing to streamText", async () => { + setupMockStreamText() - const mixedTools = [ + const mixedTools: any[] = [ ...testTools, - { type: "other" as any, something: {} }, // Should be filtered out + { type: "other", something: {} }, // Should be filtered out ] - const bedrockTools = convertToolsForBedrock(mixedTools) + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: mixedTools as any, + } - expect(bedrockTools).toHaveLength(2) - }) - }) + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read a file" }], + metadata, + ) - describe("convertToolChoiceForBedrock", () => { - it("should convert 'auto' to Bedrock auto format", () => { - const convertToolChoiceForBedrock = (handler as any).convertToolChoiceForBedrock.bind(handler) + for await (const _chunk of generator) { + /* consume */ + } - const result = convertToolChoiceForBedrock("auto") + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - expect(result).toEqual({ auto: {} }) + // Only function tools should be present (keyed by name) + expect(callArgs.tools).toBeDefined() + expect(Object.keys(callArgs.tools)).toHaveLength(2) + expect(callArgs.tools.read_file).toBeDefined() + expect(callArgs.tools.write_file).toBeDefined() }) + }) - it("should convert 'required' to Bedrock any format", () => { - const convertToolChoiceForBedrock = (handler as any).convertToolChoiceForBedrock.bind(handler) + describe("toolChoice passed to streamText", () => { + it("should default toolChoice to undefined when tool_choice is not specified", async () => { + setupMockStreamText() - const result = convertToolChoiceForBedrock("required") + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + // No tool_choice + } - expect(result).toEqual({ any: {} }) - }) + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read the file" }], + metadata, + ) - it("should return undefined for 'none'", () => { - const convertToolChoiceForBedrock = (handler as any).convertToolChoiceForBedrock.bind(handler) + for await (const _chunk of generator) { + /* consume */ + } - const result = convertToolChoiceForBedrock("none") + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - expect(result).toBeUndefined() + // mapToolChoice(undefined) returns undefined + expect(callArgs.toolChoice).toBeUndefined() }) - it("should convert specific tool choice to Bedrock tool format", () => { - const convertToolChoiceForBedrock = (handler as any).convertToolChoiceForBedrock.bind(handler) + it("should pass 'auto' toolChoice when tool_choice is 'auto'", async () => { + setupMockStreamText() - const result = convertToolChoiceForBedrock({ - type: "function", - function: { name: "read_file" }, - }) + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + tool_choice: "auto", + } - expect(result).toEqual({ - tool: { - name: "read_file", - }, - }) - }) + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read the file" }], + metadata, + ) - it("should default to auto for undefined toolChoice", () => { - const convertToolChoiceForBedrock = (handler as any).convertToolChoiceForBedrock.bind(handler) + for await (const _chunk of generator) { + /* consume */ + } - const result = convertToolChoiceForBedrock(undefined) + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - expect(result).toEqual({ auto: {} }) + expect(callArgs.toolChoice).toBe("auto") }) - }) - describe("createMessage with native tools", () => { - it("should include toolConfig when tools are provided", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) + it("should pass 'none' toolChoice when tool_choice is 'none'", async () => { + setupMockStreamText() const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task", tools: testTools, + tool_choice: "none", } - const generator = handlerWithNativeTools.createMessage( + const generator = handler.createMessage( "You are a helpful assistant.", - [{ role: "user", content: "Read the file at /test.txt" }], + [{ role: "user", content: "Read the file" }], metadata, ) - await generator.next() + for await (const _chunk of generator) { + /* consume */ + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - expect(commandArg.toolConfig).toBeDefined() - expect(commandArg.toolConfig.tools).toHaveLength(2) - expect(commandArg.toolConfig.tools[0].toolSpec.name).toBe("read_file") - expect(commandArg.toolConfig.toolChoice).toEqual({ auto: {} }) + expect(callArgs.toolChoice).toBe("none") }) - it("should always include toolConfig (tools are always present after PR #10841)", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) + it("should pass 'required' toolChoice when tool_choice is 'required'", async () => { + setupMockStreamText() const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task", - // Even without explicit tools, tools are always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) + tools: testTools, + tool_choice: "required", } - const generator = handlerWithNativeTools.createMessage( + const generator = handler.createMessage( "You are a helpful assistant.", - [{ role: "user", content: "Read the file at /test.txt" }], + [{ role: "user", content: "Read the file" }], metadata, ) - await generator.next() + for await (const _chunk of generator) { + /* consume */ + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Tools are now always present - expect(commandArg.toolConfig).toBeDefined() - expect(commandArg.toolConfig.tools).toBeDefined() - expect(commandArg.toolConfig.toolChoice).toEqual({ auto: {} }) + expect(callArgs.toolChoice).toBe("required") }) - it("should include toolConfig with undefined toolChoice when tool_choice is none", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) + it("should pass specific tool choice when tool_choice names a function", async () => { + setupMockStreamText() const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task", tools: testTools, - tool_choice: "none", // Explicitly disable tool use + tool_choice: { + type: "function", + function: { name: "read_file" }, + }, } - const generator = handlerWithNativeTools.createMessage( + const generator = handler.createMessage( "You are a helpful assistant.", - [{ role: "user", content: "Read the file at /test.txt" }], + [{ role: "user", content: "Read the file" }], metadata, ) - await generator.next() + for await (const _chunk of generator) { + /* consume */ + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // toolConfig is still provided but toolChoice is undefined for "none" - expect(commandArg.toolConfig).toBeDefined() - expect(commandArg.toolConfig.toolChoice).toBeUndefined() + expect(callArgs.toolChoice).toEqual({ + type: "tool", + toolName: "read_file", + }) }) + }) - it("should include fine-grained tool streaming beta for Claude models with native tools", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) + describe("tool call streaming events", () => { + it("should yield tool_call_start, tool_call_delta, and tool_call_end for tool input stream", async () => { + setupMockStreamTextWithToolCall() const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task", tools: testTools, } - const generator = handlerWithNativeTools.createMessage( + const generator = handler.createMessage( "You are a helpful assistant.", - [{ role: "user", content: "Read the file at /test.txt" }], + [{ role: "user", content: "Read the file" }], metadata, ) - await generator.next() + const results: any[] = [] + for await (const chunk of generator) { + results.push(chunk) + } + + // Should have tool_call_start chunk + const startChunks = results.filter((r) => r.type === "tool_call_start") + expect(startChunks).toHaveLength(1) + expect(startChunks[0]).toEqual({ + type: "tool_call_start", + id: "tool-123", + name: "read_file", + }) - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + // Should have tool_call_delta chunk + const deltaChunks = results.filter((r) => r.type === "tool_call_delta") + expect(deltaChunks).toHaveLength(1) + expect(deltaChunks[0]).toEqual({ + type: "tool_call_delta", + id: "tool-123", + delta: '{"path": "/test.txt"}', + }) - // Should include the fine-grained tool streaming beta - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) + // Should have tool_call_end chunk + const endChunks = results.filter((r) => r.type === "tool_call_end") + expect(endChunks).toHaveLength(1) + expect(endChunks[0]).toEqual({ + type: "tool_call_end", + id: "tool-123", + }) }) - it("should always include fine-grained tool streaming beta for Claude models", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", + it("should handle mixed text and tool use content in stream", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Let me read that file for you." } + yield { type: "text-delta", text: " Here's what I found:" } + yield { + type: "tool-input-start", + id: "tool-789", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-789", + delta: '{"path": "/example.txt"}', + } + yield { + type: "tool-input-end", + id: "tool-789", + } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 150, outputTokens: 75 }), + providerMetadata: Promise.resolve({}), }) const metadata: ApiHandlerCreateMessageMetadata = { taskId: "test-task", - // No tools provided + tools: testTools, } - const generator = handlerWithNativeTools.createMessage( + const generator = handler.createMessage( "You are a helpful assistant.", - [{ role: "user", content: "Hello" }], + [{ role: "user", content: "Read the example file" }], metadata, ) - await generator.next() + const results: any[] = [] + for await (const chunk of generator) { + results.push(chunk) + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + // Should have text chunks + const textChunks = results.filter((r) => r.type === "text") + expect(textChunks).toHaveLength(2) + expect(textChunks[0].text).toBe("Let me read that file for you.") + expect(textChunks[1].text).toBe(" Here's what I found:") - // Should always include anthropic_beta with fine-grained-tool-streaming for Claude models - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) + // Should have tool call start + const startChunks = results.filter((r) => r.type === "tool_call_start") + expect(startChunks).toHaveLength(1) + expect(startChunks[0].name).toBe("read_file") + + // Should have tool call delta + const deltaChunks = results.filter((r) => r.type === "tool_call_delta") + expect(deltaChunks).toHaveLength(1) + expect(deltaChunks[0].delta).toBe('{"path": "/example.txt"}') + + // Should have tool call end + const endChunks = results.filter((r) => r.type === "tool_call_end") + expect(endChunks).toHaveLength(1) }) - }) - describe("tool call streaming events", () => { - it("should yield tool_call_partial for toolUse block start", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", + it("should handle multiple tool calls in a single stream", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-1", + delta: '{"path": "/file1.txt"}', + } + yield { + type: "tool-input-end", + id: "tool-1", + } + yield { + type: "tool-input-start", + id: "tool-2", + toolName: "write_file", + } + yield { + type: "tool-input-delta", + id: "tool-2", + delta: '{"path": "/file2.txt", "content": "hello"}', + } + yield { + type: "tool-input-end", + id: "tool-2", + } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 200, outputTokens: 100 }), + providerMetadata: Promise.resolve({}), }) - // Mock stream with tool use events - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { - contentBlockStart: { - contentBlockIndex: 0, - start: { - toolUse: { - toolUseId: "tool-123", - name: "read_file", - }, - }, - }, - } - yield { - contentBlockDelta: { - contentBlockIndex: 0, - delta: { - toolUse: { - input: '{"path": "/test.txt"}', - }, - }, - }, - } - yield { - metadata: { - usage: { - inputTokens: 100, - outputTokens: 50, - }, - }, - } - })(), - }) + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + } - const generator = handlerWithNativeTools.createMessage("You are a helpful assistant.", [ - { role: "user", content: "Read the file" }, - ]) + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read and write files" }], + metadata, + ) const results: any[] = [] for await (const chunk of generator) { results.push(chunk) } - // Should have tool_call_partial chunks - const toolCallChunks = results.filter((r) => r.type === "tool_call_partial") - expect(toolCallChunks).toHaveLength(2) + // Should have two tool_call_start chunks + const startChunks = results.filter((r) => r.type === "tool_call_start") + expect(startChunks).toHaveLength(2) + expect(startChunks[0].name).toBe("read_file") + expect(startChunks[1].name).toBe("write_file") - // First chunk should have id and name - expect(toolCallChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "tool-123", - name: "read_file", - arguments: undefined, - }) + // Should have two tool_call_delta chunks + const deltaChunks = results.filter((r) => r.type === "tool_call_delta") + expect(deltaChunks).toHaveLength(2) - // Second chunk should have arguments - expect(toolCallChunks[1]).toEqual({ - type: "tool_call_partial", - index: 0, - id: undefined, - name: undefined, - arguments: '{"path": "/test.txt"}', - }) + // Should have two tool_call_end chunks + const endChunks = results.filter((r) => r.type === "tool_call_end") + expect(endChunks).toHaveLength(2) }) + }) - it("should yield tool_call_partial for contentBlock toolUse structure", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) + describe("tools schema normalization", () => { + it("should apply schema normalization (additionalProperties: false, strict: true) via convertToolsForOpenAI", async () => { + setupMockStreamText() - // Mock stream with alternative tool use structure - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { - contentBlockStart: { - contentBlockIndex: 0, - contentBlock: { - toolUse: { - toolUseId: "tool-456", - name: "write_file", + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: [ + { + type: "function" as const, + function: { + name: "test_tool", + description: "A test tool", + parameters: { + type: "object", + properties: { + arg1: { type: "string" }, }, + // Note: no "required" field and no "additionalProperties" }, }, - } - yield { - metadata: { - usage: { - inputTokens: 100, - outputTokens: 50, - }, - }, - } - })(), - }) + }, + ], + } - const generator = handlerWithNativeTools.createMessage("You are a helpful assistant.", [ - { role: "user", content: "Write a file" }, - ]) + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "test" }], + metadata, + ) - const results: any[] = [] - for await (const chunk of generator) { - results.push(chunk) + for await (const _chunk of generator) { + /* consume */ } - // Should have tool_call_partial chunk - const toolCallChunks = results.filter((r) => r.type === "tool_call_partial") - expect(toolCallChunks).toHaveLength(1) + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - expect(toolCallChunks[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "tool-456", - name: "write_file", - arguments: undefined, - }) + // The AI SDK tools should be keyed by tool name + expect(callArgs.tools).toBeDefined() + expect(callArgs.tools.test_tool).toBeDefined() }) + }) - it("should handle mixed text and tool use content", async () => { - const handlerWithNativeTools = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - }) + describe("usage metrics with tools", () => { + it("should yield usage chunk after tool call stream completes", async () => { + setupMockStreamTextWithToolCall() - // Mock stream with mixed content - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { - contentBlockStart: { - contentBlockIndex: 0, - start: { - text: "Let me read that file for you.", - }, - }, - } - yield { - contentBlockDelta: { - contentBlockIndex: 0, - delta: { - text: " Here's what I found:", - }, - }, - } - yield { - contentBlockStart: { - contentBlockIndex: 1, - start: { - toolUse: { - toolUseId: "tool-789", - name: "read_file", - }, - }, - }, - } - yield { - contentBlockDelta: { - contentBlockIndex: 1, - delta: { - toolUse: { - input: '{"path": "/example.txt"}', - }, - }, - }, - } - yield { - metadata: { - usage: { - inputTokens: 150, - outputTokens: 75, - }, - }, - } - })(), - }) + const metadata: ApiHandlerCreateMessageMetadata = { + taskId: "test-task", + tools: testTools, + } - const generator = handlerWithNativeTools.createMessage("You are a helpful assistant.", [ - { role: "user", content: "Read the example file" }, - ]) + const generator = handler.createMessage( + "You are a helpful assistant.", + [{ role: "user", content: "Read a file" }], + metadata, + ) const results: any[] = [] for await (const chunk of generator) { results.push(chunk) } - // Should have text chunks - const textChunks = results.filter((r) => r.type === "text") - expect(textChunks).toHaveLength(2) - expect(textChunks[0].text).toBe("Let me read that file for you.") - expect(textChunks[1].text).toBe(" Here's what I found:") - - // Should have tool call chunks - const toolCallChunks = results.filter((r) => r.type === "tool_call_partial") - expect(toolCallChunks).toHaveLength(2) - expect(toolCallChunks[0].name).toBe("read_file") - expect(toolCallChunks[1].arguments).toBe('{"path": "/example.txt"}') + // Should have a usage chunk at the end + const usageChunks = results.filter((r) => r.type === "usage") + expect(usageChunks).toHaveLength(1) + expect(usageChunks[0].inputTokens).toBe(100) + expect(usageChunks[0].outputTokens).toBe(50) }) }) }) diff --git a/src/api/providers/__tests__/bedrock-reasoning.spec.ts b/src/api/providers/__tests__/bedrock-reasoning.spec.ts index 9dd271744c2..dfe35d4d8e2 100644 --- a/src/api/providers/__tests__/bedrock-reasoning.spec.ts +++ b/src/api/providers/__tests__/bedrock-reasoning.spec.ts @@ -1,37 +1,48 @@ -// npx vitest api/providers/__tests__/bedrock-reasoning.test.ts +// npx vitest run api/providers/__tests__/bedrock-reasoning.spec.ts + +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText, mockCreateAmazonBedrock } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), + mockCreateAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: mockCreateAmazonBedrock, +})) + +// Mock AWS SDK credential providers +vi.mock("@aws-sdk/credential-providers", () => ({ + fromIni: vi.fn().mockReturnValue(async () => ({ + accessKeyId: "profile-access-key", + secretAccessKey: "profile-secret-key", + })), +})) + +vi.mock("../../../utils/logging", () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + }, +})) import { AwsBedrockHandler } from "../bedrock" -import { BedrockRuntimeClient, ConverseStreamCommand } from "@aws-sdk/client-bedrock-runtime" import { logger } from "../../../utils/logging" -// Mock the AWS SDK -vi.mock("@aws-sdk/client-bedrock-runtime") -vi.mock("../../../utils/logging") - -// Store the command payload for verification -let capturedPayload: any = null - describe("AwsBedrockHandler - Extended Thinking", () => { - let handler: AwsBedrockHandler - let mockSend: ReturnType - beforeEach(() => { - capturedPayload = null - mockSend = vi.fn() - - // Mock ConverseStreamCommand to capture the payload - ;(ConverseStreamCommand as unknown as ReturnType).mockImplementation((payload) => { - capturedPayload = payload - return { - input: payload, - } - }) - ;(BedrockRuntimeClient as unknown as ReturnType).mockImplementation(() => ({ - send: mockSend, - config: { region: "us-east-1" }, - })) - ;(logger.info as unknown as ReturnType).mockImplementation(() => {}) - ;(logger.error as unknown as ReturnType).mockImplementation(() => {}) + vi.clearAllMocks() }) afterEach(() => { @@ -39,8 +50,8 @@ describe("AwsBedrockHandler - Extended Thinking", () => { }) describe("Extended Thinking Support", () => { - it("should include thinking parameter for Claude Sonnet 4 when reasoning is enabled", async () => { - handler = new AwsBedrockHandler({ + it("should include reasoningConfig in providerOptions when reasoning is enabled", async () => { + const handler = new AwsBedrockHandler({ apiProvider: "bedrock", apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", awsRegion: "us-east-1", @@ -49,35 +60,17 @@ describe("AwsBedrockHandler - Extended Thinking", () => { modelMaxThinkingTokens: 4096, }) - // Mock the stream response - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { - messageStart: { role: "assistant" }, - } - yield { - contentBlockStart: { - content_block: { type: "thinking", thinking: "Let me think..." }, - contentBlockIndex: 0, - }, - } - yield { - contentBlockDelta: { - delta: { type: "thinking_delta", thinking: " about this problem." }, - }, - } - yield { - contentBlockStart: { - start: { text: "Here's the answer:" }, - contentBlockIndex: 1, - }, - } - yield { - metadata: { - usage: { inputTokens: 100, outputTokens: 50 }, - }, - } - })(), + // Mock stream with reasoning content + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think..." } + yield { type: "reasoning", text: " about this problem." } + yield { type: "text-delta", text: "Here's the answer:" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), }) const messages = [{ role: "user" as const, content: "Test message" }] @@ -88,13 +81,14 @@ describe("AwsBedrockHandler - Extended Thinking", () => { chunks.push(chunk) } - // Verify the command was called with the correct payload - expect(mockSend).toHaveBeenCalledTimes(1) - expect(capturedPayload).toBeDefined() - expect(capturedPayload.additionalModelRequestFields).toBeDefined() - expect(capturedPayload.additionalModelRequestFields.thinking).toEqual({ + // Verify streamText was called with providerOptions containing reasoningConfig + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeDefined() + expect(callArgs.providerOptions.bedrock).toBeDefined() + expect(callArgs.providerOptions.bedrock.reasoningConfig).toEqual({ type: "enabled", - budget_tokens: 4096, // Uses the full modelMaxThinkingTokens value + budgetTokens: 4096, }) // Verify reasoning chunks were yielded @@ -102,157 +96,145 @@ describe("AwsBedrockHandler - Extended Thinking", () => { expect(reasoningChunks).toHaveLength(2) expect(reasoningChunks[0].text).toBe("Let me think...") expect(reasoningChunks[1].text).toBe(" about this problem.") - - // Verify that topP is NOT present when thinking is enabled - expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP") }) - it("should pass thinking parameters from metadata", async () => { - handler = new AwsBedrockHandler({ + it("should not include reasoningConfig when reasoning is disabled", async () => { + const handler = new AwsBedrockHandler({ apiProvider: "bedrock", apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", awsRegion: "us-east-1", + // Note: no enableReasoningEffort = true, so thinking is disabled }) - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { messageStart: { role: "assistant" } } - yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } } - })(), + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), }) const messages = [{ role: "user" as const, content: "Test message" }] - const metadata = { - taskId: "test-task", - thinking: { - enabled: true, - maxTokens: 16384, - maxThinkingTokens: 8192, - }, - } + const stream = handler.createMessage("System prompt", messages) - const stream = handler.createMessage("System prompt", messages, metadata) const chunks = [] for await (const chunk of stream) { chunks.push(chunk) } - // Verify the thinking parameter was passed correctly - expect(mockSend).toHaveBeenCalledTimes(1) - expect(capturedPayload).toBeDefined() - expect(capturedPayload.additionalModelRequestFields).toBeDefined() - expect(capturedPayload.additionalModelRequestFields.thinking).toEqual({ - type: "enabled", - budget_tokens: 8192, - }) - - // Verify that topP is NOT present when thinking is enabled via metadata - expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP") + // Verify streamText was called — providerOptions should not contain reasoningConfig + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + const bedrockOpts = callArgs.providerOptions?.bedrock + expect(bedrockOpts?.reasoningConfig).toBeUndefined() }) - it("should log when extended thinking is enabled", async () => { - handler = new AwsBedrockHandler({ + it("should capture thinking signature from stream providerMetadata", async () => { + const handler = new AwsBedrockHandler({ apiProvider: "bedrock", - apiModelId: "anthropic.claude-opus-4-20250514-v1:0", + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", awsRegion: "us-east-1", enableReasoningEffort: true, - modelMaxThinkingTokens: 5000, + modelMaxThinkingTokens: 4096, }) - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { messageStart: { role: "assistant" } } - })(), + const testSignature = "test-thinking-signature-abc123" + + // Mock stream with reasoning content that includes a signature in providerMetadata + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think..." } + // The SDK emits signature as a reasoning-delta with providerMetadata.bedrock.signature + yield { + type: "reasoning", + text: "", + providerMetadata: { bedrock: { signature: testSignature } }, + } + yield { type: "text-delta", text: "Answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), }) - const messages = [{ role: "user" as const, content: "Test" }] + const messages = [{ role: "user" as const, content: "Test message" }] const stream = handler.createMessage("System prompt", messages) - for await (const chunk of stream) { + for await (const _chunk of stream) { // consume stream } - // Verify logging - expect(logger.info).toHaveBeenCalledWith( - expect.stringContaining("Extended thinking enabled"), - expect.objectContaining({ - ctx: "bedrock", - modelId: "anthropic.claude-opus-4-20250514-v1:0", - }), - ) + // Verify thinking signature was captured + expect(handler.getThoughtSignature()).toBe(testSignature) }) - it("should not include topP when thinking is disabled (global removal)", async () => { - handler = new AwsBedrockHandler({ + it("should capture redacted thinking blocks from stream providerMetadata", async () => { + const handler = new AwsBedrockHandler({ apiProvider: "bedrock", - apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", + apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", awsRegion: "us-east-1", - // Note: no enableReasoningEffort = true, so thinking is disabled + enableReasoningEffort: true, + modelMaxThinkingTokens: 4096, }) - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { messageStart: { role: "assistant" } } - yield { - contentBlockStart: { - start: { text: "Hello" }, - contentBlockIndex: 0, - }, - } - yield { - contentBlockDelta: { - delta: { text: " world" }, - }, - } - yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } } - })(), + const redactedData = "base64-encoded-redacted-data" + + // Mock stream with redacted reasoning content + async function* mockFullStream() { + yield { type: "reasoning", text: "Some thinking..." } + yield { + type: "reasoning", + text: "", + providerMetadata: { bedrock: { redactedData } }, + } + yield { type: "text-delta", text: "Answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), }) const messages = [{ role: "user" as const, content: "Test message" }] const stream = handler.createMessage("System prompt", messages) - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) + for await (const _chunk of stream) { + // consume stream } - // Verify that topP is NOT present for any model (removed globally) - expect(mockSend).toHaveBeenCalledTimes(1) - expect(capturedPayload).toBeDefined() - expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP") - - // Verify that additionalModelRequestFields contains fine-grained-tool-streaming for Claude models - expect(capturedPayload.additionalModelRequestFields).toBeDefined() - expect(capturedPayload.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) + // Verify redacted thinking blocks were captured + const redactedBlocks = handler.getRedactedThinkingBlocks() + expect(redactedBlocks).toBeDefined() + expect(redactedBlocks).toHaveLength(1) + expect(redactedBlocks![0]).toEqual({ + type: "redacted_thinking", + data: redactedData, + }) }) it("should enable reasoning when enableReasoningEffort is true in settings", async () => { - handler = new AwsBedrockHandler({ + const handler = new AwsBedrockHandler({ apiProvider: "bedrock", apiModelId: "anthropic.claude-sonnet-4-20250514-v1:0", awsRegion: "us-east-1", - enableReasoningEffort: true, // This should trigger reasoning + enableReasoningEffort: true, modelMaxThinkingTokens: 4096, }) - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { messageStart: { role: "assistant" } } - yield { - contentBlockStart: { - content_block: { type: "thinking", thinking: "Let me think..." }, - contentBlockIndex: 0, - }, - } - yield { - contentBlockDelta: { - delta: { type: "thinking_delta", thinking: " about this problem." }, - }, - } - yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } } - })(), + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think..." } + yield { type: "reasoning", text: " about this problem." } + yield { type: "text-delta", text: "Test response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 100, outputTokens: 50 }), + providerMetadata: Promise.resolve({}), }) const messages = [{ role: "user" as const, content: "Test message" }] @@ -264,17 +246,13 @@ describe("AwsBedrockHandler - Extended Thinking", () => { } // Verify thinking was enabled via settings - expect(mockSend).toHaveBeenCalledTimes(1) - expect(capturedPayload).toBeDefined() - expect(capturedPayload.additionalModelRequestFields).toBeDefined() - expect(capturedPayload.additionalModelRequestFields.thinking).toEqual({ + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions?.bedrock?.reasoningConfig).toEqual({ type: "enabled", - budget_tokens: 4096, + budgetTokens: 4096, }) - // Verify that topP is NOT present when thinking is enabled via settings - expect(capturedPayload.inferenceConfig).not.toHaveProperty("topP") - // Verify reasoning chunks were yielded const reasoningChunks = chunks.filter((c) => c.type === "reasoning") expect(reasoningChunks).toHaveLength(2) @@ -282,8 +260,8 @@ describe("AwsBedrockHandler - Extended Thinking", () => { expect(reasoningChunks[1].text).toBe(" about this problem.") }) - it("should support API key authentication", async () => { - handler = new AwsBedrockHandler({ + it("should support API key authentication via createAmazonBedrock", () => { + new AwsBedrockHandler({ apiProvider: "bedrock", apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsRegion: "us-east-1", @@ -291,41 +269,13 @@ describe("AwsBedrockHandler - Extended Thinking", () => { awsApiKey: "test-api-key-token", }) - mockSend.mockResolvedValue({ - stream: (async function* () { - yield { messageStart: { role: "assistant" } } - yield { - contentBlockStart: { - start: { text: "Hello from API key auth" }, - contentBlockIndex: 0, - }, - } - yield { metadata: { usage: { inputTokens: 100, outputTokens: 50 } } } - })(), - }) - - const messages = [{ role: "user" as const, content: "Test message" }] - const stream = handler.createMessage("System prompt", messages) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify the client was created with API key token - expect(BedrockRuntimeClient).toHaveBeenCalledWith( + // Verify the provider was created with API key + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", - token: { token: "test-api-key-token" }, - authSchemePreference: ["httpBearerAuth"], + apiKey: "test-api-key-token", }), ) - - // Verify the stream worked correctly - expect(mockSend).toHaveBeenCalledTimes(1) - const textChunks = chunks.filter((c) => c.type === "text") - expect(textChunks).toHaveLength(1) - expect(textChunks[0].text).toBe("Hello from API key auth") }) }) }) diff --git a/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts b/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts index 7823775beaa..19bb68bb775 100644 --- a/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts +++ b/src/api/providers/__tests__/bedrock-vpc-endpoint.spec.ts @@ -7,38 +7,40 @@ vi.mock("@aws-sdk/credential-providers", () => { return { fromIni: mockFromIni } }) -// Mock BedrockRuntimeClient and ConverseStreamCommand -vi.mock("@aws-sdk/client-bedrock-runtime", () => { - const mockSend = vi.fn().mockResolvedValue({ - stream: [], - }) - const mockBedrockRuntimeClient = vi.fn().mockImplementation(() => ({ - send: mockSend, - })) - +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - BedrockRuntimeClient: mockBedrockRuntimeClient, - ConverseStreamCommand: vi.fn(), - ConverseCommand: vi.fn(), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) -import { AwsBedrockHandler } from "../bedrock" -import { BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime" +// Mock createAmazonBedrock so we can inspect how it was called +const { mockCreateAmazonBedrock } = vi.hoisted(() => ({ + mockCreateAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), +})) -// Get access to the mocked functions -const mockBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient) +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: mockCreateAmazonBedrock, +})) + +import { AwsBedrockHandler } from "../bedrock" describe("Amazon Bedrock VPC Endpoint Functionality", () => { beforeEach(() => { - // Clear all mocks before each test vi.clearAllMocks() }) // Test Scenario 1: Input Validation Test describe("VPC Endpoint URL Validation", () => { - it("should configure client with endpoint URL when both URL and enabled flag are provided", () => { - // Create handler with endpoint URL and enabled flag + it("should configure provider with baseURL when both URL and enabled flag are provided", () => { new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -48,17 +50,15 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: true, }) - // Verify the client was created with the correct endpoint - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", - endpoint: "https://bedrock-vpc.example.com", + baseURL: "https://bedrock-vpc.example.com", }), ) }) - it("should not configure client with endpoint URL when URL is provided but enabled flag is false", () => { - // Create handler with endpoint URL but disabled flag + it("should not configure provider with baseURL when URL is provided but enabled flag is false", () => { new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -68,23 +68,23 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: false, }) - // Verify the client was created without the endpoint - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", }), ) - // Verify the endpoint property is not present - const clientConfig = mockBedrockRuntimeClient.mock.calls[0][0] - expect(clientConfig).not.toHaveProperty("endpoint") + const providerSettings = (mockCreateAmazonBedrock.mock.calls as unknown[][])[0][0] as Record< + string, + unknown + > + expect(providerSettings).not.toHaveProperty("baseURL") }) }) // Test Scenario 2: Edge Case Tests describe("Edge Cases", () => { it("should handle empty endpoint URL gracefully", () => { - // Create handler with empty endpoint URL but enabled flag new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -94,20 +94,21 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: true, }) - // Verify the client was created without the endpoint (since it's empty) - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", }), ) - // Verify the endpoint property is not present - const clientConfig = mockBedrockRuntimeClient.mock.calls[0][0] - expect(clientConfig).not.toHaveProperty("endpoint") + // Empty string is falsy, so baseURL should not be set + const providerSettings = (mockCreateAmazonBedrock.mock.calls as unknown[][])[0][0] as Record< + string, + unknown + > + expect(providerSettings).not.toHaveProperty("baseURL") }) it("should handle undefined endpoint URL gracefully", () => { - // Create handler with undefined endpoint URL but enabled flag new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -117,23 +118,23 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: true, }) - // Verify the client was created without the endpoint - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", }), ) - // Verify the endpoint property is not present - const clientConfig = mockBedrockRuntimeClient.mock.calls[0][0] - expect(clientConfig).not.toHaveProperty("endpoint") + const providerSettings = (mockCreateAmazonBedrock.mock.calls as unknown[][])[0][0] as Record< + string, + unknown + > + expect(providerSettings).not.toHaveProperty("baseURL") }) }) - // Test Scenario 4: Error Handling Tests + // Test Scenario 3: Error Handling Tests describe("Error Handling", () => { - it("should handle invalid endpoint URLs by passing them directly to AWS SDK", () => { - // Create handler with an invalid URL format + it("should handle invalid endpoint URLs by passing them directly to the provider", () => { new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -143,21 +144,24 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: true, }) - // Verify the client was created with the invalid endpoint - // (AWS SDK will handle the validation/errors) - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + // The invalid URL is passed directly; the provider/SDK will handle validation + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", - endpoint: "invalid-url-format", + baseURL: "invalid-url-format", }), ) }) }) - // Test Scenario 5: Persistence Tests + // Test Scenario 4: Persistence Tests describe("Persistence", () => { it("should maintain consistent behavior across multiple requests", async () => { - // Create handler with endpoint URL and enabled flag + mockGenerateText.mockResolvedValue({ + text: "test response", + usage: { promptTokens: 10, completionTokens: 5 }, + }) + const handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -167,23 +171,23 @@ describe("Amazon Bedrock VPC Endpoint Functionality", () => { awsBedrockEndpointEnabled: true, }) - // Verify the client was configured with the endpoint - expect(mockBedrockRuntimeClient).toHaveBeenCalledWith( + // Verify the provider was configured with the endpoint + expect(mockCreateAmazonBedrock).toHaveBeenCalledWith( expect.objectContaining({ region: "us-east-1", - endpoint: "https://bedrock-vpc.example.com", + baseURL: "https://bedrock-vpc.example.com", }), ) // Make a request to ensure the endpoint configuration persists try { await handler.completePrompt("Test prompt") - } catch (error) { - // Ignore errors, we're just testing the client configuration persistence + } catch { + // Ignore errors — we're just testing the provider configuration persistence } - // Verify the client instance was created and used - expect(mockBedrockRuntimeClient).toHaveBeenCalled() + // The provider factory should have been called exactly once (during construction) + expect(mockCreateAmazonBedrock).toHaveBeenCalledTimes(1) }) }) }) diff --git a/src/api/providers/__tests__/bedrock.spec.ts b/src/api/providers/__tests__/bedrock.spec.ts index 115cb9fb405..2cb09fc56db 100644 --- a/src/api/providers/__tests__/bedrock.spec.ts +++ b/src/api/providers/__tests__/bedrock.spec.ts @@ -18,24 +18,26 @@ vi.mock("@aws-sdk/credential-providers", () => { return { fromIni: mockFromIni } }) -// Mock BedrockRuntimeClient and ConverseStreamCommand -vi.mock("@aws-sdk/client-bedrock-runtime", () => { - const mockSend = vi.fn().mockResolvedValue({ - stream: [], - }) - const mockConverseStreamCommand = vi.fn() +// Use vi.hoisted to define mock functions for AI SDK +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() return { - BedrockRuntimeClient: vi.fn().mockImplementation(() => ({ - send: mockSend, - })), - ConverseStreamCommand: mockConverseStreamCommand, - ConverseCommand: vi.fn(), + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, } }) +vi.mock("@ai-sdk/amazon-bedrock", () => ({ + createAmazonBedrock: vi.fn(() => vi.fn(() => ({ modelId: "test", provider: "bedrock" }))), +})) + import { AwsBedrockHandler } from "../bedrock" -import { ConverseStreamCommand, BedrockRuntimeClient, ConverseCommand } from "@aws-sdk/client-bedrock-runtime" import { BEDROCK_1M_CONTEXT_MODEL_IDS, BEDROCK_SERVICE_TIER_MODEL_IDS, @@ -45,10 +47,6 @@ import { import type { Anthropic } from "@anthropic-ai/sdk" -// Get access to the mocked functions -const mockConverseStreamCommand = vi.mocked(ConverseStreamCommand) -const mockBedrockRuntimeClient = vi.mocked(BedrockRuntimeClient) - describe("AwsBedrockHandler", () => { let handler: AwsBedrockHandler @@ -478,12 +476,20 @@ describe("AwsBedrockHandler", () => { describe("image handling", () => { const mockImageData = Buffer.from("test-image-data").toString("base64") - beforeEach(() => { - // Reset the mocks before each test - mockConverseStreamCommand.mockReset() - }) + function setupMockStreamText() { + async function* mockFullStream() { + yield { type: "text-delta", text: "I see an image" } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + }) + } + + it("should properly pass image content through to streamText via AI SDK messages", async () => { + setupMockStreamText() - it("should properly convert image content to Bedrock format", async () => { const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", @@ -505,42 +511,39 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator - - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] - - // Verify the image was properly formatted - const imageBlock = commandArg.messages![0].content![0] - expect(imageBlock).toHaveProperty("image") - expect(imageBlock.image).toHaveProperty("format", "jpeg") - expect(imageBlock.image!.source).toHaveProperty("bytes") - expect(imageBlock.image!.source!.bytes).toBeInstanceOf(Uint8Array) - }) - - it("should reject unsupported image formats", async () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "image", - source: { - type: "base64", - data: mockImageData, - media_type: "image/tiff" as "image/jpeg", // Type assertion to bypass TS - }, - }, - ], - }, - ] - - const generator = handler.createMessage("", messages) - await expect(generator.next()).rejects.toThrow("Unsupported image format: tiff") + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Verify streamText was called + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // Verify messages were converted to AI SDK format with image parts + const aiSdkMessages = callArgs.messages + expect(aiSdkMessages).toBeDefined() + expect(aiSdkMessages.length).toBeGreaterThan(0) + + // Find the user message containing image content + const userMsg = aiSdkMessages.find((m: { role: string }) => m.role === "user") + expect(userMsg).toBeDefined() + expect(Array.isArray(userMsg.content)).toBe(true) + + // The AI SDK convertToAiSdkMessages converts images to { type: "image", image: "data:...", mimeType: "..." } + const imagePart = userMsg.content.find((p: { type: string }) => p.type === "image") + expect(imagePart).toBeDefined() + expect(imagePart.image).toContain("data:image/jpeg;base64,") + expect(imagePart.mimeType).toBe("image/jpeg") + + const textPart = userMsg.content.find((p: { type: string }) => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart.text).toBe("What's in this image?") }) it("should handle multiple images in a single message", async () => { + setupMockStreamText() + const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", @@ -574,20 +577,25 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator - - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] - - // Verify both images were properly formatted - const firstImage = commandArg.messages![0].content![0] - const secondImage = commandArg.messages![0].content![2] - - expect(firstImage).toHaveProperty("image") - expect(firstImage.image).toHaveProperty("format", "jpeg") - expect(secondImage).toHaveProperty("image") - expect(secondImage.image).toHaveProperty("format", "png") + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + // Verify streamText was called + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // Verify messages contain both images + const userMsg = callArgs.messages.find((m: { role: string }) => m.role === "user") + expect(userMsg).toBeDefined() + + const imageParts = userMsg.content.filter((p: { type: string }) => p.type === "image") + expect(imageParts).toHaveLength(2) + expect(imageParts[0].image).toContain("data:image/jpeg;base64,") + expect(imageParts[0].mimeType).toBe("image/jpeg") + expect(imageParts[1].image).toContain("data:image/png;base64,") + expect(imageParts[1].mimeType).toBe("image/png") }) }) @@ -686,6 +694,17 @@ describe("AwsBedrockHandler", () => { }) describe("1M context beta feature", () => { + function setupMockStreamText() { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + }) + } + it("should enable 1M context window when awsBedrock1MContext is true for Claude Sonnet 4", () => { const handler = new AwsBedrockHandler({ apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], @@ -731,7 +750,9 @@ describe("AwsBedrockHandler", () => { expect(model.info.contextWindow).toBe(200_000) }) - it("should include anthropic_beta parameter when 1M context is enabled", async () => { + it("should include anthropicBeta in providerOptions when 1M context is enabled", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], awsAccessKey: "test", @@ -748,23 +769,23 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator - - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any - - // Should include anthropic_beta in additionalModelRequestFields with both 1M context and fine-grained-tool-streaming - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain("context-1m-2025-08-07") - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) - // Should not include anthropic_version since thinking is not enabled - expect(commandArg.additionalModelRequestFields.anthropic_version).toBeUndefined() + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // Should include anthropicBeta in providerOptions.bedrock with 1M context + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + expect(bedrockOpts).toBeDefined() + expect(bedrockOpts!.anthropicBeta).toContain("context-1m-2025-08-07") }) - it("should not include 1M context beta when 1M context is disabled but still include fine-grained-tool-streaming", async () => { + it("should not include 1M context beta when 1M context is disabled", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], awsAccessKey: "test", @@ -781,22 +802,24 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator - - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any - - // Should include anthropic_beta with fine-grained-tool-streaming for Claude models - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) - // Should NOT include 1M context beta - expect(commandArg.additionalModelRequestFields.anthropic_beta).not.toContain("context-1m-2025-08-07") + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // Should NOT include anthropicBeta with 1M context + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + if (bedrockOpts?.anthropicBeta) { + expect(bedrockOpts.anthropicBeta).not.toContain("context-1m-2025-08-07") + } }) - it("should not include 1M context beta for non-Claude Sonnet 4 models but still include fine-grained-tool-streaming", async () => { + it("should not include 1M context beta for non-Claude Sonnet 4 models", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test", @@ -813,19 +836,19 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator - - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any - - // Should include anthropic_beta with fine-grained-tool-streaming for Claude models (even non-Sonnet 4) - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) - // Should NOT include 1M context beta for non-Sonnet 4 models - expect(commandArg.additionalModelRequestFields.anthropic_beta).not.toContain("context-1m-2025-08-07") + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // Should NOT include anthropicBeta with 1M context for non-Sonnet 4 models + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + if (bedrockOpts?.anthropicBeta) { + expect(bedrockOpts.anthropicBeta).not.toContain("context-1m-2025-08-07") + } }) it("should enable 1M context window with cross-region inference for Claude Sonnet 4", () => { @@ -846,7 +869,9 @@ describe("AwsBedrockHandler", () => { expect(model.id).toBe(`us.${BEDROCK_1M_CONTEXT_MODEL_IDS[0]}`) }) - it("should include anthropic_beta parameter with cross-region inference for Claude Sonnet 4", async () => { + it("should include anthropicBeta with cross-region inference for Claude Sonnet 4", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: BEDROCK_1M_CONTEXT_MODEL_IDS[0], awsAccessKey: "test", @@ -864,33 +889,34 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator - - // Verify the command was created with the right payload - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[ - mockConverseStreamCommand.mock.calls.length - 1 - ][0] as any - - // Should include anthropic_beta in additionalModelRequestFields with both 1M context and fine-grained-tool-streaming - expect(commandArg.additionalModelRequestFields).toBeDefined() - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain("context-1m-2025-08-07") - expect(commandArg.additionalModelRequestFields.anthropic_beta).toContain( - "fine-grained-tool-streaming-2025-05-14", - ) - // Should not include anthropic_version since thinking is not enabled - expect(commandArg.additionalModelRequestFields.anthropic_version).toBeUndefined() - // Model ID should have cross-region prefix - expect(commandArg.modelId).toBe(`us.${BEDROCK_1M_CONTEXT_MODEL_IDS[0]}`) + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // Should include anthropicBeta in providerOptions.bedrock with 1M context + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + expect(bedrockOpts).toBeDefined() + expect(bedrockOpts!.anthropicBeta).toContain("context-1m-2025-08-07") }) }) describe("service tier feature", () => { const supportedModelId = BEDROCK_SERVICE_TIER_MODEL_IDS[0] // amazon.nova-lite-v1:0 - beforeEach(() => { - mockConverseStreamCommand.mockReset() - }) + function setupMockStreamText() { + async function* mockFullStream() { + yield { type: "text-delta", text: "Response" } + } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 5 }), + providerMetadata: Promise.resolve({}), + }) + } describe("pricing multipliers in getModel()", () => { it("should apply FLEX tier pricing with 50% discount", () => { @@ -976,7 +1002,9 @@ describe("AwsBedrockHandler", () => { }) describe("service_tier parameter in API requests", () => { - it("should include service_tier as top-level parameter for supported models", async () => { + it("should include service_tier in providerOptions.bedrock.additionalModelRequestFields for supported models", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: supportedModelId, awsAccessKey: "test", @@ -993,23 +1021,27 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator - - // Verify the command was created with service_tier at top level - // Per AWS documentation, service_tier must be a top-level parameter, not inside additionalModelRequestFields - // https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any - - // service_tier should be at the top level of the payload - expect(commandArg.service_tier).toBe("PRIORITY") - // service_tier should NOT be in additionalModelRequestFields - if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) } + + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] + + // service_tier should be passed through providerOptions.bedrock.additionalModelRequestFields + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + expect(bedrockOpts).toBeDefined() + const additionalFields = bedrockOpts!.additionalModelRequestFields as + | Record + | undefined + expect(additionalFields).toBeDefined() + expect(additionalFields!.service_tier).toBe("PRIORITY") }) - it("should include service_tier FLEX as top-level parameter", async () => { + it("should include service_tier FLEX in providerOptions", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: supportedModelId, awsAccessKey: "test", @@ -1026,20 +1058,26 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // service_tier should be at the top level of the payload - expect(commandArg.service_tier).toBe("FLEX") - // service_tier should NOT be in additionalModelRequestFields - if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() - } + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + expect(bedrockOpts).toBeDefined() + const additionalFields = bedrockOpts!.additionalModelRequestFields as + | Record + | undefined + expect(additionalFields).toBeDefined() + expect(additionalFields!.service_tier).toBe("FLEX") }) it("should NOT include service_tier for unsupported models", async () => { + setupMockStreamText() + const unsupportedModelId = "anthropic.claude-3-5-sonnet-20241022-v2:0" const handler = new AwsBedrockHandler({ apiModelId: unsupportedModelId, @@ -1057,19 +1095,25 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Service tier should NOT be included for unsupported models (at top level or in additionalModelRequestFields) - expect(commandArg.service_tier).toBeUndefined() - if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + // Service tier should NOT be included for unsupported models + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + if (bedrockOpts?.additionalModelRequestFields) { + const additionalFields = bedrockOpts.additionalModelRequestFields as Record + expect(additionalFields.service_tier).toBeUndefined() } }) it("should NOT include service_tier when not specified", async () => { + setupMockStreamText() + const handler = new AwsBedrockHandler({ apiModelId: supportedModelId, awsAccessKey: "test", @@ -1086,15 +1130,19 @@ describe("AwsBedrockHandler", () => { ] const generator = handler.createMessage("", messages) - await generator.next() // Start the generator + const chunks: unknown[] = [] + for await (const chunk of generator) { + chunks.push(chunk) + } - expect(mockConverseStreamCommand).toHaveBeenCalled() - const commandArg = mockConverseStreamCommand.mock.calls[0][0] as any + expect(mockStreamText).toHaveBeenCalledTimes(1) + const callArgs = mockStreamText.mock.calls[0][0] - // Service tier should NOT be included when not specified (at top level or in additionalModelRequestFields) - expect(commandArg.service_tier).toBeUndefined() - if (commandArg.additionalModelRequestFields) { - expect(commandArg.additionalModelRequestFields.service_tier).toBeUndefined() + // Service tier should NOT be included when not specified + const bedrockOpts = callArgs.providerOptions?.bedrock as Record | undefined + if (bedrockOpts?.additionalModelRequestFields) { + const additionalFields = bedrockOpts.additionalModelRequestFields as Record + expect(additionalFields.service_tier).toBeUndefined() } }) }) @@ -1127,16 +1175,16 @@ describe("AwsBedrockHandler", () => { }) describe("error telemetry", () => { - let mockSend: ReturnType - beforeEach(() => { mockCaptureException.mockClear() - // Get access to the mock send function from the mocked client - mockSend = vi.mocked(BedrockRuntimeClient).mock.results[0]?.value?.send }) it("should capture telemetry on createMessage error", async () => { - // Create a handler with a fresh mock + // Mock streamText to throw an error + mockStreamText.mockImplementation(() => { + throw new Error("Bedrock API error") + }) + const errorHandler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -1144,15 +1192,6 @@ describe("AwsBedrockHandler", () => { awsRegion: "us-east-1", }) - // Get the mock send from the new handler instance - const clientInstance = - vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1] - ?.value - const mockSendFn = clientInstance?.send as ReturnType - - // Mock the send to throw an error - mockSendFn.mockRejectedValueOnce(new Error("Bedrock API error")) - const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", @@ -1186,7 +1225,9 @@ describe("AwsBedrockHandler", () => { }) it("should capture telemetry on completePrompt error", async () => { - // Create a handler with a fresh mock + // Mock generateText to throw an error + mockGenerateText.mockRejectedValueOnce(new Error("Bedrock completion error")) + const errorHandler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -1194,15 +1235,6 @@ describe("AwsBedrockHandler", () => { awsRegion: "us-east-1", }) - // Get the mock send from the new handler instance - const clientInstance = - vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1] - ?.value - const mockSendFn = clientInstance?.send as ReturnType - - // Mock the send to throw an error for ConverseCommand - mockSendFn.mockRejectedValueOnce(new Error("Bedrock completion error")) - // Call completePrompt - it should throw await expect(errorHandler.completePrompt("Test prompt")).rejects.toThrow() @@ -1223,7 +1255,11 @@ describe("AwsBedrockHandler", () => { }) it("should still throw the error after capturing telemetry", async () => { - // Create a handler with a fresh mock + // Mock streamText to throw an error + mockStreamText.mockImplementation(() => { + throw new Error("Test error for throw verification") + }) + const errorHandler = new AwsBedrockHandler({ apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0", awsAccessKey: "test-access-key", @@ -1231,15 +1267,6 @@ describe("AwsBedrockHandler", () => { awsRegion: "us-east-1", }) - // Get the mock send from the new handler instance - const clientInstance = - vi.mocked(BedrockRuntimeClient).mock.results[vi.mocked(BedrockRuntimeClient).mock.results.length - 1] - ?.value - const mockSendFn = clientInstance?.send as ReturnType - - // Mock the send to throw an error - mockSendFn.mockRejectedValueOnce(new Error("Test error for throw verification")) - const messages: Anthropic.Messages.MessageParam[] = [ { role: "user", diff --git a/src/api/providers/bedrock.ts b/src/api/providers/bedrock.ts index ca747439ef3..375dd2c0421 100644 --- a/src/api/providers/bedrock.ts +++ b/src/api/providers/bedrock.ts @@ -1,24 +1,13 @@ -import { - BedrockRuntimeClient, - ConverseStreamCommand, - ConverseCommand, - BedrockRuntimeClientConfig, - ContentBlock, - Message, - SystemContentBlock, - Tool, - ToolConfiguration, - ToolChoice, -} from "@aws-sdk/client-bedrock-runtime" -import OpenAI from "openai" +import type { Anthropic } from "@anthropic-ai/sdk" +import { createAmazonBedrock, type AmazonBedrockProvider } from "@ai-sdk/amazon-bedrock" +import { streamText, generateText, ToolSet } from "ai" import { fromIni } from "@aws-sdk/credential-providers" -import { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" import { type ModelInfo, type ProviderSettings, type BedrockModelId, - type BedrockServiceTier, bedrockDefaultModelId, bedrockModels, bedrockDefaultPromptRouterModelId, @@ -34,165 +23,22 @@ import { } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { ApiStream } from "../transform/stream" +import type { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { getModelParams } from "../transform/model-params" +import { shouldUseReasoningBudget } from "../../shared/api" import { BaseProvider } from "./base-provider" +import { DEFAULT_HEADERS } from "./constants" import { logger } from "../../utils/logging" import { Package } from "../../shared/package" -import { MultiPointStrategy } from "../transform/cache-strategy/multi-point-strategy" -import { ModelInfo as CacheModelInfo } from "../transform/cache-strategy/types" -import { convertToBedrockConverseMessages as sharedConverter } from "../transform/bedrock-converse-format" -import { getModelParams } from "../transform/model-params" -import { shouldUseReasoningBudget } from "../../shared/api" -import { normalizeToolSchema } from "../../utils/json-schema" import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" -/************************************************************************************ - * - * TYPES - * - *************************************************************************************/ - -// Define interface for Bedrock inference config -interface BedrockInferenceConfig { - maxTokens: number - temperature?: number -} - -// Define interface for Bedrock additional model request fields -// This includes thinking configuration, 1M context beta, and other model-specific parameters -interface BedrockAdditionalModelFields { - thinking?: { - type: "enabled" - budget_tokens: number - } - anthropic_beta?: string[] - [key: string]: any // Add index signature to be compatible with DocumentType -} - -// Define interface for Bedrock payload -interface BedrockPayload { - modelId: BedrockModelId | string - messages: Message[] - system?: SystemContentBlock[] - inferenceConfig: BedrockInferenceConfig - anthropic_version?: string - additionalModelRequestFields?: BedrockAdditionalModelFields - toolConfig?: ToolConfiguration -} - -// Extended payload type that includes service_tier as a top-level parameter -// AWS Bedrock service tiers (STANDARD, FLEX, PRIORITY) are specified at the top level -// https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html -type BedrockPayloadWithServiceTier = BedrockPayload & { - service_tier?: BedrockServiceTier -} - -// Define specific types for content block events to avoid 'as any' usage -// These handle the multiple possible structures returned by AWS SDK -interface ContentBlockStartEvent { - start?: { - text?: string - thinking?: string - toolUse?: { - toolUseId?: string - name?: string - } - } - contentBlockIndex?: number - // Alternative structure used by some AWS SDK versions - content_block?: { - type?: string - thinking?: string - } - // Official AWS SDK structure for reasoning (as documented) - contentBlock?: { - type?: string - thinking?: string - reasoningContent?: { - text?: string - } - // Tool use block start - toolUse?: { - toolUseId?: string - name?: string - } - } -} - -interface ContentBlockDeltaEvent { - delta?: { - text?: string - thinking?: string - type?: string - // AWS SDK structure for reasoning content deltas - // Includes text (reasoning), signature (verification token), and redactedContent (safety-filtered) - reasoningContent?: { - text?: string - signature?: string - redactedContent?: Uint8Array - } - // Tool use input delta - toolUse?: { - input?: string - } - } - contentBlockIndex?: number -} - -// Define types for stream events based on AWS SDK -export interface StreamEvent { - messageStart?: { - role?: string - } - messageStop?: { - stopReason?: "end_turn" | "tool_use" | "max_tokens" | "stop_sequence" - additionalModelResponseFields?: Record - } - contentBlockStart?: ContentBlockStartEvent - contentBlockDelta?: ContentBlockDeltaEvent - metadata?: { - usage?: { - inputTokens: number - outputTokens: number - totalTokens?: number // Made optional since we don't use it - // New cache-related fields - cacheReadInputTokens?: number - cacheWriteInputTokens?: number - cacheReadInputTokenCount?: number - cacheWriteInputTokenCount?: number - } - metrics?: { - latencyMs: number - } - } - // New trace field for prompt router - trace?: { - promptRouter?: { - invokedModelId?: string - usage?: { - inputTokens: number - outputTokens: number - totalTokens?: number // Made optional since we don't use it - // New cache-related fields - cacheReadTokens?: number - cacheWriteTokens?: number - cacheReadInputTokenCount?: number - cacheWriteInputTokenCount?: number - } - } - } -} - -// Type for usage information in stream events -export type UsageType = { - inputTokens?: number - outputTokens?: number - cacheReadInputTokens?: number - cacheWriteInputTokens?: number - cacheReadInputTokenCount?: number - cacheWriteInputTokenCount?: number -} - /************************************************************************************ * * PROVIDER @@ -201,7 +47,7 @@ export type UsageType = { export class AwsBedrockHandler extends BaseProvider implements SingleCompletionHandler { protected options: ProviderSettings - private client: BedrockRuntimeClient + protected provider: AmazonBedrockProvider private arnInfo: any private readonly providerName = "Bedrock" private lastThoughtSignature: string | undefined @@ -212,10 +58,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH this.options = options let region = this.options.awsRegion - // process the various user input options, be opinionated about the intent of the options - // and determine the model to use during inference and for cost calculations - // There are variations on ARN strings that can be entered making the conditional logic - // more involved than the non-ARN branch of logic + // Process custom ARN if provided if (this.options.awsCustomArn) { this.arnInfo = this.parseArn(this.options.awsCustomArn, region) @@ -224,8 +67,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH ctx: "bedrock", errorMessage: this.arnInfo.errorMessage, }) - - // Throw a consistent error with a prefix that can be detected by callers const errorMessage = this.arnInfo.errorMessage || "Invalid ARN format. ARN should follow the pattern: arn:aws:bedrock:region:account-id:resource-type/resource-name" @@ -233,21 +74,16 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } if (this.arnInfo.region && this.arnInfo.region !== this.options.awsRegion) { - // Log if there's a region mismatch between the ARN and the region selected by the user - // We will use the ARNs region, so execution can continue, but log an info statement. - // Log a warning if there's a region mismatch between the ARN and the region selected by the user - // We will use the ARNs region, so execution can continue, but log an info statement. logger.info(this.arnInfo.errorMessage, { ctx: "bedrock", selectedRegion: this.options.awsRegion, arnRegion: this.arnInfo.region, }) - this.options.awsRegion = this.arnInfo.region } this.options.apiModelId = this.arnInfo.modelId - if (this.arnInfo.awsUseCrossRegionInference) this.options.awsUseCrossRegionInference = true + if (this.arnInfo.crossRegionInference) this.options.awsUseCrossRegionInference = true } if (!this.options.modelTemperature) { @@ -256,44 +92,46 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH this.costModelConfig = this.getModel() - const clientConfig: BedrockRuntimeClientConfig = { - userAgentAppId: `RooCode#${Package.version}`, + // Build provider settings for AI SDK + const providerSettings: Parameters[0] = { region: this.options.awsRegion, - // Add the endpoint configuration when specified and enabled + headers: { + ...DEFAULT_HEADERS, + "User-Agent": `RooCode#${Package.version}`, + }, + // Add VPC endpoint if specified and enabled ...(this.options.awsBedrockEndpoint && - this.options.awsBedrockEndpointEnabled && { endpoint: this.options.awsBedrockEndpoint }), + this.options.awsBedrockEndpointEnabled && { baseURL: this.options.awsBedrockEndpoint }), } if (this.options.awsUseApiKey && this.options.awsApiKey) { - // Use API key/token-based authentication if enabled and API key is set - clientConfig.token = { token: this.options.awsApiKey } - clientConfig.authSchemePreference = ["httpBearerAuth"] // Otherwise there's no end of credential problems. - clientConfig.requestHandler = { - // This should be the default anyway, but without setting something - // this provider fails to work with LiteLLM passthrough. - requestTimeout: 0, - } + // Use API key/token-based authentication + providerSettings.apiKey = this.options.awsApiKey } else if (this.options.awsUseProfile && this.options.awsProfile) { - // Use profile-based credentials if enabled and profile is set - clientConfig.credentials = fromIni({ - profile: this.options.awsProfile, - ignoreCache: true, - }) + // Use profile-based credentials via credentialProvider + const profile = this.options.awsProfile + providerSettings.credentialProvider = async () => { + const creds = await fromIni({ profile, ignoreCache: true })() + return { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + ...(creds.sessionToken ? { sessionToken: creds.sessionToken } : {}), + } + } } else if (this.options.awsAccessKey && this.options.awsSecretKey) { - // Use direct credentials if provided - clientConfig.credentials = { - accessKeyId: this.options.awsAccessKey, - secretAccessKey: this.options.awsSecretKey, - ...(this.options.awsSessionToken ? { sessionToken: this.options.awsSessionToken } : {}), + // Use direct credentials + providerSettings.accessKeyId = this.options.awsAccessKey + providerSettings.secretAccessKey = this.options.awsSecretKey + if (this.options.awsSessionToken) { + providerSettings.sessionToken = this.options.awsSessionToken } } - this.client = new BedrockRuntimeClient(clientConfig) + this.provider = createAmazonBedrock(providerSettings) } // Helper to guess model info from custom modelId string if not in bedrockModels private guessModelInfoFromId(modelId: string): Partial { - // Define a mapping for model ID patterns and their configurations const modelConfigMap: Record> = { "claude-4": { maxTokens: 8192, @@ -333,7 +171,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH }, } - // Match the model ID to a configuration const id = modelId.toLowerCase() for (const [pattern, config] of Object.entries(modelConfigMap)) { if (id.includes(pattern)) { @@ -341,7 +178,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - // Default fallback return { maxTokens: BEDROCK_MAX_TOKENS, contextWindow: BEDROCK_DEFAULT_CONTEXT, @@ -353,619 +189,343 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata & { - thinking?: { - enabled: boolean - maxTokens?: number - maxThinkingTokens?: number - } - }, + metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { const modelConfig = this.getModel() - const usePromptCache = Boolean(this.options.awsUsePromptCache && this.supportsAwsPromptCache(modelConfig)) - const conversationId = - messages.length > 0 - ? `conv_${messages[0].role}_${ - typeof messages[0].content === "string" - ? messages[0].content.substring(0, 20) - : "complex_content" - }` - : "default_conversation" - - const formatted = this.convertToBedrockConverseMessages( - messages, - systemPrompt, - usePromptCache, - modelConfig.info, - conversationId, - ) + // Reset thinking state for this request + this.lastThoughtSignature = undefined + this.lastRedactedThinkingBlocks = [] - let additionalModelRequestFields: BedrockAdditionalModelFields | undefined - let thinkingEnabled = false + // Filter out provider-specific meta entries (e.g., { type: "reasoning" }) + // that are not valid Anthropic MessageParam values + type ReasoningMetaLike = { type?: string } + const filteredMessages = messages.filter((message): message is Anthropic.Messages.MessageParam => { + const meta = message as ReasoningMetaLike + if (meta.type === "reasoning") { + return false + } + return true + }) + + // Convert messages to AI SDK format + const aiSdkMessages = convertToAiSdkMessages(filteredMessages) + + // Convert tools to AI SDK format + let openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + const toolChoice = mapToolChoice(metadata?.tool_choice) - // Determine if thinking should be enabled - // metadata?.thinking?.enabled: Explicitly enabled through API metadata (direct request) - // shouldUseReasoningBudget(): Enabled through user settings (enableReasoningEffort = true) - const isThinkingExplicitlyEnabled = metadata?.thinking?.enabled + // Build provider options for reasoning, betas, etc. + const bedrockProviderOptions: Record = {} + + // Extended thinking / reasoning configuration const isThinkingEnabledBySettings = shouldUseReasoningBudget({ model: modelConfig.info, settings: this.options }) && modelConfig.reasoning && modelConfig.reasoningBudget - if ((isThinkingExplicitlyEnabled || isThinkingEnabledBySettings) && modelConfig.info.supportsReasoningBudget) { - thinkingEnabled = true - additionalModelRequestFields = { - thinking: { - type: "enabled", - budget_tokens: metadata?.thinking?.maxThinkingTokens || modelConfig.reasoningBudget || 4096, - }, + if (isThinkingEnabledBySettings && modelConfig.info.supportsReasoningBudget) { + bedrockProviderOptions.reasoningConfig = { + type: "enabled", + budgetTokens: modelConfig.reasoningBudget, } - logger.info("Extended thinking enabled for Bedrock request", { - ctx: "bedrock", - modelId: modelConfig.id, - thinking: additionalModelRequestFields.thinking, - }) } - const inferenceConfig: BedrockInferenceConfig = { - maxTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number), - temperature: modelConfig.temperature ?? (this.options.modelTemperature as number), - } - - // Check if 1M context is enabled for supported Claude 4 models - // Use parseBaseModelId to handle cross-region inference prefixes - const baseModelId = this.parseBaseModelId(modelConfig.id) - const is1MContextEnabled = - BEDROCK_1M_CONTEXT_MODEL_IDS.includes(baseModelId as any) && this.options.awsBedrock1MContext - - // Determine if service tier should be applied (checked later when building payload) - const useServiceTier = - this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelId as any) - if (useServiceTier) { - logger.info("Service tier specified for Bedrock request", { - ctx: "bedrock", - modelId: modelConfig.id, - serviceTier: this.options.awsBedrockServiceTier, - }) - } - - // Add anthropic_beta headers for various features - // Start with an empty array and add betas as needed + // Anthropic beta headers for various features const anthropicBetas: string[] = [] + const baseModelId = this.parseBaseModelId(modelConfig.id) // Add 1M context beta if enabled - if (is1MContextEnabled) { + if (BEDROCK_1M_CONTEXT_MODEL_IDS.includes(baseModelId as any) && this.options.awsBedrock1MContext) { anthropicBetas.push("context-1m-2025-08-07") } - // Add fine-grained tool streaming beta for Claude models - // This enables proper tool use streaming for Anthropic models on Bedrock - if (baseModelId.includes("claude")) { - anthropicBetas.push("fine-grained-tool-streaming-2025-05-14") - } - - // Apply anthropic_beta to additionalModelRequestFields if any betas are needed if (anthropicBetas.length > 0) { - if (!additionalModelRequestFields) { - additionalModelRequestFields = {} as BedrockAdditionalModelFields - } - additionalModelRequestFields.anthropic_beta = anthropicBetas - } - - const toolConfig: ToolConfiguration = { - tools: this.convertToolsForBedrock(metadata?.tools ?? []), - toolChoice: this.convertToolChoiceForBedrock(metadata?.tool_choice), - } - - // Build payload with optional service_tier at top level - // Service tier is a top-level parameter per AWS documentation, NOT inside additionalModelRequestFields - // https://docs.aws.amazon.com/bedrock/latest/userguide/service-tiers-inference.html - const payload: BedrockPayloadWithServiceTier = { - modelId: modelConfig.id, - messages: formatted.messages, - system: formatted.system, - inferenceConfig, - ...(additionalModelRequestFields && { additionalModelRequestFields }), - // Add anthropic_version at top level when using thinking features - ...(thinkingEnabled && { anthropic_version: "bedrock-2023-05-31" }), - toolConfig, - // Add service_tier as a top-level parameter (not inside additionalModelRequestFields) - ...(useServiceTier && { service_tier: this.options.awsBedrockServiceTier }), - } + bedrockProviderOptions.anthropicBeta = anthropicBetas + } + + // Additional model request fields (service tier, etc.) + // Note: The AI SDK may not directly support service_tier as a top-level param, + // so we pass it through additionalModelRequestFields + if (this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelId as any)) { + bedrockProviderOptions.additionalModelRequestFields = { + ...(bedrockProviderOptions.additionalModelRequestFields as Record | undefined), + service_tier: this.options.awsBedrockServiceTier, + } + } + + // Prompt caching: use AI SDK's cachePoint mechanism + // The AI SDK's @ai-sdk/amazon-bedrock supports cachePoint in providerOptions per message. + // + // Strategy: Bedrock allows up to 4 cache checkpoints. We use them as: + // 1. System prompt (via systemProviderOptions below) + // 2-4. Up to 3 user messages in the conversation history + // + // For the message cache points, we target the last 2 user messages (matching + // Anthropic's strategy: write-to-cache + read-from-cache) PLUS an earlier "anchor" + // user message near the middle of the conversation. This anchor ensures the 20-block + // lookback window has a stable cache entry to hit, covering all assistant/tool messages + // between the anchor and the recent messages. + // + // We identify targets in the ORIGINAL Anthropic messages (before AI SDK conversion) + // because convertToAiSdkMessages() splits user messages containing tool_results into + // separate "tool" + "user" role messages, which would skew naive counting. + const usePromptCache = Boolean(this.options.awsUsePromptCache && this.supportsAwsPromptCache(modelConfig)) - // Create AbortController with 10 minute timeout - const controller = new AbortController() - let timeoutId: NodeJS.Timeout | undefined + if (usePromptCache) { + const cachePointOption = { bedrock: { cachePoint: { type: "default" as const } } } - try { - timeoutId = setTimeout( - () => { - controller.abort() - }, - 10 * 60 * 1000, + // Find all user message indices in the original (pre-conversion) message array. + const originalUserIndices = filteredMessages.reduce( + (acc, msg, idx) => (msg.role === "user" ? [...acc, idx] : acc), + [], ) - const command = new ConverseStreamCommand(payload) - const response = await this.client.send(command, { - abortSignal: controller.signal, - }) - - if (!response.stream) { - clearTimeout(timeoutId) - throw new Error("No stream available in the response") + // Select up to 3 user messages for cache points (system prompt uses the 4th): + // - Last user message: write to cache for next request + // - Second-to-last user message: read from cache for current request + // - An "anchor" message earlier in the conversation for 20-block window coverage + const targetOriginalIndices = new Set() + const numUserMsgs = originalUserIndices.length + + if (numUserMsgs >= 1) { + // Always cache the last user message + targetOriginalIndices.add(originalUserIndices[numUserMsgs - 1]) + } + if (numUserMsgs >= 2) { + // Cache the second-to-last user message + targetOriginalIndices.add(originalUserIndices[numUserMsgs - 2]) + } + if (numUserMsgs >= 5) { + // Add an anchor cache point roughly in the first third of user messages. + // This ensures that the 20-block lookback from the second-to-last breakpoint + // can find a stable cache entry, covering all the assistant and tool messages + // in the middle of the conversation. We pick the user message at ~1/3 position. + const anchorIdx = Math.floor(numUserMsgs / 3) + // Only add if it's not already one of the last-2 targets + if (!targetOriginalIndices.has(originalUserIndices[anchorIdx])) { + targetOriginalIndices.add(originalUserIndices[anchorIdx]) + } } - // Reset thinking state for this request - this.lastThoughtSignature = undefined - this.lastRedactedThinkingBlocks = [] - - for await (const chunk of response.stream) { - // Parse the chunk as JSON if it's a string (for tests) - let streamEvent: StreamEvent - try { - streamEvent = typeof chunk === "string" ? JSON.parse(chunk) : (chunk as unknown as StreamEvent) - } catch (e) { - logger.error("Failed to parse stream event", { + // Apply cachePoint to the correct AI SDK messages by walking both arrays in parallel. + // A single original user message with tool_results becomes [tool-role msg, user-role msg] + // in the AI SDK array, while a plain user message becomes [user-role msg]. + if (targetOriginalIndices.size > 0) { + this.applyCachePointsToAiSdkMessages( + filteredMessages, + aiSdkMessages, + targetOriginalIndices, + cachePointOption, + ) + } + } + + // Build streamText request + // Cast providerOptions to any to bypass strict JSONObject typing — the AI SDK accepts the correct runtime values + const requestOptions: Parameters[0] = { + model: this.provider(modelConfig.id), + system: systemPrompt, + ...(usePromptCache && { + systemProviderOptions: { bedrock: { cachePoint: { type: "default" } } } as Record, + }), + messages: aiSdkMessages, + temperature: modelConfig.temperature ?? (this.options.modelTemperature as number), + maxOutputTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number), + tools: aiSdkTools, + toolChoice, + ...(Object.keys(bedrockProviderOptions).length > 0 && { + providerOptions: { bedrock: bedrockProviderOptions } as any, + }), + } + + try { + const result = streamText(requestOptions) + + // Process the full stream + for await (const part of result.fullStream) { + // Capture thinking signature from stream events. + // The AI SDK's @ai-sdk/amazon-bedrock emits the signature as a reasoning-delta + // event with providerMetadata.bedrock.signature (empty delta text, signature in metadata). + // Also check tool-call events for thoughtSignature (Gemini pattern). + const partAny = part as any + if (partAny.providerMetadata?.bedrock?.signature) { + this.lastThoughtSignature = partAny.providerMetadata.bedrock.signature + logger.info("Captured thinking signature from stream", { ctx: "bedrock", - error: e instanceof Error ? e : String(e), - chunk: typeof chunk === "string" ? chunk : "binary data", + signatureLength: this.lastThoughtSignature?.length, }) - continue - } - - // Handle metadata events first - if (streamEvent.metadata?.usage) { - const usage = (streamEvent.metadata?.usage || {}) as UsageType - - // Check both field naming conventions for cache tokens - const cacheReadTokens = usage.cacheReadInputTokens || usage.cacheReadInputTokenCount || 0 - const cacheWriteTokens = usage.cacheWriteInputTokens || usage.cacheWriteInputTokenCount || 0 - - // Always include all available token information - yield { - type: "usage", - inputTokens: usage.inputTokens || 0, - outputTokens: usage.outputTokens || 0, - cacheReadTokens: cacheReadTokens, - cacheWriteTokens: cacheWriteTokens, - } - continue + } else if (partAny.providerMetadata?.bedrock?.thoughtSignature) { + this.lastThoughtSignature = partAny.providerMetadata.bedrock.thoughtSignature + } else if (partAny.providerMetadata?.anthropic?.thoughtSignature) { + this.lastThoughtSignature = partAny.providerMetadata.anthropic.thoughtSignature } - if (streamEvent?.trace?.promptRouter?.invokedModelId) { - try { - //update the in-use model info to be based on the invoked Model Id for the router - //so that pricing, context window, caching etc have values that can be used - //However, we want to keep the id of the model to be the ID for the router for - //subsequent requests so they are sent back through the router - let invokedArnInfo = this.parseArn(streamEvent.trace.promptRouter.invokedModelId) - let invokedModel = this.getModelById(invokedArnInfo.modelId as string, invokedArnInfo.modelType) - if (invokedModel) { - invokedModel.id = modelConfig.id - this.costModelConfig = invokedModel - } - - // Handle metadata events for the promptRouter. - if (streamEvent?.trace?.promptRouter?.usage) { - const routerUsage = streamEvent.trace.promptRouter.usage - - // Check both field naming conventions for cache tokens - const cacheReadTokens = - routerUsage.cacheReadTokens || routerUsage.cacheReadInputTokenCount || 0 - const cacheWriteTokens = - routerUsage.cacheWriteTokens || routerUsage.cacheWriteInputTokenCount || 0 - - yield { - type: "usage", - inputTokens: routerUsage.inputTokens || 0, - outputTokens: routerUsage.outputTokens || 0, - cacheReadTokens: cacheReadTokens, - cacheWriteTokens: cacheWriteTokens, - } - } - } catch (error) { - logger.error("Error handling Bedrock invokedModelId", { - ctx: "bedrock", - error: error instanceof Error ? error : String(error), - }) - } finally { - // eslint-disable-next-line no-unsafe-finally - continue - } - } - - // Handle message start - if (streamEvent.messageStart) { - continue - } - - // Handle content blocks - if (streamEvent.contentBlockStart) { - const cbStart = streamEvent.contentBlockStart - - // Check if this is a reasoning block (AWS SDK structure) - if (cbStart.contentBlock?.reasoningContent) { - if (cbStart.contentBlockIndex && cbStart.contentBlockIndex > 0) { - yield { type: "reasoning", text: "\n" } - } - yield { - type: "reasoning", - text: cbStart.contentBlock.reasoningContent.text || "", - } - } - // Check for thinking block - handle both possible AWS SDK structures - // cbStart.contentBlock: newer structure - // cbStart.content_block: alternative structure seen in some AWS SDK versions - else if (cbStart.contentBlock?.type === "thinking" || cbStart.content_block?.type === "thinking") { - const contentBlock = cbStart.contentBlock || cbStart.content_block - if (cbStart.contentBlockIndex && cbStart.contentBlockIndex > 0) { - yield { type: "reasoning", text: "\n" } - } - if (contentBlock?.thinking) { - yield { - type: "reasoning", - text: contentBlock.thinking, - } - } - } - // Handle tool use block start - else if (cbStart.start?.toolUse || cbStart.contentBlock?.toolUse) { - const toolUse = cbStart.start?.toolUse || cbStart.contentBlock?.toolUse - if (toolUse) { - yield { - type: "tool_call_partial", - index: cbStart.contentBlockIndex ?? 0, - id: toolUse.toolUseId, - name: toolUse.name, - arguments: undefined, - } - } - } else if (cbStart.start?.text) { - yield { - type: "text", - text: cbStart.start.text, - } - } - continue + // Capture redacted reasoning data from stream events + if (partAny.providerMetadata?.bedrock?.redactedData) { + this.lastRedactedThinkingBlocks.push({ + type: "redacted_thinking", + data: partAny.providerMetadata.bedrock.redactedData, + }) } - // Handle content deltas - if (streamEvent.contentBlockDelta) { - const cbDelta = streamEvent.contentBlockDelta - const delta = cbDelta.delta - - // Process reasoning and text content deltas - // Multiple structures are supported for AWS SDK compatibility: - // - delta.reasoningContent.text: AWS docs structure for reasoning - // - delta.thinking: alternative structure for thinking content - // - delta.text: standard text content - // - delta.toolUse.input: tool input arguments - if (delta) { - // Check for reasoningContent property (AWS SDK structure) - if (delta.reasoningContent?.text) { - yield { - type: "reasoning", - text: delta.reasoningContent.text, - } - continue - } - - // Capture the thinking signature from reasoningContent.signature delta. - // Bedrock Converse API sends the signature as a separate delta after all - // reasoning text deltas. This signature must be round-tripped back for - // multi-turn conversations with tool use (Anthropic API requirement). - if (delta.reasoningContent?.signature) { - this.lastThoughtSignature = delta.reasoningContent.signature - continue - } - - // Capture redacted thinking content (opaque binary data from safety-filtered reasoning). - // Anthropic returns this when extended thinking content is filtered. It must be - // passed back verbatim in multi-turn conversations for proper reasoning continuity. - if (delta.reasoningContent?.redactedContent) { - const redactedContent = delta.reasoningContent.redactedContent - this.lastRedactedThinkingBlocks.push({ - type: "redacted_thinking", - data: Buffer.from(redactedContent).toString("base64"), - }) - continue - } - - // Handle tool use input delta - if (delta.toolUse?.input) { - yield { - type: "tool_call_partial", - index: cbDelta.contentBlockIndex ?? 0, - id: undefined, - name: undefined, - arguments: delta.toolUse.input, - } - continue - } - - // Handle alternative thinking structure (fallback for older SDK versions) - if (delta.type === "thinking_delta" && delta.thinking) { - yield { - type: "reasoning", - text: delta.thinking, - } - } else if (delta.text) { - yield { - type: "text", - text: delta.text, - } - } - } - continue - } - // Handle message stop - if (streamEvent.messageStop) { - continue + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk } } - // Clear timeout after stream completes - clearTimeout(timeoutId) - } catch (error: unknown) { - // Clear timeout on error - clearTimeout(timeoutId) - // Capture error in telemetry before processing + // Yield usage metrics at the end + const usage = await result.usage + const providerMetadata = await result.providerMetadata + if (usage) { + yield this.processUsageMetrics(usage, modelConfig.info, providerMetadata) + } + } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) const apiError = new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "createMessage") TelemetryService.instance.captureException(apiError) - // Check if this is a throttling error that should trigger retry logic - const errorType = this.getErrorType(error) - - // For throttling errors, throw immediately without yielding chunks - // This allows the retry mechanism in attemptApiRequest() to catch and handle it - // The retry logic in Task.ts (around line 1817) expects errors to be thrown - // on the first chunk for proper exponential backoff behavior - if (errorType === "THROTTLING") { + // Check for throttling errors that should trigger retry (re-throw original to preserve status) + if (this.isThrottlingError(error)) { if (error instanceof Error) { throw error - } else { - throw new Error("Throttling error occurred") } + throw new Error("Throttling error occurred") } - // For non-throttling errors, use the standard error handling with chunks - const errorChunks = this.handleBedrockError(error, true) // true for streaming context - // Yield each chunk individually to ensure type compatibility - for (const chunk of errorChunks) { - yield chunk as any // Cast to any to bypass type checking since we know the structure is correct - } - - // Re-throw with enhanced error message for retry system - const enhancedErrorMessage = this.formatErrorMessage(error, this.getErrorType(error), true) - if (error instanceof Error) { - const enhancedError = new Error(enhancedErrorMessage) - // Preserve important properties from the original error - enhancedError.name = error.name - // Validate and preserve status property - if ("status" in error && typeof (error as any).status === "number") { - ;(enhancedError as any).status = (error as any).status - } - // Validate and preserve $metadata property - if ( - "$metadata" in error && - typeof (error as any).$metadata === "object" && - (error as any).$metadata !== null - ) { - ;(enhancedError as any).$metadata = (error as any).$metadata - } - throw enhancedError - } else { - throw new Error("An unknown error occurred") - } + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, this.providerName) } } - async completePrompt(prompt: string): Promise { - try { - const modelConfig = this.getModel() - - // For completePrompt, thinking is typically not used, but we should still check - // if thinking was somehow enabled in the model config - const thinkingEnabled = - shouldUseReasoningBudget({ model: modelConfig.info, settings: this.options }) && - modelConfig.reasoning && - modelConfig.reasoningBudget - - const inferenceConfig: BedrockInferenceConfig = { - maxTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number), - temperature: modelConfig.temperature ?? (this.options.modelTemperature as number), - } - - // For completePrompt, use a unique conversation ID based on the prompt - const conversationId = `prompt_${prompt.substring(0, 20)}` - - const payload = { - modelId: modelConfig.id, - messages: this.convertToBedrockConverseMessages( - [ - { - role: "user", - content: prompt, - }, - ], - undefined, - false, - modelConfig.info, - conversationId, - ).messages, - inferenceConfig, - } - - const command = new ConverseCommand(payload) - const response = await this.client.send(command) - - if ( - response?.output?.message?.content && - response.output.message.content.length > 0 && - response.output.message.content[0].text && - response.output.message.content[0].text.trim().length > 0 - ) { - try { - return response.output.message.content[0].text - } catch (parseError) { - logger.error("Failed to parse Bedrock response", { - ctx: "bedrock", - error: parseError instanceof Error ? parseError : String(parseError), - }) + /** + * Process usage metrics from the AI SDK response. + */ + private processUsageMetrics( + usage: { inputTokens?: number; outputTokens?: number }, + info: ModelInfo, + providerMetadata?: Record>, + ): ApiStreamUsageChunk { + const inputTokens = usage.inputTokens ?? 0 + const outputTokens = usage.outputTokens ?? 0 + + // The AI SDK exposes reasoningTokens as a top-level field on usage, and also + // under outputTokenDetails.reasoningTokens — there is no .details property. + const reasoningTokens = + (usage as any).reasoningTokens ?? (usage as any).outputTokenDetails?.reasoningTokens ?? 0 + + // Extract cache metrics primarily from usage (AI SDK standard locations), + // falling back to providerMetadata.bedrock.usage for provider-specific fields. + const bedrockUsage = providerMetadata?.bedrock?.usage as + | { cacheReadInputTokens?: number; cacheWriteInputTokens?: number } + | undefined + const cacheReadTokens = + (usage as any).inputTokenDetails?.cacheReadTokens ?? + (usage as any).cachedInputTokens ?? + bedrockUsage?.cacheReadInputTokens ?? + 0 + const cacheWriteTokens = + (usage as any).inputTokenDetails?.cacheWriteTokens ?? bedrockUsage?.cacheWriteInputTokens ?? 0 + + // For prompt routers, the AI SDK surfaces the invoked model ID in + // providerMetadata.bedrock.trace.promptRouter.invokedModelId. + // When present, look up that model's pricing info for accurate cost calculation. + const invokedModelId = (providerMetadata?.bedrock as any)?.trace?.promptRouter?.invokedModelId as + | string + | undefined + let costInfo = info + if (invokedModelId) { + try { + const invokedArnInfo = this.parseArn(invokedModelId) + const invokedModel = this.getModelById(invokedArnInfo.modelId as string, invokedArnInfo.modelType) + if (invokedModel) { + // Update costModelConfig so subsequent requests use the invoked model's pricing, + // but keep the router's ID so requests continue through the router. + invokedModel.id = this.costModelConfig.id || invokedModel.id + this.costModelConfig = invokedModel + costInfo = invokedModel.info } + } catch (error) { + logger.error("Error handling Bedrock invokedModelId", { + ctx: "bedrock", + error: error instanceof Error ? error : String(error), + }) } - return "" - } catch (error) { - // Capture error in telemetry - const model = this.getModel() - const telemetryErrorMessage = error instanceof Error ? error.message : String(error) - const apiError = new ApiProviderError(telemetryErrorMessage, this.providerName, model.id, "completePrompt") - TelemetryService.instance.captureException(apiError) + } - // Use the extracted error handling method for all errors - const errorResult = this.handleBedrockError(error, false) // false for non-streaming context - // Since we're in a non-streaming context, we know the result is a string - const errorMessage = errorResult as string - - // Create enhanced error for retry system - const enhancedError = new Error(errorMessage) - if (error instanceof Error) { - // Preserve important properties from the original error - enhancedError.name = error.name - // Validate and preserve status property - if ("status" in error && typeof (error as any).status === "number") { - ;(enhancedError as any).status = (error as any).status - } - // Validate and preserve $metadata property - if ( - "$metadata" in error && - typeof (error as any).$metadata === "object" && - (error as any).$metadata !== null - ) { - ;(enhancedError as any).$metadata = (error as any).$metadata - } - } - throw enhancedError + return { + type: "usage", + inputTokens, + outputTokens, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + reasoningTokens: reasoningTokens > 0 ? reasoningTokens : undefined, + totalCost: this.calculateCost({ + inputTokens, + outputTokens, + cacheWriteTokens, + cacheReadTokens, + reasoningTokens, + info: costInfo, + }), } } /** - * Convert Anthropic messages to Bedrock Converse format + * Check if an error is a throttling/rate limit error */ - private convertToBedrockConverseMessages( - anthropicMessages: Anthropic.Messages.MessageParam[] | { role: string; content: string }[], - systemMessage?: string, - usePromptCache: boolean = false, - modelInfo?: any, - conversationId?: string, // Optional conversation ID to track cache points across messages - ): { system: SystemContentBlock[]; messages: Message[] } { - // First convert messages using shared converter for proper image handling - const convertedMessages = sharedConverter(anthropicMessages as Anthropic.Messages.MessageParam[]) - - // If prompt caching is disabled, return the converted messages directly - if (!usePromptCache) { - return { - system: systemMessage ? [{ text: systemMessage } as SystemContentBlock] : [], - messages: convertedMessages, - } - } - - // Convert model info to expected format for cache strategy - const cacheModelInfo: CacheModelInfo = { - maxTokens: modelInfo?.maxTokens || 8192, - contextWindow: modelInfo?.contextWindow || 200_000, - supportsPromptCache: modelInfo?.supportsPromptCache || false, - maxCachePoints: modelInfo?.maxCachePoints || 0, - minTokensPerCachePoint: modelInfo?.minTokensPerCachePoint || 50, - cachableFields: modelInfo?.cachableFields || [], - } - - // Get previous cache point placements for this conversation if available - const previousPlacements = - conversationId && this.previousCachePointPlacements[conversationId] - ? this.previousCachePointPlacements[conversationId] - : undefined - - // Create config for cache strategy - const config = { - modelInfo: cacheModelInfo, - systemPrompt: systemMessage, - messages: anthropicMessages as Anthropic.Messages.MessageParam[], - usePromptCache, - previousCachePointPlacements: previousPlacements, - } + private isThrottlingError(error: unknown): boolean { + if (!(error instanceof Error)) return false + if ((error as any).status === 429 || (error as any).$metadata?.httpStatusCode === 429) return true + if ((error as any).name === "ThrottlingException") return true + const msg = error.message.toLowerCase() + return ( + msg.includes("throttl") || + msg.includes("rate limit") || + msg.includes("too many requests") || + msg.includes("bedrock is unable to process your request") + ) + } - // Get cache point placements - let strategy = new MultiPointStrategy(config) - const cacheResult = strategy.determineOptimalCachePoints() + async completePrompt(prompt: string): Promise { + const modelConfig = this.getModel() - // Store cache point placements for future use if conversation ID is provided - if (conversationId && cacheResult.messageCachePointPlacements) { - this.previousCachePointPlacements[conversationId] = cacheResult.messageCachePointPlacements - } + try { + const result = await generateText({ + model: this.provider(modelConfig.id), + prompt, + temperature: modelConfig.temperature ?? (this.options.modelTemperature as number), + maxOutputTokens: modelConfig.maxTokens || (modelConfig.info.maxTokens as number), + }) - // Apply cache points to the properly converted messages - const messagesWithCache = convertedMessages.map((msg, index) => { - const placement = cacheResult.messageCachePointPlacements?.find((p) => p.index === index) - if (placement) { - return { - ...msg, - content: [...(msg.content || []), { cachePoint: { type: "default" } } as ContentBlock], - } - } - return msg - }) + return result.text + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + const apiError = new ApiProviderError(errorMessage, this.providerName, modelConfig.id, "completePrompt") + TelemetryService.instance.captureException(apiError) - return { - system: cacheResult.system, - messages: messagesWithCache, + // Handle AI SDK errors (AI_RetryError, AI_APICallError, etc.) + throw handleAiSdkError(error, this.providerName) } } /************************************************************************************ * - * MODEL IDENTIFICATION + * MODEL CONFIGURATION * *************************************************************************************/ private costModelConfig: { id: BedrockModelId | string; info: ModelInfo } = { id: "", - info: { maxTokens: 0, contextWindow: 0, supportsPromptCache: false, supportsImages: false }, + info: { maxTokens: 0, contextWindow: 0, supportsPromptCache: false }, } private parseArn(arn: string, region?: string) { - /* - * VIA Roo analysis: platform-independent Regex. It's designed to parse Amazon Bedrock ARNs and doesn't rely on any platform-specific features - * like file path separators, line endings, or case sensitivity behaviors. The forward slashes in the regex are properly escaped and - * represent literal characters in the AWS ARN format, not filesystem paths. This regex will function consistently across Windows, - * macOS, Linux, and any other operating system where JavaScript runs. - * - * Supports any AWS partition (aws, aws-us-gov, aws-cn, or future partitions). - * The partition is not captured since we don't need to use it. - * - * This matches ARNs like: - * - Foundation Model: arn:aws:bedrock:us-west-2::foundation-model/anthropic.claude-v2 - * - GovCloud Inference Profile: arn:aws-us-gov:bedrock:us-gov-west-1:123456789012:inference-profile/us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0 - * - Prompt Router: arn:aws:bedrock:us-west-2:123456789012:prompt-router/anthropic-claude - * - Inference Profile: arn:aws:bedrock:us-west-2:123456789012:inference-profile/anthropic.claude-v2 - * - Cross Region Inference Profile: arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.anthropic.claude-3-5-sonnet-20241022-v2:0 - * - Custom Model (Provisioned Throughput): arn:aws:bedrock:us-west-2:123456789012:provisioned-model/my-custom-model - * - Imported Model: arn:aws:bedrock:us-west-2:123456789012:imported-model/my-imported-model - * - * match[0] - The entire matched string - * match[1] - The region (e.g., "us-east-1", "us-gov-west-1") - * match[2] - The account ID (can be empty string for AWS-managed resources) - * match[3] - The resource type (e.g., "foundation-model") - * match[4] - The resource ID (e.g., "anthropic.claude-3-sonnet-20240229-v1:0") - */ - const arnRegex = /^arn:[^:]+:(?:bedrock|sagemaker):([^:]+):([^:]*):(?:([^\/]+)\/([\w\.\-:]+)|([^\/]+))$/ let match = arn.match(arnRegex) if (match && match[1] && match[3] && match[4]) { - // Create the result object const result: { isValid: boolean region?: string @@ -975,25 +535,21 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH crossRegionInference: boolean } = { isValid: true, - crossRegionInference: false, // Default to false + crossRegionInference: false, } result.modelType = match[3] const originalModelId = match[4] result.modelId = this.parseBaseModelId(originalModelId) - // Extract the region from the first capture group const arnRegion = match[1] result.region = arnRegion - // Check if the original model ID had a region prefix if (originalModelId && result.modelId !== originalModelId) { - // If the model ID changed after parsing, it had a region prefix let prefix = originalModelId.replace(result.modelId, "") result.crossRegionInference = AwsBedrockHandler.isSystemInferenceProfile(prefix) } - // Check if region in ARN matches provided region (if specified) if (region && arnRegion !== region) { result.errorMessage = `Region mismatch: The region in your ARN (${arnRegion}) does not match your selected region (${region}). This may cause access issues. The provider will use the region from the ARN.` result.region = arnRegion @@ -1002,7 +558,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH return result } - // If we get here, the regex didn't match return { isValid: false, region: undefined, @@ -1013,40 +568,27 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - //This strips any region prefix that used on cross-region model inference ARNs private parseBaseModelId(modelId: string): string { - if (!modelId) { - return modelId - } + if (!modelId) return modelId - // Remove AWS cross-region inference profile prefixes - // as defined in AWS_INFERENCE_PROFILE_MAPPING for (const [_, inferenceProfile] of AWS_INFERENCE_PROFILE_MAPPING) { if (modelId.startsWith(inferenceProfile)) { - // Remove the inference profile prefix from the model ID return modelId.substring(inferenceProfile.length) } } - // Also strip Global Inference profile prefix if present if (modelId.startsWith("global.")) { return modelId.substring("global.".length) } - // Return the model ID as-is for all other cases return modelId } - //Prompt Router responses come back in a different sequence and the model used is in the response and must be fetched by name getModelById(modelId: string, modelType?: string): { id: BedrockModelId | string; info: ModelInfo } { - // Try to find the model in bedrockModels const baseModelId = this.parseBaseModelId(modelId) as BedrockModelId let model if (baseModelId in bedrockModels) { - //Do a deep copy of the model info so that later in the code the model id and maxTokens can be set. - // The bedrockModels array is a constant and updating the model ID from the returned invokedModelID value - // in a prompt router response isn't possible on the constant. model = { id: baseModelId, info: JSON.parse(JSON.stringify(bedrockModels[baseModelId])) } } else if (modelType && modelType.includes("router")) { model = { @@ -1054,7 +596,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH info: JSON.parse(JSON.stringify(bedrockModels[bedrockDefaultPromptRouterModelId])), } } else { - // Use heuristics for model info, then allow overrides from ProviderSettings const guessed = this.guessModelInfoFromId(modelId) model = { id: bedrockDefaultModelId, @@ -1065,7 +606,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - // Always allow user to override detected/guessed maxTokens and contextWindow if (this.options.modelMaxTokens && this.options.modelMaxTokens > 0) { model.info.maxTokens = this.options.modelMaxTokens } @@ -1085,7 +625,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH reasoningBudget?: number } { if (this.costModelConfig?.id?.trim().length > 0) { - // Get model params for cost model config const params = getModelParams({ format: "anthropic", modelId: this.costModelConfig.id, @@ -1098,28 +637,19 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH let modelConfig = undefined - // If custom ARN is provided, use it if (this.options.awsCustomArn) { modelConfig = this.getModelById(this.arnInfo.modelId, this.arnInfo.modelType) - - //If the user entered an ARN for a foundation-model they've done the same thing as picking from our list of options. - //We leave the model data matching the same as if a drop-down input method was used by not overwriting the model ID with the user input ARN - //Otherwise the ARN is not a foundation-model resource type that ARN should be used as the identifier in Bedrock interactions if (this.arnInfo.modelType !== "foundation-model") modelConfig.id = this.options.awsCustomArn } else { - //a model was selected from the drop down modelConfig = this.getModelById(this.options.apiModelId as string) - // Apply Global Inference prefix if enabled and supported (takes precedence over cross-region) const baseIdForGlobal = this.parseBaseModelId(modelConfig.id) if ( this.options.awsUseGlobalInference && BEDROCK_GLOBAL_INFERENCE_MODEL_IDS.includes(baseIdForGlobal as any) ) { modelConfig.id = `global.${baseIdForGlobal}` - } - // Otherwise, add cross-region inference prefix if enabled - else if (this.options.awsUseCrossRegionInference && this.options.awsRegion) { + } else if (this.options.awsUseCrossRegionInference && this.options.awsRegion) { const prefix = AwsBedrockHandler.getPrefixForRegion(this.options.awsRegion) if (prefix) { modelConfig.id = `${prefix}${modelConfig.id}` @@ -1127,11 +657,9 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - // Check if 1M context is enabled for supported Claude 4 models - // Use parseBaseModelId to handle cross-region inference prefixes + // Check if 1M context is enabled const baseModelId = this.parseBaseModelId(modelConfig.id) if (BEDROCK_1M_CONTEXT_MODEL_IDS.includes(baseModelId as any) && this.options.awsBedrock1MContext) { - // Update context window and pricing to 1M tier when 1M context beta is enabled const tier = modelConfig.info.tiers?.[0] modelConfig.info = { ...modelConfig.info, @@ -1143,7 +671,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - // Get model params including reasoning configuration const params = getModelParams({ format: "anthropic", modelId: modelConfig.id, @@ -1152,12 +679,11 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH defaultTemperature: BEDROCK_DEFAULT_TEMPERATURE, }) - // Apply service tier pricing if specified and model supports it + // Apply service tier pricing const baseModelIdForTier = this.parseBaseModelId(modelConfig.id) if (this.options.awsBedrockServiceTier && BEDROCK_SERVICE_TIER_MODEL_IDS.includes(baseModelIdForTier as any)) { const pricingMultiplier = BEDROCK_SERVICE_TIER_PRICING[this.options.awsBedrockServiceTier] if (pricingMultiplier && pricingMultiplier !== 1.0) { - // Apply pricing multiplier to all price fields modelConfig.info = { ...modelConfig.info, inputPrice: modelConfig.info.inputPrice @@ -1176,7 +702,6 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH } } - // Don't override maxTokens/contextWindow here; handled in getModelById (and includes user overrides) return { ...modelConfig, ...params } as { id: BedrockModelId | string info: ModelInfo @@ -1193,103 +718,82 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH * *************************************************************************************/ - // Store previous cache point placements for maintaining consistency across consecutive messages - private previousCachePointPlacements: { [conversationId: string]: any[] } = {} - private supportsAwsPromptCache(modelConfig: { id: BedrockModelId | string; info: ModelInfo }): boolean | undefined { - // Check if the model supports prompt cache - // The cachableFields property is not part of the ModelInfo type in schemas - // but it's used in the bedrockModels object in shared/api.ts return ( modelConfig?.info?.supportsPromptCache && - // Use optional chaining and type assertion to access cachableFields (modelConfig?.info as any)?.cachableFields && (modelConfig?.info as any)?.cachableFields?.length > 0 ) } /** - * Removes any existing cachePoint nodes from content blocks - */ - private removeCachePoints(content: any): any { - if (Array.isArray(content)) { - return content.map((block) => { - // Use destructuring to remove cachePoint property - const { cachePoint: _, ...rest } = block - return rest - }) - } - - return content - } - - /************************************************************************************ - * - * NATIVE TOOLS + * Apply cachePoint providerOptions to the correct AI SDK messages by walking + * the original Anthropic messages and converted AI SDK messages in parallel. * - *************************************************************************************/ - - /** - * Convert OpenAI tool definitions to Bedrock Converse format - * Transforms JSON Schema to draft 2020-12 compliant format required by Claude models. - * @param tools Array of OpenAI ChatCompletionTool definitions - * @returns Array of Bedrock Tool definitions + * convertToAiSdkMessages() can split a single Anthropic user message (containing + * tool_results + text) into 2 AI SDK messages (tool role + user role). This method + * accounts for that split so cache points land on the right message. */ - private convertToolsForBedrock(tools: OpenAI.Chat.ChatCompletionTool[]): Tool[] { - return tools - .filter((tool) => tool.type === "function") - .map( - (tool) => - ({ - toolSpec: { - name: tool.function.name, - description: tool.function.description, - inputSchema: { - // Normalize schema to JSON Schema draft 2020-12 compliant format - // This converts type: ["T", "null"] to anyOf: [{type: "T"}, {type: "null"}] - json: normalizeToolSchema(tool.function.parameters as Record), - }, - }, - }) as Tool, - ) - } - - /** - * Convert OpenAI tool_choice to Bedrock ToolChoice format - * @param toolChoice OpenAI tool_choice parameter - * @returns Bedrock ToolChoice configuration - */ - private convertToolChoiceForBedrock( - toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"], - ): ToolChoice | undefined { - if (!toolChoice) { - // Default to auto - model decides whether to use tools - return { auto: {} } as ToolChoice - } - - if (typeof toolChoice === "string") { - switch (toolChoice) { - case "none": - return undefined // Bedrock doesn't have "none", just omit tools - case "auto": - return { auto: {} } as ToolChoice - case "required": - return { any: {} } as ToolChoice // Model must use at least one tool - default: - return { auto: {} } as ToolChoice + private applyCachePointsToAiSdkMessages( + originalMessages: Anthropic.Messages.MessageParam[], + aiSdkMessages: { role: string; providerOptions?: Record> }[], + targetOriginalIndices: Set, + cachePointOption: Record>, + ): void { + let aiSdkIdx = 0 + for (let origIdx = 0; origIdx < originalMessages.length; origIdx++) { + const origMsg = originalMessages[origIdx] + + if (typeof origMsg.content === "string") { + // Simple string content → 1 AI SDK message + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx++ + } else if (origMsg.role === "user") { + // User message with array content may split into tool + user messages. + const hasToolResults = origMsg.content.some((part) => (part as { type: string }).type === "tool_result") + const hasNonToolContent = origMsg.content.some( + (part) => (part as { type: string }).type === "text" || (part as { type: string }).type === "image", + ) + + if (hasToolResults && hasNonToolContent) { + // Split into tool msg + user msg — cache the user msg (the second one) + const userMsgIdx = aiSdkIdx + 1 + if (targetOriginalIndices.has(origIdx) && userMsgIdx < aiSdkMessages.length) { + aiSdkMessages[userMsgIdx].providerOptions = { + ...aiSdkMessages[userMsgIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx += 2 + } else if (hasToolResults) { + // Only tool results → 1 tool msg + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx++ + } else { + // Only text/image content → 1 user msg + if (targetOriginalIndices.has(origIdx) && aiSdkIdx < aiSdkMessages.length) { + aiSdkMessages[aiSdkIdx].providerOptions = { + ...aiSdkMessages[aiSdkIdx].providerOptions, + ...cachePointOption, + } + } + aiSdkIdx++ + } + } else { + // Assistant message → 1 AI SDK message + aiSdkIdx++ } } - - // Handle object form { type: "function", function: { name: string } } - if (typeof toolChoice === "object" && "function" in toolChoice) { - return { - tool: { - name: toolChoice.function.name, - }, - } as ToolChoice - } - - return { auto: {} } as ToolChoice } /************************************************************************************ @@ -1299,19 +803,15 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH *************************************************************************************/ private static getPrefixForRegion(region: string): string | undefined { - // Use AWS recommended inference profile prefixes - // Array is pre-sorted by pattern length (descending) to ensure more specific patterns match first for (const [regionPattern, inferenceProfile] of AWS_INFERENCE_PROFILE_MAPPING) { if (region.startsWith(regionPattern)) { return inferenceProfile } } - return undefined } private static isSystemInferenceProfile(prefix: string): boolean { - // Check if the prefix is defined in AWS_INFERENCE_PROFILE_MAPPING for (const [_, inferenceProfile] of AWS_INFERENCE_PROFILE_MAPPING) { if (prefix === inferenceProfile) { return true @@ -1322,299 +822,51 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH /************************************************************************************ * - * ERROR HANDLING + * COST CALCULATION * *************************************************************************************/ - /** - * Error type definitions for Bedrock API errors - */ - private static readonly ERROR_TYPES: Record< - string, - { - patterns: string[] // Strings to match in lowercase error message or name - messageTemplate: string // Template with placeholders like {region}, {modelId}, etc. - logLevel: "error" | "warn" | "info" // Log level for this error type - } - > = { - ACCESS_DENIED: { - patterns: ["access", "denied", "permission"], - messageTemplate: `You don't have access to the model specified. - -Please verify: -1. Try cross-region inference if you're using a foundation model -2. If using an ARN, verify the ARN is correct and points to a valid model -3. Your AWS credentials have permission to access this model (check IAM policies) -4. The region in the ARN matches the region where the model is deployed -5. If using a provisioned model, ensure it's active and not in a failed state`, - logLevel: "error", - }, - NOT_FOUND: { - patterns: ["not found", "does not exist"], - messageTemplate: `The specified ARN does not exist or is invalid. Please check: - -1. The ARN format is correct (arn:aws:bedrock:region:account-id:resource-type/resource-name) -2. The model exists in the specified region -3. The account ID in the ARN is correct`, - logLevel: "error", - }, - THROTTLING: { - patterns: [ - "throttl", - "rate", - "limit", - "bedrock is unable to process your request", // Amazon Bedrock specific throttling message - "please wait", - "quota exceeded", - "service unavailable", - "busy", - "overloaded", - "too many requests", - "request limit", - "concurrent requests", - ], - messageTemplate: `Request was throttled or rate limited. Please try: -1. Reducing the frequency of requests -2. If using a provisioned model, check its throughput settings -3. Contact AWS support to request a quota increase if needed - -`, - logLevel: "error", - }, - TOO_MANY_TOKENS: { - patterns: ["too many tokens", "token limit exceeded", "context length", "maximum context length"], - messageTemplate: `"Too many tokens" error detected. -Possible Causes: -1. Input exceeds model's context window limit -2. Rate limiting (too many tokens per minute) -3. Quota exceeded for token usage -4. Other token-related service limitations - -Suggestions: -1. Reduce the size of your input -2. Split your request into smaller chunks -3. Use a model with a larger context window -4. If rate limited, reduce request frequency -5. Check your Amazon Bedrock quotas and limits - -`, - logLevel: "error", - }, - SERVICE_QUOTA_EXCEEDED: { - patterns: ["service quota exceeded", "service quota", "quota exceeded for model"], - messageTemplate: `Service quota exceeded. This error indicates you've reached AWS service limits. - -Please try: -1. Contact AWS support to request a quota increase -2. Reduce request frequency temporarily -3. Check your Amazon Bedrock quotas in the AWS console -4. Consider using a different model or region with available capacity - -`, - logLevel: "error", - }, - MODEL_NOT_READY: { - patterns: ["model not ready", "model is not ready", "provisioned throughput not ready", "model loading"], - messageTemplate: `Model is not ready or still loading. This can happen with: -1. Provisioned throughput models that are still initializing -2. Custom models that are being loaded -3. Models that are temporarily unavailable - -Please try: -1. Wait a few minutes and retry -2. Check the model status in Amazon Bedrock console -3. Verify the model is properly provisioned - -`, - logLevel: "error", - }, - INTERNAL_SERVER_ERROR: { - patterns: ["internal server error", "internal error", "server error", "service error"], - messageTemplate: `Amazon Bedrock internal server error. This is a temporary service issue. - -Please try: -1. Retry the request after a brief delay -2. If the error persists, check AWS service health -3. Contact AWS support if the issue continues - -`, - logLevel: "error", - }, - ON_DEMAND_NOT_SUPPORTED: { - patterns: ["with on-demand throughput isn’t supported."], - messageTemplate: ` -1. Try enabling cross-region inference in settings. -2. Or, create an inference profile and then leverage the "Use custom ARN..." option of the model selector in settings.`, - logLevel: "error", - }, - ABORT: { - patterns: ["aborterror"], // This will match error.name.toLowerCase() for AbortError - messageTemplate: `Request was aborted: The operation timed out or was manually cancelled. Please try again or check your network connection.`, - logLevel: "info", - }, - INVALID_ARN_FORMAT: { - patterns: ["invalid_arn_format:", "invalid arn format"], - messageTemplate: `Invalid ARN format. ARN should follow the pattern: arn:aws:bedrock:region:account-id:resource-type/resource-name`, - logLevel: "error", - }, - VALIDATION_ERROR: { - patterns: [ - "input tag", - "does not match any of the expected tags", - "field required", - "validation", - "invalid parameter", - ], - messageTemplate: `Parameter validation error: {errorMessage} - -This error indicates that the request parameters don't match Amazon Bedrock's expected format. - -Common causes: -1. Extended thinking parameter format is incorrect -2. Model-specific parameters are not supported by this model -3. API parameter structure has changed - -Please check: -- Model supports the requested features (extended thinking, etc.) -- Parameter format matches Amazon Bedrock specification -- Model ID is correct for the requested features`, - logLevel: "error", - }, - // Default/generic error - GENERIC: { - patterns: [], // Empty patterns array means this is the default - messageTemplate: `Unknown Error: {errorMessage}`, - logLevel: "error", - }, - } - - /** - * Determines the error type based on the error message or name - */ - private getErrorType(error: unknown): string { - if (!(error instanceof Error)) { - return "GENERIC" - } - - // Check for HTTP 429 status code (Too Many Requests) - if ((error as any).status === 429 || (error as any).$metadata?.httpStatusCode === 429) { - return "THROTTLING" - } - - // Check for Amazon Bedrock specific throttling exception names - if ((error as any).name === "ThrottlingException" || (error as any).__type === "ThrottlingException") { - return "THROTTLING" - } - - const errorMessage = error.message.toLowerCase() - const errorName = error.name.toLowerCase() - - // Check each error type's patterns in order of specificity (most specific first) - const errorTypeOrder = [ - "SERVICE_QUOTA_EXCEEDED", // Most specific - check before THROTTLING - "MODEL_NOT_READY", - "TOO_MANY_TOKENS", - "INTERNAL_SERVER_ERROR", - "ON_DEMAND_NOT_SUPPORTED", - "NOT_FOUND", - "ACCESS_DENIED", - "THROTTLING", // Less specific - check after more specific patterns - ] - - for (const errorType of errorTypeOrder) { - const definition = AwsBedrockHandler.ERROR_TYPES[errorType] - if (!definition) continue - - // If any pattern matches in either message or name, return this error type - if (definition.patterns.some((pattern) => errorMessage.includes(pattern) || errorName.includes(pattern))) { - return errorType - } - } - - // Default to generic error - return "GENERIC" - } - - /** - * Formats an error message based on the error type and context - */ - private formatErrorMessage(error: unknown, errorType: string, _isStreamContext: boolean): string { - const definition = AwsBedrockHandler.ERROR_TYPES[errorType] || AwsBedrockHandler.ERROR_TYPES.GENERIC - let template = definition.messageTemplate - - // Prepare template variables - const templateVars: Record = {} - - if (error instanceof Error) { - templateVars.errorMessage = error.message - templateVars.errorName = error.name - - const modelConfig = this.getModel() - templateVars.modelId = modelConfig.id - templateVars.contextWindow = String(modelConfig.info.contextWindow || "unknown") - } + private calculateCost({ + inputTokens, + outputTokens, + cacheWriteTokens = 0, + cacheReadTokens = 0, + reasoningTokens = 0, + info, + }: { + inputTokens: number + outputTokens: number + cacheWriteTokens?: number + cacheReadTokens?: number + reasoningTokens?: number + info: ModelInfo + }): number { + const inputPrice = info.inputPrice ?? 0 + const outputPrice = info.outputPrice ?? 0 + const cacheWritesPrice = info.cacheWritesPrice ?? 0 + const cacheReadsPrice = info.cacheReadsPrice ?? 0 - // Add context-specific template variables - const region = - typeof this?.client?.config?.region === "function" - ? this?.client?.config?.region() - : this?.client?.config?.region - templateVars.regionInfo = `(${region})` + const uncachedInputTokens = Math.max(0, inputTokens - cacheWriteTokens - cacheReadTokens) + const billedOutputTokens = outputTokens + reasoningTokens - // Replace template variables - for (const [key, value] of Object.entries(templateVars)) { - template = template.replace(new RegExp(`{${key}}`, "g"), value || "") - } + const cacheWriteCost = cacheWriteTokens > 0 ? cacheWritesPrice * (cacheWriteTokens / 1_000_000) : 0 + const cacheReadCost = cacheReadTokens > 0 ? cacheReadsPrice * (cacheReadTokens / 1_000_000) : 0 + const inputTokensCost = inputPrice * (uncachedInputTokens / 1_000_000) + const outputTokensCost = outputPrice * (billedOutputTokens / 1_000_000) - return template + return inputTokensCost + outputTokensCost + cacheWriteCost + cacheReadCost } - /** - * Handles Bedrock API errors and generates appropriate error messages - * @param error The error that occurred - * @param isStreamContext Whether the error occurred in a streaming context (true) or not (false) - * @returns Error message string for non-streaming context or array of stream chunks for streaming context - */ - private handleBedrockError( - error: unknown, - isStreamContext: boolean, - ): string | Array<{ type: string; text?: string; inputTokens?: number; outputTokens?: number }> { - // Determine error type - const errorType = this.getErrorType(error) - - // Format error message - const errorMessage = this.formatErrorMessage(error, errorType, isStreamContext) - - // Log the error - const definition = AwsBedrockHandler.ERROR_TYPES[errorType] - const logMethod = definition.logLevel - const contextName = isStreamContext ? "createMessage" : "completePrompt" - logger[logMethod](`${errorType} error in ${contextName}`, { - ctx: "bedrock", - customArn: this.options.awsCustomArn, - errorType, - errorMessage: error instanceof Error ? error.message : String(error), - ...(error instanceof Error && error.stack ? { errorStack: error.stack } : {}), - ...(this.client?.config?.region ? { clientRegion: this.client.config.region } : {}), - }) - - // Return appropriate response based on isStreamContext - if (isStreamContext) { - return [ - { type: "text", text: `Error: ${errorMessage}` }, - { type: "usage", inputTokens: 0, outputTokens: 0 }, - ] - } else { - // For non-streaming context, add the expected prefix - return `Bedrock completion error: ${errorMessage}` - } - } + /************************************************************************************ + * + * THINKING SIGNATURE ROUND-TRIP + * + *************************************************************************************/ /** - * Returns the thinking signature captured from the last Bedrock Converse API response. - * Claude models with extended thinking return a cryptographic signature in the - * reasoning content delta, which must be round-tripped back for multi-turn - * conversations with tool use (Anthropic API requirement). + * Returns the thinking signature captured from the last Bedrock response. + * Claude models with extended thinking return a cryptographic signature + * which must be round-tripped back for multi-turn conversations with tool use. */ getThoughtSignature(): string | undefined { return this.lastThoughtSignature @@ -1622,11 +874,13 @@ Please check: /** * Returns any redacted thinking blocks captured from the last Bedrock response. - * Anthropic returns these when safety filters trigger on the model's internal - * reasoning. They contain opaque binary data (base64-encoded) that must be - * passed back verbatim for proper reasoning continuity. + * Anthropic returns these when safety filters trigger on reasoning content. */ getRedactedThinkingBlocks(): Array<{ type: "redacted_thinking"; data: string }> | undefined { return this.lastRedactedThinkingBlocks.length > 0 ? this.lastRedactedThinkingBlocks : undefined } + + override isAiSdkProvider(): boolean { + return true + } } diff --git a/src/api/transform/__tests__/ai-sdk.spec.ts b/src/api/transform/__tests__/ai-sdk.spec.ts index 3c1ca6d87e5..ea4b9a4235e 100644 --- a/src/api/transform/__tests__/ai-sdk.spec.ts +++ b/src/api/transform/__tests__/ai-sdk.spec.ts @@ -349,7 +349,14 @@ describe("AI SDK conversion utilities", () => { expect(result[0]).toEqual({ role: "assistant", content: [ - { type: "reasoning", text: "Deep thought" }, + { + type: "reasoning", + text: "Deep thought", + providerOptions: { + bedrock: { signature: "sig" }, + anthropic: { signature: "sig" }, + }, + }, { type: "text", text: "OK" }, ], }) diff --git a/src/api/transform/__tests__/bedrock-converse-format.spec.ts b/src/api/transform/__tests__/bedrock-converse-format.spec.ts deleted file mode 100644 index f8c3c9f0162..00000000000 --- a/src/api/transform/__tests__/bedrock-converse-format.spec.ts +++ /dev/null @@ -1,694 +0,0 @@ -// npx vitest run src/api/transform/__tests__/bedrock-converse-format.spec.ts - -import { convertToBedrockConverseMessages } from "../bedrock-converse-format" -import { Anthropic } from "@anthropic-ai/sdk" -import { ContentBlock, ToolResultContentBlock } from "@aws-sdk/client-bedrock-runtime" -import { OPENAI_CALL_ID_MAX_LENGTH } from "../../../utils/tool-id" - -describe("convertToBedrockConverseMessages", () => { - it("converts simple text messages correctly", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ] - - const result = convertToBedrockConverseMessages(messages) - - expect(result).toEqual([ - { - role: "user", - content: [{ text: "Hello" }], - }, - { - role: "assistant", - content: [{ text: "Hi there" }], - }, - ]) - }) - - it("converts messages with images correctly", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Look at this image:", - }, - { - type: "image", - source: { - type: "base64", - data: "SGVsbG8=", // "Hello" in base64 - media_type: "image/jpeg" as const, - }, - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - expect(result[0].content).toHaveLength(2) - expect(result[0].content[0]).toEqual({ text: "Look at this image:" }) - - const imageBlock = result[0].content[1] as ContentBlock - if ("image" in imageBlock && imageBlock.image && imageBlock.image.source) { - expect(imageBlock.image.format).toBe("jpeg") - expect(imageBlock.image.source).toBeDefined() - expect(imageBlock.image.source.bytes).toBeDefined() - } else { - expect.fail("Expected image block not found") - } - }) - - it("converts tool use messages correctly (native tools format; default)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "test-id", - name: "read_file", - input: { - path: "test.txt", - }, - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("assistant") - const toolBlock = result[0].content[0] as ContentBlock - if ("toolUse" in toolBlock && toolBlock.toolUse) { - expect(toolBlock.toolUse).toEqual({ - toolUseId: "test-id", - name: "read_file", - input: { path: "test.txt" }, - }) - } else { - expect.fail("Expected tool use block not found") - } - }) - - it("converts tool use messages correctly (native tools format)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "test-id", - name: "read_file", - input: { - path: "test.txt", - }, - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("assistant") - const toolBlock = result[0].content[0] as ContentBlock - if ("toolUse" in toolBlock && toolBlock.toolUse) { - expect(toolBlock.toolUse).toEqual({ - toolUseId: "test-id", - name: "read_file", - input: { path: "test.txt" }, - }) - } else { - expect.fail("Expected tool use block not found") - } - }) - - it("converts tool result messages to native format (default)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "test-id", - content: [{ type: "text", text: "File contents here" }], - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - const resultBlock = result[0].content[0] as ContentBlock - if ("toolResult" in resultBlock && resultBlock.toolResult) { - const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }] - expect(resultBlock.toolResult).toEqual({ - toolUseId: "test-id", - content: expectedContent, - status: "success", - }) - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("converts tool result messages to native format", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "test-id", - content: [{ type: "text", text: "File contents here" }], - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - const resultBlock = result[0].content[0] as ContentBlock - if ("toolResult" in resultBlock && resultBlock.toolResult) { - const expectedContent: ToolResultContentBlock[] = [{ text: "File contents here" }] - expect(resultBlock.toolResult).toEqual({ - toolUseId: "test-id", - content: expectedContent, - status: "success", - }) - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("converts tool result messages with string content to native format (default)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "test-id", - content: "File: test.txt\nLines 1-5:\nHello World", - } as any, // Anthropic types don't allow string content but runtime can have it - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - const resultBlock = result[0].content[0] as ContentBlock - if ("toolResult" in resultBlock && resultBlock.toolResult) { - expect(resultBlock.toolResult).toEqual({ - toolUseId: "test-id", - content: [{ text: "File: test.txt\nLines 1-5:\nHello World" }], - status: "success", - }) - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("converts tool result messages with string content to native format", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "test-id", - content: "File: test.txt\nLines 1-5:\nHello World", - } as any, // Anthropic types don't allow string content but runtime can have it - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - const resultBlock = result[0].content[0] as ContentBlock - if ("toolResult" in resultBlock && resultBlock.toolResult) { - expect(resultBlock.toolResult).toEqual({ - toolUseId: "test-id", - content: [{ text: "File: test.txt\nLines 1-5:\nHello World" }], - status: "success", - }) - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("keeps both tool_use and tool_result in native format by default", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: "call-123", - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "call-123", - content: "File contents here", - } as any, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - // Both should be native toolUse/toolResult blocks - const assistantContent = result[0]?.content?.[0] as ContentBlock - const userContent = result[1]?.content?.[0] as ContentBlock - - expect("toolUse" in assistantContent).toBe(true) - expect("toolResult" in userContent).toBe(true) - expect("text" in assistantContent).toBe(false) - expect("text" in userContent).toBe(false) - }) - - it("handles text content correctly", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Hello world", - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - if (!result[0] || !result[0].content) { - expect.fail("Expected result to have content") - return - } - - expect(result[0].role).toBe("user") - expect(result[0].content).toHaveLength(1) - const textBlock = result[0].content[0] as ContentBlock - expect(textBlock).toEqual({ text: "Hello world" }) - }) - - describe("toolUseId sanitization for Bedrock 64-char limit", () => { - it("truncates toolUseId longer than 64 characters in tool_use blocks", () => { - const longId = "a".repeat(100) - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: longId, - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - const toolBlock = result[0]?.content?.[0] as ContentBlock - - if ("toolUse" in toolBlock && toolBlock.toolUse && toolBlock.toolUse.toolUseId) { - expect(toolBlock.toolUse.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH) - expect(toolBlock.toolUse.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH) - expect(toolBlock.toolUse.toolUseId).toContain("_") - } else { - expect.fail("Expected tool use block not found") - } - }) - - it("truncates toolUseId longer than 64 characters in tool_result blocks with string content", () => { - const longId = "b".repeat(100) - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: longId, - content: "Result content", - } as any, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - const resultBlock = result[0]?.content?.[0] as ContentBlock - - if ("toolResult" in resultBlock && resultBlock.toolResult && resultBlock.toolResult.toolUseId) { - expect(resultBlock.toolResult.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH) - expect(resultBlock.toolResult.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH) - expect(resultBlock.toolResult.toolUseId).toContain("_") - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("truncates toolUseId longer than 64 characters in tool_result blocks with array content", () => { - const longId = "c".repeat(100) - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: longId, - content: [{ type: "text", text: "Result content" }], - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - const resultBlock = result[0]?.content?.[0] as ContentBlock - - if ("toolResult" in resultBlock && resultBlock.toolResult && resultBlock.toolResult.toolUseId) { - expect(resultBlock.toolResult.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH) - expect(resultBlock.toolResult.toolUseId.length).toBe(OPENAI_CALL_ID_MAX_LENGTH) - } else { - expect.fail("Expected tool result block not found") - } - }) - - it("keeps toolUseId unchanged when under 64 characters", () => { - const shortId = "short-id-123" - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: shortId, - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - const toolBlock = result[0]?.content?.[0] as ContentBlock - - if ("toolUse" in toolBlock && toolBlock.toolUse) { - expect(toolBlock.toolUse.toolUseId).toBe(shortId) - } else { - expect.fail("Expected tool use block not found") - } - }) - - it("produces consistent truncated IDs for the same input", () => { - const longId = "d".repeat(100) - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: longId, - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - ] - - const result1 = convertToBedrockConverseMessages(messages) - const result2 = convertToBedrockConverseMessages(messages) - - const toolBlock1 = result1[0]?.content?.[0] as ContentBlock - const toolBlock2 = result2[0]?.content?.[0] as ContentBlock - - if ("toolUse" in toolBlock1 && toolBlock1.toolUse && "toolUse" in toolBlock2 && toolBlock2.toolUse) { - expect(toolBlock1.toolUse.toolUseId).toBe(toolBlock2.toolUse.toolUseId) - } else { - expect.fail("Expected tool use blocks not found") - } - }) - - it("produces different truncated IDs for different long inputs", () => { - const longId1 = "e".repeat(100) - const longId2 = "f".repeat(100) - - const messages1: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "tool_use", id: longId1, name: "read_file", input: {} }], - }, - ] - const messages2: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "tool_use", id: longId2, name: "read_file", input: {} }], - }, - ] - - const result1 = convertToBedrockConverseMessages(messages1) - const result2 = convertToBedrockConverseMessages(messages2) - - const toolBlock1 = result1[0]?.content?.[0] as ContentBlock - const toolBlock2 = result2[0]?.content?.[0] as ContentBlock - - if ("toolUse" in toolBlock1 && toolBlock1.toolUse && "toolUse" in toolBlock2 && toolBlock2.toolUse) { - expect(toolBlock1.toolUse.toolUseId).not.toBe(toolBlock2.toolUse.toolUseId) - } else { - expect.fail("Expected tool use blocks not found") - } - }) - - it("matching tool_use and tool_result IDs are both truncated consistently", () => { - const longId = "g".repeat(100) - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { - type: "tool_use", - id: longId, - name: "read_file", - input: { path: "test.txt" }, - }, - ], - }, - { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: longId, - content: "File contents", - } as any, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - const toolUseBlock = result[0]?.content?.[0] as ContentBlock - const toolResultBlock = result[1]?.content?.[0] as ContentBlock - - if ( - "toolUse" in toolUseBlock && - toolUseBlock.toolUse && - toolUseBlock.toolUse.toolUseId && - "toolResult" in toolResultBlock && - toolResultBlock.toolResult && - toolResultBlock.toolResult.toolUseId - ) { - expect(toolUseBlock.toolUse.toolUseId).toBe(toolResultBlock.toolResult.toolUseId) - expect(toolUseBlock.toolUse.toolUseId.length).toBeLessThanOrEqual(OPENAI_CALL_ID_MAX_LENGTH) - } else { - expect.fail("Expected tool use and result blocks not found") - } - }) - }) - - describe("thinking and reasoning block handling", () => { - it("should convert thinking blocks to reasoningContent format", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "thinking", thinking: "Let me think about this...", signature: "sig-abc123" } as any, - { type: "text", text: "Here is my answer." }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0].role).toBe("assistant") - expect(result[0].content).toHaveLength(2) - - const reasoningBlock = result[0].content![0] as any - expect(reasoningBlock.reasoningContent).toBeDefined() - expect(reasoningBlock.reasoningContent.reasoningText.text).toBe("Let me think about this...") - expect(reasoningBlock.reasoningContent.reasoningText.signature).toBe("sig-abc123") - - const textBlock = result[0].content![1] as any - expect(textBlock.text).toBe("Here is my answer.") - }) - - it("should convert redacted_thinking blocks with data to reasoningContent.redactedContent", () => { - const testData = Buffer.from("encrypted-redacted-content").toString("base64") - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "redacted_thinking", data: testData } as any, { type: "text", text: "Response" }], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(2) - - const redactedBlock = result[0].content![0] as any - expect(redactedBlock.reasoningContent).toBeDefined() - expect(redactedBlock.reasoningContent.redactedContent).toBeInstanceOf(Uint8Array) - // Verify round-trip: decode back and compare - const decoded = Buffer.from(redactedBlock.reasoningContent.redactedContent).toString("utf-8") - expect(decoded).toBe("encrypted-redacted-content") - }) - - it("should skip redacted_thinking blocks without data", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [{ type: "redacted_thinking" } as any, { type: "text", text: "Response" }], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - expect(result).toHaveLength(1) - // Only the text block should remain (redacted_thinking without data is filtered out) - expect(result[0].content).toHaveLength(1) - expect((result[0].content![0] as any).text).toBe("Response") - }) - - it("should skip reasoning blocks (internal Roo Code format)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "reasoning", text: "Internal reasoning" } as any, - { type: "text", text: "Response" }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect((result[0].content![0] as any).text).toBe("Response") - }) - - it("should skip thoughtSignature blocks (Gemini format)", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "text", text: "Response" }, - { type: "thoughtSignature", thoughtSignature: "gemini-sig" } as any, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(1) - expect((result[0].content![0] as any).text).toBe("Response") - }) - - it("should handle full thinking + redacted_thinking + text + tool_use message", () => { - const redactedData = Buffer.from("redacted-binary").toString("base64") - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "assistant", - content: [ - { type: "thinking", thinking: "Deep thought", signature: "sig-xyz" } as any, - { type: "redacted_thinking", data: redactedData } as any, - { type: "text", text: "I'll use a tool." }, - { type: "tool_use", id: "tool-1", name: "read_file", input: { path: "test.txt" } }, - ], - }, - ] - - const result = convertToBedrockConverseMessages(messages) - - expect(result).toHaveLength(1) - expect(result[0].content).toHaveLength(4) - - // thinking → reasoningContent.reasoningText - expect((result[0].content![0] as any).reasoningContent.reasoningText.text).toBe("Deep thought") - expect((result[0].content![0] as any).reasoningContent.reasoningText.signature).toBe("sig-xyz") - - // redacted_thinking → reasoningContent.redactedContent - expect((result[0].content![1] as any).reasoningContent.redactedContent).toBeInstanceOf(Uint8Array) - - // text - expect((result[0].content![2] as any).text).toBe("I'll use a tool.") - - // tool_use → toolUse - expect((result[0].content![3] as any).toolUse.name).toBe("read_file") - }) - }) -}) diff --git a/src/api/transform/ai-sdk.ts b/src/api/transform/ai-sdk.ts index 9b48ee57f79..c673fad3d27 100644 --- a/src/api/transform/ai-sdk.ts +++ b/src/api/transform/ai-sdk.ts @@ -139,6 +139,11 @@ export function convertToAiSdkMessages( providerOptions?: Record> }> = [] + // Capture thinking signature for Anthropic-protocol providers (Bedrock, Anthropic). + // Task.ts stores thinking blocks as { type: "thinking", thinking: "...", signature: "..." }. + // The signature must be passed back via providerOptions on reasoning parts. + let thinkingSignature: string | undefined + // Extract thoughtSignature from content blocks (Gemini 3 thought signature round-tripping). // Task.ts stores these as { type: "thoughtSignature", thoughtSignature: "..." } blocks. let thoughtSignature: string | undefined @@ -196,16 +201,20 @@ export function convertToAiSdkMessages( if ((part as unknown as { type?: string }).type === "thinking") { if (reasoningContent) continue - const thinking = (part as unknown as { thinking?: string }).thinking - if (typeof thinking === "string" && thinking.length > 0) { - reasoningParts.push(thinking) + const thinkingPart = part as unknown as { thinking?: string; signature?: string } + if (typeof thinkingPart.thinking === "string" && thinkingPart.thinking.length > 0) { + reasoningParts.push(thinkingPart.thinking) + } + // Capture the signature for round-tripping (Anthropic/Bedrock thinking) + if (thinkingPart.signature) { + thinkingSignature = thinkingPart.signature } continue } } const content: Array< - | { type: "reasoning"; text: string } + | { type: "reasoning"; text: string; providerOptions?: Record> } | { type: "text"; text: string } | { type: "tool-call" @@ -219,7 +228,20 @@ export function convertToAiSdkMessages( if (reasoningContent) { content.push({ type: "reasoning", text: reasoningContent }) } else if (reasoningParts.length > 0) { - content.push({ type: "reasoning", text: reasoningParts.join("") }) + const reasoningPart: (typeof content)[number] = { + type: "reasoning", + text: reasoningParts.join(""), + } + // Attach thinking signature for Anthropic/Bedrock round-tripping. + // The AI SDK's @ai-sdk/amazon-bedrock reads providerOptions.bedrock.signature + // and attaches it to reasoningContent.reasoningText.signature in the Bedrock request. + if (thinkingSignature) { + reasoningPart.providerOptions = { + bedrock: { signature: thinkingSignature }, + anthropic: { signature: thinkingSignature }, + } + } + content.push(reasoningPart) } if (textParts.length > 0) { diff --git a/src/api/transform/bedrock-converse-format.ts b/src/api/transform/bedrock-converse-format.ts deleted file mode 100644 index 1a77513f439..00000000000 --- a/src/api/transform/bedrock-converse-format.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { ConversationRole, Message, ContentBlock } from "@aws-sdk/client-bedrock-runtime" -import { sanitizeOpenAiCallId } from "../../utils/tool-id" - -interface BedrockMessageContent { - type: "text" | "image" | "video" | "tool_use" | "tool_result" - text?: string - source?: { - type: "base64" - data: string | Uint8Array // string for Anthropic, Uint8Array for Bedrock - media_type: "image/jpeg" | "image/png" | "image/gif" | "image/webp" - } - // Video specific fields - format?: string - s3Location?: { - uri: string - bucketOwner?: string - } - // Tool use and result fields - toolUseId?: string - name?: string - input?: any - output?: any // Used for tool_result type -} - -/** - * Convert Anthropic messages to Bedrock Converse format - * @param anthropicMessages Messages in Anthropic format - */ -export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): Message[] { - return anthropicMessages.map((anthropicMessage) => { - // Map Anthropic roles to Bedrock roles - const role: ConversationRole = anthropicMessage.role === "assistant" ? "assistant" : "user" - - if (typeof anthropicMessage.content === "string") { - return { - role, - content: [ - { - text: anthropicMessage.content, - }, - ] as ContentBlock[], - } - } - - // Process complex content types - const content = anthropicMessage.content.map((block) => { - const messageBlock = block as BedrockMessageContent & { - id?: string - tool_use_id?: string - content?: string | Array<{ type: string; text: string }> - output?: string | Array<{ type: string; text: string }> - } - - if (messageBlock.type === "text") { - return { - text: messageBlock.text || "", - } as ContentBlock - } - - if (messageBlock.type === "image" && messageBlock.source) { - // Convert base64 string to byte array if needed - let byteArray: Uint8Array - if (typeof messageBlock.source.data === "string") { - const binaryString = atob(messageBlock.source.data) - byteArray = new Uint8Array(binaryString.length) - for (let i = 0; i < binaryString.length; i++) { - byteArray[i] = binaryString.charCodeAt(i) - } - } else { - byteArray = messageBlock.source.data - } - - // Extract format from media_type (e.g., "image/jpeg" -> "jpeg") - const format = messageBlock.source.media_type.split("/")[1] - if (!["png", "jpeg", "gif", "webp"].includes(format)) { - throw new Error(`Unsupported image format: ${format}`) - } - - return { - image: { - format: format as "png" | "jpeg" | "gif" | "webp", - source: { - bytes: byteArray, - }, - }, - } as ContentBlock - } - - if (messageBlock.type === "tool_use") { - // Native-only: keep input as JSON object for Bedrock's toolUse format - return { - toolUse: { - toolUseId: sanitizeOpenAiCallId(messageBlock.id || ""), - name: messageBlock.name || "", - input: messageBlock.input || {}, - }, - } as ContentBlock - } - - if (messageBlock.type === "tool_result") { - // Handle content field - can be string or array (native tool format) - if (messageBlock.content) { - // Content is a string - if (typeof messageBlock.content === "string") { - return { - toolResult: { - toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""), - content: [ - { - text: messageBlock.content, - }, - ], - status: "success", - }, - } as ContentBlock - } - // Content is an array of content blocks - if (Array.isArray(messageBlock.content)) { - return { - toolResult: { - toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""), - content: messageBlock.content.map((item) => ({ - text: typeof item === "string" ? item : item.text || String(item), - })), - status: "success", - }, - } as ContentBlock - } - } - - // Fall back to output handling if content is not available - if (messageBlock.output && typeof messageBlock.output === "string") { - return { - toolResult: { - toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""), - content: [ - { - text: messageBlock.output, - }, - ], - status: "success", - }, - } as ContentBlock - } - // Handle array of content blocks if output is an array - if (Array.isArray(messageBlock.output)) { - return { - toolResult: { - toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""), - content: messageBlock.output.map((part) => { - if (typeof part === "object" && "text" in part) { - return { text: part.text } - } - // Skip images in tool results as they're handled separately - if (typeof part === "object" && "type" in part && part.type === "image") { - return { text: "(see following message for image)" } - } - return { text: String(part) } - }), - status: "success", - }, - } as ContentBlock - } - - // Default case - return { - toolResult: { - toolUseId: sanitizeOpenAiCallId(messageBlock.tool_use_id || ""), - content: [ - { - text: String(messageBlock.output || ""), - }, - ], - status: "success", - }, - } as ContentBlock - } - - if (messageBlock.type === "video") { - const videoContent = messageBlock.s3Location - ? { - s3Location: { - uri: messageBlock.s3Location.uri, - bucketOwner: messageBlock.s3Location.bucketOwner, - }, - } - : messageBlock.source - - return { - video: { - format: "mp4", // Default to mp4, adjust based on actual format if needed - source: videoContent, - }, - } as ContentBlock - } - - // Handle Anthropic thinking blocks (stored by Task.ts for extended thinking) - // Convert to Bedrock Converse API's reasoningContent format - const blockAny = block as { type: string; thinking?: string; signature?: string } - if (blockAny.type === "thinking" && blockAny.thinking) { - return { - reasoningContent: { - reasoningText: { - text: blockAny.thinking, - signature: blockAny.signature, - }, - }, - } as ContentBlock - } - - // Handle redacted thinking blocks (Anthropic sends these when content is filtered). - // Convert base64-encoded data back to Uint8Array for Bedrock Converse API's - // reasoningContent.redactedContent format. - if (blockAny.type === "redacted_thinking" && (blockAny as unknown as { data?: string }).data) { - const base64Data = (blockAny as unknown as { data: string }).data - const binaryData = Buffer.from(base64Data, "base64") - return { - reasoningContent: { - redactedContent: new Uint8Array(binaryData), - }, - } as ContentBlock - } - - // Skip redacted_thinking blocks without data (shouldn't happen, but be safe) - if (blockAny.type === "redacted_thinking") { - return undefined as unknown as ContentBlock - } - - // Skip reasoning blocks (internal Roo Code format, not for the API) - if (blockAny.type === "reasoning" || blockAny.type === "thoughtSignature") { - return undefined as unknown as ContentBlock - } - - // Default case for unknown block types - return { - text: "[Unknown Block Type]", - } as ContentBlock - }) - - // Filter out undefined entries (from skipped block types like redacted_thinking, reasoning) - const filteredContent = content.filter((block): block is ContentBlock => block != null) - - return { - role, - content: filteredContent, - } - }) -} diff --git a/src/api/transform/cache-strategy/__tests__/cache-strategy.spec.ts b/src/api/transform/cache-strategy/__tests__/cache-strategy.spec.ts deleted file mode 100644 index 1e702d88a0b..00000000000 --- a/src/api/transform/cache-strategy/__tests__/cache-strategy.spec.ts +++ /dev/null @@ -1,1112 +0,0 @@ -import { ContentBlock, SystemContentBlock, BedrockRuntimeClient } from "@aws-sdk/client-bedrock-runtime" -import { Anthropic } from "@anthropic-ai/sdk" - -import { MultiPointStrategy } from "../multi-point-strategy" -import { CacheStrategyConfig, ModelInfo, CachePointPlacement } from "../types" -import { AwsBedrockHandler } from "../../../providers/bedrock" - -// Common test utilities -const defaultModelInfo: ModelInfo = { - maxTokens: 8192, - contextWindow: 200_000, - supportsPromptCache: true, - maxCachePoints: 4, - minTokensPerCachePoint: 50, - cachableFields: ["system", "messages", "tools"], -} - -const createConfig = (overrides: Partial = {}): CacheStrategyConfig => ({ - modelInfo: { - ...defaultModelInfo, - ...(overrides.modelInfo || {}), - }, - systemPrompt: "You are a helpful assistant", - messages: [], - usePromptCache: true, - ...overrides, -}) - -const createMessageWithTokens = (role: "user" | "assistant", tokenCount: number) => ({ - role, - content: "x".repeat(tokenCount * 4), // Approximate 4 chars per token -}) - -const hasCachePoint = (block: ContentBlock | SystemContentBlock): boolean => { - return ( - "cachePoint" in block && - typeof block.cachePoint === "object" && - block.cachePoint !== null && - "type" in block.cachePoint && - block.cachePoint.type === "default" - ) -} - -// Create a mock object to store the last config passed to convertToBedrockConverseMessages -interface CacheConfig { - modelInfo: any - systemPrompt?: string - messages: any[] - usePromptCache: boolean -} - -const convertToBedrockConverseMessagesMock = { - lastConfig: null as CacheConfig | null, - result: null as any, -} - -describe("Cache Strategy", () => { - // SECTION 1: Direct Strategy Implementation Tests - describe("Strategy Implementation", () => { - describe("Strategy Selection", () => { - it("should use MultiPointStrategy when caching is not supported", () => { - const config = createConfig({ - modelInfo: { ...defaultModelInfo, supportsPromptCache: false }, - }) - - const strategy = new MultiPointStrategy(config) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - }) - - it("should use MultiPointStrategy when caching is disabled", () => { - const config = createConfig({ usePromptCache: false }) - - const strategy = new MultiPointStrategy(config) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - }) - - it("should use MultiPointStrategy when maxCachePoints is 1", () => { - const config = createConfig({ - modelInfo: { ...defaultModelInfo, maxCachePoints: 1 }, - }) - - const strategy = new MultiPointStrategy(config) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - }) - - it("should use MultiPointStrategy for multi-point cases", () => { - // Setup: Using multiple messages to test multi-point strategy - const config = createConfig({ - messages: [createMessageWithTokens("user", 50), createMessageWithTokens("assistant", 50)], - modelInfo: { - ...defaultModelInfo, - maxCachePoints: 4, - minTokensPerCachePoint: 50, - }, - }) - - const strategy = new MultiPointStrategy(config) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - }) - }) - - describe("Message Formatting with Cache Points", () => { - it("converts simple text messages correctly", () => { - const config = createConfig({ - messages: [ - { role: "user", content: "Hello" }, - { role: "assistant", content: "Hi there" }, - ], - systemPrompt: "", - modelInfo: { ...defaultModelInfo, supportsPromptCache: false }, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - expect(result.messages).toEqual([ - { - role: "user", - content: [{ text: "Hello" }], - }, - { - role: "assistant", - content: [{ text: "Hi there" }], - }, - ]) - }) - - describe("system cache block insertion", () => { - it("adds system cache block when prompt caching is enabled, messages exist, and system prompt is long enough", () => { - // Create a system prompt that's at least 50 tokens (200+ characters) - const longSystemPrompt = - "You are a helpful assistant that provides detailed and accurate information. " + - "You should always be polite, respectful, and considerate of the user's needs. " + - "When answering questions, try to provide comprehensive explanations that are easy to understand. " + - "If you don't know something, be honest about it rather than making up information." - - const config = createConfig({ - messages: [{ role: "user", content: "Hello" }], - systemPrompt: longSystemPrompt, - modelInfo: { - ...defaultModelInfo, - supportsPromptCache: true, - cachableFields: ["system", "messages", "tools"], - }, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Check that system blocks include both the text and a cache block - expect(result.system).toHaveLength(2) - expect(result.system[0]).toEqual({ text: longSystemPrompt }) - expect(hasCachePoint(result.system[1])).toBe(true) - }) - - it("adds system cache block when model info specifies it should", () => { - const shortSystemPrompt = "You are a helpful assistant" - - const config = createConfig({ - messages: [{ role: "user", content: "Hello" }], - systemPrompt: shortSystemPrompt, - modelInfo: { - ...defaultModelInfo, - supportsPromptCache: true, - minTokensPerCachePoint: 1, // Set to 1 to ensure it passes the threshold - cachableFields: ["system", "messages", "tools"], - }, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Check that system blocks include both the text and a cache block - expect(result.system).toHaveLength(2) - expect(result.system[0]).toEqual({ text: shortSystemPrompt }) - expect(hasCachePoint(result.system[1])).toBe(true) - }) - - it("does not add system cache block when system prompt is too short", () => { - const shortSystemPrompt = "You are a helpful assistant" - - const config = createConfig({ - messages: [{ role: "user", content: "Hello" }], - systemPrompt: shortSystemPrompt, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Check that system blocks only include the text, no cache block - expect(result.system).toHaveLength(1) - expect(result.system[0]).toEqual({ text: shortSystemPrompt }) - }) - - it("does not add cache blocks when messages array is empty even if prompt caching is enabled", () => { - const config = createConfig({ - messages: [], - systemPrompt: "You are a helpful assistant", - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Check that system blocks only include the text, no cache block - expect(result.system).toHaveLength(1) - expect(result.system[0]).toEqual({ text: "You are a helpful assistant" }) - - // Verify no messages or cache blocks were added - expect(result.messages).toHaveLength(0) - }) - - it("does not add system cache block when prompt caching is disabled", () => { - const config = createConfig({ - messages: [{ role: "user", content: "Hello" }], - systemPrompt: "You are a helpful assistant", - usePromptCache: false, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Check that system blocks only include the text - expect(result.system).toHaveLength(1) - expect(result.system[0]).toEqual({ text: "You are a helpful assistant" }) - }) - - it("does not insert message cache blocks when prompt caching is disabled", () => { - // Create a long conversation that would trigger cache blocks if enabled - const messages: Anthropic.Messages.MessageParam[] = Array(10) - .fill(null) - .map((_, i) => ({ - role: i % 2 === 0 ? "user" : "assistant", - content: - "This is message " + - (i + 1) + - " with some additional text to increase token count. " + - "Adding more text to ensure we exceed the token threshold for cache block insertion.", - })) - - const config = createConfig({ - messages, - systemPrompt: "", - usePromptCache: false, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Verify no cache blocks were inserted - expect(result.messages).toHaveLength(10) - result.messages.forEach((message) => { - if (message.content) { - message.content.forEach((block) => { - expect(hasCachePoint(block)).toBe(false) - }) - } - }) - }) - }) - }) - }) - - // SECTION 2: AwsBedrockHandler Integration Tests - describe("AwsBedrockHandler Integration", () => { - let handler: AwsBedrockHandler - - const mockMessages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello", - }, - { - role: "assistant", - content: "Hi there!", - }, - ] - - const systemPrompt = "You are a helpful assistant" - - beforeEach(() => { - // Clear all mocks before each test - vitest.clearAllMocks() - - // Create a handler with prompt cache enabled and a model that supports it - handler = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", // This model supports prompt cache - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - awsUsePromptCache: true, - }) - - // Mock the getModel method to return a model with cachableFields and multi-point support - vitest.spyOn(handler, "getModel").mockReturnValue({ - id: "anthropic.claude-3-7-sonnet-20250219-v1:0", - info: { - maxTokens: 8192, - contextWindow: 200000, - supportsPromptCache: true, - supportsImages: true, - cachableFields: ["system", "messages"], - maxCachePoints: 4, // Support for multiple cache points - minTokensPerCachePoint: 50, - }, - }) - - // Mock the client.send method - const mockInvoke = vitest.fn().mockResolvedValue({ - stream: { - [Symbol.asyncIterator]: async function* () { - yield { - metadata: { - usage: { - inputTokens: 10, - outputTokens: 5, - }, - }, - } - }, - }, - }) - - handler["client"] = { - send: mockInvoke, - config: { region: "us-east-1" }, - } as unknown as BedrockRuntimeClient - - // Mock the convertToBedrockConverseMessages method to capture the config - vitest.spyOn(handler as any, "convertToBedrockConverseMessages").mockImplementation(function ( - ...args: any[] - ) { - const messages = args[0] - const systemMessage = args[1] - const usePromptCache = args[2] - const modelInfo = args[3] - - // Store the config for later inspection - const config: CacheConfig = { - modelInfo, - systemPrompt: systemMessage, - messages, - usePromptCache, - } - convertToBedrockConverseMessagesMock.lastConfig = config - - // Create a strategy based on the config - let strategy - // Use MultiPointStrategy for all cases - strategy = new MultiPointStrategy(config as any) - - // Store the result - const result = strategy.determineOptimalCachePoints() - convertToBedrockConverseMessagesMock.result = result - - return result - }) - }) - - it("should select MultiPointStrategy when conditions are met", async () => { - // Reset the mock - convertToBedrockConverseMessagesMock.lastConfig = null - - // Call the method that uses convertToBedrockConverseMessages - const stream = handler.createMessage(systemPrompt, mockMessages) - for await (const _chunk of stream) { - // Just consume the stream - } - - // Verify that convertToBedrockConverseMessages was called with the right parameters - expect(convertToBedrockConverseMessagesMock.lastConfig).toMatchObject({ - modelInfo: expect.objectContaining({ - supportsPromptCache: true, - maxCachePoints: 4, - }), - usePromptCache: true, - }) - - // Verify that the config would result in a MultiPointStrategy - expect(convertToBedrockConverseMessagesMock.lastConfig).not.toBeNull() - if (convertToBedrockConverseMessagesMock.lastConfig) { - const strategy = new MultiPointStrategy(convertToBedrockConverseMessagesMock.lastConfig as any) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - } - }) - - it("should use MultiPointStrategy when maxCachePoints is 1", async () => { - // Mock the getModel method to return a model with only single-point support - vitest.spyOn(handler, "getModel").mockReturnValue({ - id: "anthropic.claude-3-7-sonnet-20250219-v1:0", - info: { - maxTokens: 8192, - contextWindow: 200000, - supportsPromptCache: true, - supportsImages: true, - cachableFields: ["system"], - maxCachePoints: 1, // Only supports one cache point - minTokensPerCachePoint: 50, - }, - }) - - // Reset the mock - convertToBedrockConverseMessagesMock.lastConfig = null - - // Call the method that uses convertToBedrockConverseMessages - const stream = handler.createMessage(systemPrompt, mockMessages) - for await (const _chunk of stream) { - // Just consume the stream - } - - // Verify that convertToBedrockConverseMessages was called with the right parameters - expect(convertToBedrockConverseMessagesMock.lastConfig).toMatchObject({ - modelInfo: expect.objectContaining({ - supportsPromptCache: true, - maxCachePoints: 1, - }), - usePromptCache: true, - }) - - // Verify that the config would result in a MultiPointStrategy - expect(convertToBedrockConverseMessagesMock.lastConfig).not.toBeNull() - if (convertToBedrockConverseMessagesMock.lastConfig) { - const strategy = new MultiPointStrategy(convertToBedrockConverseMessagesMock.lastConfig as any) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - } - }) - - it("should use MultiPointStrategy when prompt cache is disabled", async () => { - // Create a handler with prompt cache disabled - handler = new AwsBedrockHandler({ - apiModelId: "anthropic.claude-3-7-sonnet-20250219-v1:0", - awsAccessKey: "test-access-key", - awsSecretKey: "test-secret-key", - awsRegion: "us-east-1", - awsUsePromptCache: false, // Prompt cache disabled - }) - - // Mock the getModel method - vitest.spyOn(handler, "getModel").mockReturnValue({ - id: "anthropic.claude-3-7-sonnet-20250219-v1:0", - info: { - maxTokens: 8192, - contextWindow: 200000, - supportsPromptCache: true, - supportsImages: true, - cachableFields: ["system", "messages"], - maxCachePoints: 4, - minTokensPerCachePoint: 50, - }, - }) - - // Mock the client.send method - const mockInvoke = vitest.fn().mockResolvedValue({ - stream: { - [Symbol.asyncIterator]: async function* () { - yield { - metadata: { - usage: { - inputTokens: 10, - outputTokens: 5, - }, - }, - } - }, - }, - }) - - handler["client"] = { - send: mockInvoke, - config: { region: "us-east-1" }, - } as unknown as BedrockRuntimeClient - - // Mock the convertToBedrockConverseMessages method again for the new handler - vitest.spyOn(handler as any, "convertToBedrockConverseMessages").mockImplementation(function ( - ...args: any[] - ) { - const messages = args[0] - const systemMessage = args[1] - const usePromptCache = args[2] - const modelInfo = args[3] - - // Store the config for later inspection - const config: CacheConfig = { - modelInfo, - systemPrompt: systemMessage, - messages, - usePromptCache, - } - convertToBedrockConverseMessagesMock.lastConfig = config - - // Create a strategy based on the config - let strategy - // Use MultiPointStrategy for all cases - strategy = new MultiPointStrategy(config as any) - - // Store the result - const result = strategy.determineOptimalCachePoints() - convertToBedrockConverseMessagesMock.result = result - - return result - }) - - // Reset the mock - convertToBedrockConverseMessagesMock.lastConfig = null - - // Call the method that uses convertToBedrockConverseMessages - const stream = handler.createMessage(systemPrompt, mockMessages) - for await (const _chunk of stream) { - // Just consume the stream - } - - // Verify that convertToBedrockConverseMessages was called with the right parameters - expect(convertToBedrockConverseMessagesMock.lastConfig).toMatchObject({ - usePromptCache: false, - }) - - // Verify that the config would result in a MultiPointStrategy - expect(convertToBedrockConverseMessagesMock.lastConfig).not.toBeNull() - if (convertToBedrockConverseMessagesMock.lastConfig) { - const strategy = new MultiPointStrategy(convertToBedrockConverseMessagesMock.lastConfig as any) - expect(strategy).toBeInstanceOf(MultiPointStrategy) - } - }) - - it("should include cachePoint nodes in API request when using MultiPointStrategy", async () => { - // Mock the convertToBedrockConverseMessages method to return a result with cache points - ;(handler as any).convertToBedrockConverseMessages.mockReturnValueOnce({ - system: [{ text: systemPrompt }, { cachePoint: { type: "default" } }], - messages: mockMessages.map((msg: any) => ({ - role: msg.role, - content: [{ text: typeof msg.content === "string" ? msg.content : msg.content[0].text }], - })), - }) - - // Create a spy for the client.send method - const mockSend = vitest.fn().mockResolvedValue({ - stream: { - [Symbol.asyncIterator]: async function* () { - yield { - metadata: { - usage: { - inputTokens: 10, - outputTokens: 5, - }, - }, - } - }, - }, - }) - - handler["client"] = { - send: mockSend, - config: { region: "us-east-1" }, - } as unknown as BedrockRuntimeClient - - // Call the method that uses convertToBedrockConverseMessages - const stream = handler.createMessage(systemPrompt, mockMessages) - for await (const _chunk of stream) { - // Just consume the stream - } - - // Verify that the API request included system with cachePoint - expect(mockSend).toHaveBeenCalledWith( - expect.objectContaining({ - input: expect.objectContaining({ - system: expect.arrayContaining([ - expect.objectContaining({ - text: systemPrompt, - }), - expect.objectContaining({ - cachePoint: expect.anything(), - }), - ]), - }), - }), - expect.anything(), - ) - }) - - it("should yield usage results with cache tokens when using MultiPointStrategy", async () => { - // Mock the convertToBedrockConverseMessages method to return a result with cache points - ;(handler as any).convertToBedrockConverseMessages.mockReturnValueOnce({ - system: [{ text: systemPrompt }, { cachePoint: { type: "default" } }], - messages: mockMessages.map((msg: any) => ({ - role: msg.role, - content: [{ text: typeof msg.content === "string" ? msg.content : msg.content[0].text }], - })), - }) - - // Create a mock stream that includes cache token fields - const mockApiResponse = { - metadata: { - usage: { - inputTokens: 10, - outputTokens: 5, - cacheReadInputTokens: 5, - cacheWriteInputTokens: 10, - }, - }, - } - - const mockStream = { - [Symbol.asyncIterator]: async function* () { - yield mockApiResponse - }, - } - - const mockSend = vitest.fn().mockImplementation(() => { - return Promise.resolve({ - stream: mockStream, - }) - }) - - handler["client"] = { - send: mockSend, - config: { region: "us-east-1" }, - } as unknown as BedrockRuntimeClient - - // Call the method that uses convertToBedrockConverseMessages - const stream = handler.createMessage(systemPrompt, mockMessages) - const chunks = [] - - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Verify that usage results with cache tokens are yielded - expect(chunks.length).toBeGreaterThan(0) - // The test already expects cache tokens, but the implementation might not be including them - // Let's make the test more flexible to accept either format - expect(chunks[0]).toMatchObject({ - type: "usage", - inputTokens: 10, - outputTokens: 5, - }) - }) - }) - - // SECTION 3: Multi-Point Strategy Cache Point Placement Tests - describe("Multi-Point Strategy Cache Point Placement", () => { - // These tests match the examples in the cache-strategy-documentation.md file - - // Common model info for all tests - const multiPointModelInfo: ModelInfo = { - maxTokens: 4096, - contextWindow: 200000, - supportsPromptCache: true, - maxCachePoints: 3, - minTokensPerCachePoint: 50, // Lower threshold to ensure tests pass - cachableFields: ["system", "messages"], - } - - // Helper function to create a message with approximate token count - const createMessage = (role: "user" | "assistant", content: string, tokenCount: number) => { - // Pad the content to reach the desired token count (approx 4 chars per token) - const paddingNeeded = Math.max(0, tokenCount * 4 - content.length) - const padding = " ".repeat(paddingNeeded) - return { - role, - content: content + padding, - } - } - - // Helper to log cache point placements for debugging - const logPlacements = (placements: any[]) => { - console.log( - "Cache point placements:", - placements.map((p) => `index: ${p.index}, tokens: ${p.tokensCovered}`), - ) - } - - describe("Example 1: Initial Cache Point Placement", () => { - it("should place a cache point after the second user message", () => { - // Create messages matching Example 1 from documentation - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", // ~10 tokens - messages, - usePromptCache: true, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Log placements for debugging - if (result.messageCachePointPlacements) { - logPlacements(result.messageCachePointPlacements) - } - - // Verify cache point placements - expect(result.messageCachePointPlacements).toBeDefined() - expect(result.messageCachePointPlacements?.length).toBeGreaterThan(0) - - // First cache point should be after a user message - const firstPlacement = result.messageCachePointPlacements?.[0] - expect(firstPlacement).toBeDefined() - expect(firstPlacement?.type).toBe("message") - expect(messages[firstPlacement?.index || 0].role).toBe("user") - // Instead of checking for cache points in the messages array, - // we'll verify that the cache point placements array has at least one entry - // This is sufficient since we've already verified that the first placement exists - // and is after a user message - expect(result.messageCachePointPlacements?.length).toBeGreaterThan(0) - }) - }) - - describe("Example 2: Adding One Exchange with Cache Point Preservation", () => { - it("should preserve the previous cache point and add a new one when possible", () => { - // Create messages matching Example 2 from documentation - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - ] - - // Previous cache point placements from Example 1 - const previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, // After the second user message (What about deep learning?) - type: "message", - tokensCovered: 300, - }, - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", // ~10 tokens - messages, - usePromptCache: true, - previousCachePointPlacements, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Log placements for debugging - if (result.messageCachePointPlacements) { - logPlacements(result.messageCachePointPlacements) - } - - // Verify cache point placements - expect(result.messageCachePointPlacements).toBeDefined() - - // First cache point should be preserved from previous - expect(result.messageCachePointPlacements?.[0]).toMatchObject({ - index: 2, // After the second user message - type: "message", - }) - - // Check if we have a second cache point (may not always be added depending on token distribution) - if (result.messageCachePointPlacements && result.messageCachePointPlacements.length > 1) { - // Second cache point should be after a user message - const secondPlacement = result.messageCachePointPlacements[1] - expect(secondPlacement.type).toBe("message") - expect(messages[secondPlacement.index].role).toBe("user") - expect(secondPlacement.index).toBeGreaterThan(2) // Should be after the first cache point - } - }) - }) - - describe("Example 3: Adding Another Exchange with Cache Point Preservation", () => { - it("should preserve previous cache points when possible", () => { - // Create messages matching Example 3 from documentation - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - createMessage("user", "Can you explain backpropagation?", 100), - createMessage("assistant", "Backpropagation is an algorithm used to train neural networks...", 200), - ] - - // Previous cache point placements from Example 2 - const previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, // After the second user message (What about deep learning?) - type: "message", - tokensCovered: 300, - }, - { - index: 4, // After the third user message (How do neural networks work?) - type: "message", - tokensCovered: 300, - }, - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", // ~10 tokens - messages, - usePromptCache: true, - previousCachePointPlacements, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Log placements for debugging - if (result.messageCachePointPlacements) { - logPlacements(result.messageCachePointPlacements) - } - - // Verify cache point placements - expect(result.messageCachePointPlacements).toBeDefined() - - // First cache point should be preserved from previous - expect(result.messageCachePointPlacements?.[0]).toMatchObject({ - index: 2, // After the second user message - type: "message", - }) - - // Check if we have a second cache point preserved - if (result.messageCachePointPlacements && result.messageCachePointPlacements.length > 1) { - // Second cache point should be preserved or at a new position - const secondPlacement = result.messageCachePointPlacements[1] - expect(secondPlacement.type).toBe("message") - expect(messages[secondPlacement.index].role).toBe("user") - } - - // Check if we have a third cache point - if (result.messageCachePointPlacements && result.messageCachePointPlacements.length > 2) { - // Third cache point should be after a user message - const thirdPlacement = result.messageCachePointPlacements[2] - expect(thirdPlacement.type).toBe("message") - expect(messages[thirdPlacement.index].role).toBe("user") - expect(thirdPlacement.index).toBeGreaterThan(result.messageCachePointPlacements[1].index) // Should be after the second cache point - } - }) - }) - - describe("Example 4: Adding a Fourth Exchange with Cache Point Reallocation", () => { - it("should handle cache point reallocation when all points are used", () => { - // Create messages matching Example 4 from documentation - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - createMessage("user", "Can you explain backpropagation?", 100), - createMessage("assistant", "Backpropagation is an algorithm used to train neural networks...", 200), - createMessage("user", "What are some applications of deep learning?", 100), - createMessage("assistant", "Deep learning has many applications including...", 200), - ] - - // Previous cache point placements from Example 3 - const previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, // After the second user message (What about deep learning?) - type: "message", - tokensCovered: 300, - }, - { - index: 4, // After the third user message (How do neural networks work?) - type: "message", - tokensCovered: 300, - }, - { - index: 6, // After the fourth user message (Can you explain backpropagation?) - type: "message", - tokensCovered: 300, - }, - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", // ~10 tokens - messages, - usePromptCache: true, - previousCachePointPlacements, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Log placements for debugging - if (result.messageCachePointPlacements) { - logPlacements(result.messageCachePointPlacements) - } - - // Verify cache point placements - expect(result.messageCachePointPlacements).toBeDefined() - expect(result.messageCachePointPlacements?.length).toBeLessThanOrEqual(3) // Should not exceed max cache points - - // First cache point should be preserved - expect(result.messageCachePointPlacements?.[0]).toMatchObject({ - index: 2, // After the second user message - type: "message", - }) - - // Check that all cache points are at valid user message positions - result.messageCachePointPlacements?.forEach((placement) => { - expect(placement.type).toBe("message") - expect(messages[placement.index].role).toBe("user") - }) - - // Check that cache points are in ascending order by index - for (let i = 1; i < (result.messageCachePointPlacements?.length || 0); i++) { - expect(result.messageCachePointPlacements?.[i].index).toBeGreaterThan( - result.messageCachePointPlacements?.[i - 1].index || 0, - ) - } - - // Check that the last cache point covers the new messages - const lastPlacement = - result.messageCachePointPlacements?.[result.messageCachePointPlacements.length - 1] - expect(lastPlacement?.index).toBeGreaterThanOrEqual(6) // Should be at or after the fourth user message - }) - }) - - describe("Cache Point Optimization", () => { - // Note: This test is skipped because it's meant to verify the documentation is correct, - // but the actual implementation behavior is different. The documentation has been updated - // to match the correct behavior. - it.skip("documentation example 5 verification", () => { - // This test verifies that the documentation for Example 5 is correct - // In Example 5, the third cache point at index 10 should cover 660 tokens - // (260 tokens from messages 7-8 plus 400 tokens from the new messages) - - // Create messages matching Example 5 from documentation - const _messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - createMessage("user", "Can you explain backpropagation?", 100), - createMessage("assistant", "Backpropagation is an algorithm used to train neural networks...", 200), - createMessage("user", "What are some applications of deep learning?", 100), - createMessage("assistant", "Deep learning has many applications including...", 160), - // New messages with 400 tokens total - createMessage("user", "Can you provide a detailed example?", 100), - createMessage("assistant", "Here's a detailed example...", 300), - ] - - // Previous cache point placements from Example 4 - const _previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, // After the second user message - type: "message", - tokensCovered: 240, - }, - { - index: 6, // After the fourth user message - type: "message", - tokensCovered: 440, - }, - { - index: 8, // After the fifth user message - type: "message", - tokensCovered: 260, - }, - ] - - // In the documentation, the algorithm decides to replace the cache point at index 8 - // with a new one at index 10, and the tokensCovered value should be 660 tokens - // (260 tokens from messages 7-8 plus 400 tokens from the new messages) - - // However, the actual implementation may behave differently depending on how - // it calculates token counts and makes decisions about cache point placement - - // The important part is that our fix ensures that when a cache point is created, - // the tokensCovered value represents all tokens from the previous cache point - // to the current cache point, not just the tokens in the new messages - }) - - it("should not combine cache points when new messages have fewer tokens than the smallest combined gap", () => { - // This test verifies that when new messages have fewer tokens than the smallest combined gap, - // the algorithm keeps all existing cache points and doesn't add a new one - - // Create a spy on console.log to capture the actual values - const originalConsoleLog = console.log - const mockConsoleLog = vitest.fn() - console.log = mockConsoleLog - - try { - // Create messages with a small addition at the end - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - createMessage("user", "Can you explain backpropagation?", 100), - createMessage( - "assistant", - "Backpropagation is an algorithm used to train neural networks...", - 200, - ), - // Small addition (only 50 tokens total) - createMessage("user", "Thanks for the explanation.", 20), - createMessage("assistant", "You're welcome!", 30), - ] - - // Previous cache point placements with significant token coverage - const previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, // After the second user message - type: "message", - tokensCovered: 400, // Significant token coverage - }, - { - index: 4, // After the third user message - type: "message", - tokensCovered: 300, // Significant token coverage - }, - { - index: 6, // After the fourth user message - type: "message", - tokensCovered: 300, // Significant token coverage - }, - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", // ~10 tokens - messages, - usePromptCache: true, - previousCachePointPlacements, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Verify cache point placements - expect(result.messageCachePointPlacements).toBeDefined() - - // Should keep all three previous cache points since combining would be inefficient - expect(result.messageCachePointPlacements?.length).toBe(3) - - // All original cache points should be preserved - expect(result.messageCachePointPlacements?.[0].index).toBe(2) - expect(result.messageCachePointPlacements?.[1].index).toBe(4) - expect(result.messageCachePointPlacements?.[2].index).toBe(6) - - // No new cache point should be added for the small addition - } finally { - // Restore original console.log - console.log = originalConsoleLog - } - }) - - it("should make correct decisions based on token counts", () => { - // This test verifies that the algorithm correctly compares token counts - // and makes the right decision about combining cache points - - // Create messages with a variety of token counts - const messages = [ - createMessage("user", "Tell me about machine learning.", 100), - createMessage("assistant", "Machine learning is a field of study...", 200), - createMessage("user", "What about deep learning?", 100), - createMessage("assistant", "Deep learning is a subset of machine learning...", 200), - createMessage("user", "How do neural networks work?", 100), - createMessage("assistant", "Neural networks are composed of layers of nodes...", 200), - createMessage("user", "Can you explain backpropagation?", 100), - createMessage("assistant", "Backpropagation is an algorithm used to train neural networks...", 200), - // New messages - createMessage("user", "Can you provide a detailed example?", 100), - createMessage("assistant", "Here's a detailed example...", 200), - ] - - // Previous cache point placements - const previousCachePointPlacements: CachePointPlacement[] = [ - { - index: 2, - type: "message", - tokensCovered: 400, - }, - { - index: 4, - type: "message", - tokensCovered: 150, - }, - { - index: 6, - type: "message", - tokensCovered: 150, - }, - ] - - const config = createConfig({ - modelInfo: multiPointModelInfo, - systemPrompt: "You are a helpful assistant.", - messages, - usePromptCache: true, - previousCachePointPlacements, - }) - - const strategy = new MultiPointStrategy(config) - const result = strategy.determineOptimalCachePoints() - - // Verify we have cache points - expect(result.messageCachePointPlacements).toBeDefined() - expect(result.messageCachePointPlacements?.length).toBeGreaterThan(0) - }) - }) - }) -}) diff --git a/src/api/transform/cache-strategy/base-strategy.ts b/src/api/transform/cache-strategy/base-strategy.ts deleted file mode 100644 index 1bc05cdb843..00000000000 --- a/src/api/transform/cache-strategy/base-strategy.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { ContentBlock, SystemContentBlock, Message, ConversationRole } from "@aws-sdk/client-bedrock-runtime" -import { CacheStrategyConfig, CacheResult, CachePointPlacement } from "./types" - -export abstract class CacheStrategy { - /** - * Determine optimal cache point placements and return the formatted result - */ - public abstract determineOptimalCachePoints(): CacheResult - - protected config: CacheStrategyConfig - protected systemTokenCount: number = 0 - - constructor(config: CacheStrategyConfig) { - this.config = config - this.initializeMessageGroups() - this.calculateSystemTokens() - } - - /** - * Initialize message groups from the input messages - */ - protected initializeMessageGroups(): void { - if (!this.config.messages.length) return - } - - /** - * Calculate token count for system prompt using a more accurate approach - */ - protected calculateSystemTokens(): void { - if (this.config.systemPrompt) { - const text = this.config.systemPrompt - - // Use a more accurate token estimation than simple character count - // Count words and add overhead for punctuation and special tokens - const words = text.split(/\s+/).filter((word) => word.length > 0) - // Average English word is ~1.3 tokens - let tokenCount = words.length * 1.3 - // Add overhead for punctuation and special characters - tokenCount += (text.match(/[.,!?;:()[\]{}""''`]/g) || []).length * 0.3 - // Add overhead for newlines - tokenCount += (text.match(/\n/g) || []).length * 0.5 - // Add a small overhead for system prompt structure - tokenCount += 5 - - this.systemTokenCount = Math.ceil(tokenCount) - } - } - - /** - * Create a cache point content block - */ - protected createCachePoint(): ContentBlock { - return { cachePoint: { type: "default" } } as unknown as ContentBlock - } - - /** - * Convert messages to content blocks - */ - protected messagesToContentBlocks(messages: Anthropic.Messages.MessageParam[]): Message[] { - return messages.map((message) => { - const role: ConversationRole = message.role === "assistant" ? "assistant" : "user" - - const content: ContentBlock[] = Array.isArray(message.content) - ? message.content.map((block) => { - if (typeof block === "string") { - return { text: block } as unknown as ContentBlock - } - if ("text" in block) { - return { text: block.text } as unknown as ContentBlock - } - // Handle other content types if needed - return { text: "[Unsupported Content]" } as unknown as ContentBlock - }) - : [{ text: message.content } as unknown as ContentBlock] - - return { - role, - content, - } - }) - } - - /** - * Check if a token count meets the minimum threshold for caching - */ - protected meetsMinTokenThreshold(tokenCount: number): boolean { - const minTokens = this.config.modelInfo.minTokensPerCachePoint - if (!minTokens) { - return false - } - return tokenCount >= minTokens - } - - /** - * Estimate token count for a message using a more accurate approach - * This implementation is based on the BaseProvider's countTokens method - * but adapted to work without requiring an instance of BaseProvider - */ - protected estimateTokenCount(message: Anthropic.Messages.MessageParam): number { - // Use a more sophisticated token counting approach - if (!message.content) return 0 - - let totalTokens = 0 - - if (Array.isArray(message.content)) { - for (const block of message.content) { - if (block.type === "text") { - // Use a more accurate token estimation than simple character count - // This is still an approximation but better than character/4 - const text = block.text || "" - if (text.length > 0) { - // Count words and add overhead for punctuation and special tokens - const words = text.split(/\s+/).filter((word) => word.length > 0) - // Average English word is ~1.3 tokens - totalTokens += words.length * 1.3 - // Add overhead for punctuation and special characters - totalTokens += (text.match(/[.,!?;:()[\]{}""''`]/g) || []).length * 0.3 - // Add overhead for newlines - totalTokens += (text.match(/\n/g) || []).length * 0.5 - } - } else if (block.type === "image") { - // For images, use a conservative estimate - totalTokens += 300 - } - } - } else if (typeof message.content === "string") { - const text = message.content - // Count words and add overhead for punctuation and special tokens - const words = text.split(/\s+/).filter((word) => word.length > 0) - // Average English word is ~1.3 tokens - totalTokens += words.length * 1.3 - // Add overhead for punctuation and special characters - totalTokens += (text.match(/[.,!?;:()[\]{}""''`]/g) || []).length * 0.3 - // Add overhead for newlines - totalTokens += (text.match(/\n/g) || []).length * 0.5 - } - - // Add a small overhead for message structure - totalTokens += 10 - - return Math.ceil(totalTokens) - } - - /** - * Apply cache points to content blocks based on placements - */ - protected applyCachePoints(messages: Message[], placements: CachePointPlacement[]): Message[] { - const result: Message[] = [] - for (let i = 0; i < messages.length; i++) { - const placement = placements.find((p) => p.index === i) - - if (placement) { - messages[i].content?.push(this.createCachePoint()) - } - result.push(messages[i]) - } - - return result - } - - /** - * Format the final result with cache points applied - */ - protected formatResult(systemBlocks: SystemContentBlock[] = [], messages: Message[]): CacheResult { - const result = { - system: systemBlocks, - messages, - } - return result - } -} diff --git a/src/api/transform/cache-strategy/multi-point-strategy.ts b/src/api/transform/cache-strategy/multi-point-strategy.ts deleted file mode 100644 index dc82136997c..00000000000 --- a/src/api/transform/cache-strategy/multi-point-strategy.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { SystemContentBlock } from "@aws-sdk/client-bedrock-runtime" -import { CacheStrategy } from "./base-strategy" -import { CacheResult, CachePointPlacement } from "./types" -import { logger } from "../../../utils/logging" - -/** - * Strategy for handling multiple cache points. - * Creates cache points after messages as soon as uncached tokens exceed minimumTokenCount. - */ -export class MultiPointStrategy extends CacheStrategy { - /** - * Determine optimal cache point placements and return the formatted result - */ - public determineOptimalCachePoints(): CacheResult { - // If prompt caching is disabled or no messages, return without cache points - if (!this.config.usePromptCache || this.config.messages.length === 0) { - return this.formatWithoutCachePoints() - } - - const supportsSystemCache = this.config.modelInfo.cachableFields.includes("system") - const supportsMessageCache = this.config.modelInfo.cachableFields.includes("messages") - const minTokensPerPoint = this.config.modelInfo.minTokensPerCachePoint - let remainingCachePoints: number = this.config.modelInfo.maxCachePoints - - // First, determine if we'll use a system cache point - const useSystemCache = - supportsSystemCache && this.config.systemPrompt && this.meetsMinTokenThreshold(this.systemTokenCount) - - // Handle system blocks - let systemBlocks: SystemContentBlock[] = [] - if (this.config.systemPrompt) { - systemBlocks = [{ text: this.config.systemPrompt } as unknown as SystemContentBlock] - if (useSystemCache) { - systemBlocks.push(this.createCachePoint() as unknown as SystemContentBlock) - remainingCachePoints-- - } - } - - // If message caching isn't supported, return with just system caching - if (!supportsMessageCache) { - return this.formatResult(systemBlocks, this.messagesToContentBlocks(this.config.messages)) - } - - const placements = this.determineMessageCachePoints(minTokensPerPoint, remainingCachePoints) - const messages = this.messagesToContentBlocks(this.config.messages) - let cacheResult = this.formatResult(systemBlocks, this.applyCachePoints(messages, placements)) - - // Store the placements for future use (to maintain consistency across consecutive messages) - // This needs to be handled by the caller by passing these placements back in the next call - cacheResult.messageCachePointPlacements = placements - - return cacheResult - } - - /** - * Determine optimal cache point placements for messages - * This method handles both new conversations and growing conversations - * - * @param minTokensPerPoint Minimum tokens required per cache point - * @param remainingCachePoints Number of cache points available - * @returns Array of cache point placements - */ - private determineMessageCachePoints( - minTokensPerPoint: number, - remainingCachePoints: number, - ): CachePointPlacement[] { - if (this.config.messages.length <= 1) { - return [] - } - - const placements: CachePointPlacement[] = [] - const totalMessages = this.config.messages.length - const previousPlacements = this.config.previousCachePointPlacements || [] - - // Special case: If previousPlacements is empty, place initial cache points - if (previousPlacements.length === 0) { - let currentIndex = 0 - - while (currentIndex < totalMessages && remainingCachePoints > 0) { - const newPlacement = this.findOptimalPlacementForRange( - currentIndex, - totalMessages - 1, - minTokensPerPoint, - ) - - if (newPlacement) { - placements.push(newPlacement) - currentIndex = newPlacement.index + 1 - remainingCachePoints-- - } else { - break - } - } - - return placements - } - - // Calculate tokens in new messages (added since last cache point placement) - const lastPreviousIndex = previousPlacements[previousPlacements.length - 1].index - const newMessagesTokens = this.config.messages - .slice(lastPreviousIndex + 1) - .reduce((acc, curr) => acc + this.estimateTokenCount(curr), 0) - - // If new messages have enough tokens for a cache point, we need to decide - // whether to keep all previous cache points or combine some - if (newMessagesTokens >= minTokensPerPoint) { - // If we have enough cache points for all previous placements plus a new one, keep them all - if (remainingCachePoints > previousPlacements.length) { - // Keep all previous placements - for (const placement of previousPlacements) { - if (placement.index < totalMessages) { - placements.push(placement) - } - } - - // Add a new placement for the new messages - const newPlacement = this.findOptimalPlacementForRange( - lastPreviousIndex + 1, - totalMessages - 1, - minTokensPerPoint, - ) - - if (newPlacement) { - placements.push(newPlacement) - } - } else { - // We need to decide which previous cache points to keep and which to combine - // Strategy: Compare the token count of new messages with the smallest combined token gap - - // First, analyze the token distribution between previous cache points - const tokensBetweenPlacements: number[] = [] - let startIdx = 0 - - for (const placement of previousPlacements) { - const tokens = this.config.messages - .slice(startIdx, placement.index + 1) - .reduce((acc, curr) => acc + this.estimateTokenCount(curr), 0) - - tokensBetweenPlacements.push(tokens) - startIdx = placement.index + 1 - } - - // Find the two consecutive placements with the smallest token gap - let smallestGapIndex = 0 - let smallestGap = Number.MAX_VALUE - - for (let i = 0; i < tokensBetweenPlacements.length - 1; i++) { - const gap = tokensBetweenPlacements[i] + tokensBetweenPlacements[i + 1] - if (gap < smallestGap) { - smallestGap = gap - smallestGapIndex = i - } - } - - // Only combine cache points if it's beneficial - // Compare the token count of new messages with the smallest combined token gap - // Apply a required percentage increase to ensure reallocation is worth it - const requiredPercentageIncrease = 1.2 // 20% increase required - const requiredTokenThreshold = smallestGap * requiredPercentageIncrease - - if (newMessagesTokens >= requiredTokenThreshold) { - // It's beneficial to combine cache points since new messages have significantly more tokens - logger.info("Combining cache points is beneficial", { - ctx: "cache-strategy", - newMessagesTokens, - smallestGap, - requiredTokenThreshold, - action: "combining_cache_points", - }) - - // Combine the two placements with the smallest gap - for (let i = 0; i < previousPlacements.length; i++) { - if (i !== smallestGapIndex && i !== smallestGapIndex + 1) { - // Keep this placement - if (previousPlacements[i].index < totalMessages) { - placements.push(previousPlacements[i]) - } - } else if (i === smallestGapIndex) { - // Replace with a combined placement - const combinedEndIndex = previousPlacements[i + 1].index - - // Find the optimal placement within this combined range - const startOfRange = i === 0 ? 0 : previousPlacements[i - 1].index + 1 - const combinedPlacement = this.findOptimalPlacementForRange( - startOfRange, - combinedEndIndex, - minTokensPerPoint, - ) - - if (combinedPlacement) { - placements.push(combinedPlacement) - } - - // Skip the next placement as we've combined it - i++ - } - } - - // If we freed up a cache point, use it for the new messages - if (placements.length < remainingCachePoints) { - const newPlacement = this.findOptimalPlacementForRange( - lastPreviousIndex + 1, - totalMessages - 1, - minTokensPerPoint, - ) - - if (newPlacement) { - placements.push(newPlacement) - } - } - } else { - // It's not beneficial to combine cache points - // Keep all previous placements and don't add a new one for the new messages - logger.info("Combining cache points is not beneficial", { - ctx: "cache-strategy", - newMessagesTokens, - smallestGap, - action: "keeping_existing_cache_points", - }) - - // Keep all previous placements that are still valid - for (const placement of previousPlacements) { - if (placement.index < totalMessages) { - placements.push(placement) - } - } - } - } - - return placements - } else { - // New messages don't have enough tokens for a cache point - // Keep all previous placements that are still valid - for (const placement of previousPlacements) { - if (placement.index < totalMessages) { - placements.push(placement) - } - } - - return placements - } - } - - /** - * Find the optimal placement for a cache point within a specified range of messages - * Simply finds the last user message in the range - */ - private findOptimalPlacementForRange( - startIndex: number, - endIndex: number, - minTokensPerPoint: number, - ): CachePointPlacement | null { - if (startIndex >= endIndex) { - return null - } - - // Find the last user message in the range - let lastUserMessageIndex = -1 - for (let i = endIndex; i >= startIndex; i--) { - if (this.config.messages[i].role === "user") { - lastUserMessageIndex = i - break - } - } - - if (lastUserMessageIndex >= 0) { - // Calculate the total tokens covered from the previous cache point (or start of conversation) - // to this cache point. This ensures tokensCovered represents the full span of tokens - // that will be cached by this cache point. - let totalTokensCovered = 0 - - // Find the previous cache point index - const previousPlacements = this.config.previousCachePointPlacements || [] - let previousCachePointIndex = -1 - - for (const placement of previousPlacements) { - if (placement.index < startIndex && placement.index > previousCachePointIndex) { - previousCachePointIndex = placement.index - } - } - - // Calculate tokens from previous cache point (or start) to this cache point - const tokenStartIndex = previousCachePointIndex + 1 - totalTokensCovered = this.config.messages - .slice(tokenStartIndex, lastUserMessageIndex + 1) - .reduce((acc, curr) => acc + this.estimateTokenCount(curr), 0) - - // Guard clause: ensure we have enough tokens to justify a cache point - if (totalTokensCovered < minTokensPerPoint) { - return null - } - return { - index: lastUserMessageIndex, - type: "message", - tokensCovered: totalTokensCovered, - } - } - - return null - } - - /** - * Format result without cache points - * - * @returns Cache result without cache points - */ - private formatWithoutCachePoints(): CacheResult { - const systemBlocks: SystemContentBlock[] = this.config.systemPrompt - ? [{ text: this.config.systemPrompt } as unknown as SystemContentBlock] - : [] - - return this.formatResult(systemBlocks, this.messagesToContentBlocks(this.config.messages)) - } -} diff --git a/src/api/transform/cache-strategy/types.ts b/src/api/transform/cache-strategy/types.ts deleted file mode 100644 index 2b5d5736c96..00000000000 --- a/src/api/transform/cache-strategy/types.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import { SystemContentBlock, Message } from "@aws-sdk/client-bedrock-runtime" - -/** - * Information about a model's capabilities and constraints - */ -export interface ModelInfo { - /** Maximum number of tokens the model can generate */ - maxTokens: number - /** Maximum context window size in tokens */ - contextWindow: number - /** Whether the model supports prompt caching */ - supportsPromptCache: boolean - /** Maximum number of cache points supported by the model */ - maxCachePoints: number - /** Minimum number of tokens required for a cache point */ - minTokensPerCachePoint: number - /** Fields that can be cached */ - cachableFields: Array<"system" | "messages" | "tools"> -} - -/** - * Cache point definition - */ -export interface CachePoint { - /** Type of cache point */ - type: "default" -} - -/** - * Result of cache strategy application - */ -export interface CacheResult { - /** System content blocks */ - system: SystemContentBlock[] - /** Message content blocks */ - messages: Message[] - /** Cache point placements for messages (for maintaining consistency across consecutive messages) */ - messageCachePointPlacements?: CachePointPlacement[] -} - -/** - * Represents the position and metadata for a cache point - */ -export interface CachePointPlacement { - /** Where to insert the cache point */ - index: number - /** Type of cache point */ - type: "system" | "message" - /** Number of tokens this cache point covers */ - tokensCovered: number -} - -/** - * Configuration for the caching strategy - */ -export interface CacheStrategyConfig { - /** Model information */ - modelInfo: ModelInfo - /** System prompt text */ - systemPrompt?: string - /** Messages to process */ - messages: Anthropic.Messages.MessageParam[] - /** Whether to use prompt caching */ - usePromptCache: boolean - /** Previous cache point placements (for maintaining consistency across consecutive messages) */ - previousCachePointPlacements?: CachePointPlacement[] -} diff --git a/src/package.json b/src/package.json index 3e0201c6412..f2b574c2626 100644 --- a/src/package.json +++ b/src/package.json @@ -450,6 +450,7 @@ "clean": "rimraf README.md CHANGELOG.md LICENSE dist logs mock .turbo" }, "dependencies": { + "@ai-sdk/amazon-bedrock": "^4.0.50", "@ai-sdk/cerebras": "^1.0.0", "@ai-sdk/deepseek": "^2.0.14", "@ai-sdk/fireworks": "^2.0.26", @@ -458,8 +459,6 @@ "@ai-sdk/groq": "^3.0.19", "@ai-sdk/mistral": "^3.0.0", "@ai-sdk/xai": "^3.0.46", - "sambanova-ai-provider": "^1.2.2", - "@anthropic-ai/bedrock-sdk": "^0.10.2", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.7.0", "@aws-sdk/client-bedrock-runtime": "^3.922.0", @@ -518,6 +517,7 @@ "puppeteer-core": "^23.4.0", "reconnecting-eventsource": "^1.6.4", "safe-stable-stringify": "^2.5.0", + "sambanova-ai-provider": "^1.2.2", "sanitize-filename": "^1.6.3", "say": "^0.16.0", "semver-compare": "^1.0.0", From 6d2459c7e84767ee2dae185ed7cc66e317ce23fd Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:51:18 -0500 Subject: [PATCH 09/31] feat: add disabledTools setting to globally disable native tools (#11277) * feat: add disabledTools setting to globally disable native tools Add a disabledTools field to GlobalSettings that allows disabling specific native tools by name. This enables cloud agents to be configured with restricted tool access. Schema: - Add disabledTools: z.array(toolNamesSchema).optional() to globalSettingsSchema - Add disabledTools to organizationDefaultSettingsSchema.pick() - Add disabledTools to ExtensionState Pick type Prompt generation (tool filtering): - Add disabledTools to BuildToolsOptions interface - Pass disabledTools through filterSettings to filterNativeToolsForMode() - Remove disabled tools from allowedToolNames set in filterNativeToolsForMode() Execution-time validation (safety net): - Extract disabledTools from state in presentAssistantMessage - Convert disabledTools to toolRequirements format for validateToolUse() Wiring: - Add disabledTools to ClineProvider getState() and getStateToPostToWebview() - Pass disabledTools to all buildNativeToolsArrayWithRestrictions() call sites EXT-778 * fix: check toolRequirements before ALWAYS_AVAILABLE_TOOLS Moves the toolRequirements check before the ALWAYS_AVAILABLE_TOOLS early-return in isToolAllowedForMode(). This ensures disabledTools can block always-available tools (switch_mode, new_task, etc.) at execution time, making the validation layer consistent with the filtering layer. --- packages/types/src/__tests__/cloud.test.ts | 37 +++++++++ packages/types/src/cloud.ts | 1 + packages/types/src/global-settings.ts | 7 ++ packages/types/src/vscode-extension-host.ts | 1 + .../presentAssistantMessage.ts | 13 ++- .../__tests__/filter-tools-for-mode.spec.ts | 80 +++++++++++++++++++ .../prompts/tools/filter-tools-for-mode.ts | 7 ++ src/core/task/Task.ts | 4 + src/core/task/build-tools.ts | 3 + .../tools/__tests__/validateToolUse.spec.ts | 54 +++++++++++++ src/core/tools/validateToolUse.ts | 24 +++--- src/core/webview/ClineProvider.ts | 3 + 12 files changed, 221 insertions(+), 13 deletions(-) create mode 100644 src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts diff --git a/packages/types/src/__tests__/cloud.test.ts b/packages/types/src/__tests__/cloud.test.ts index 7a6cebd8a51..be8d631ce0a 100644 --- a/packages/types/src/__tests__/cloud.test.ts +++ b/packages/types/src/__tests__/cloud.test.ts @@ -2,10 +2,12 @@ import { organizationCloudSettingsSchema, + organizationDefaultSettingsSchema, organizationFeaturesSchema, organizationSettingsSchema, userSettingsConfigSchema, type OrganizationCloudSettings, + type OrganizationDefaultSettings, type OrganizationFeatures, type OrganizationSettings, type UserSettingsConfig, @@ -481,3 +483,38 @@ describe("userSettingsConfigSchema with llmEnhancedFeaturesEnabled", () => { expect(result.data?.llmEnhancedFeaturesEnabled).toBe(true) }) }) + +describe("organizationDefaultSettingsSchema with disabledTools", () => { + it("should accept disabledTools as an array of valid tool names", () => { + const input: OrganizationDefaultSettings = { + disabledTools: ["execute_command", "browser_action"], + } + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(true) + expect(result.data?.disabledTools).toEqual(["execute_command", "browser_action"]) + }) + + it("should accept empty disabledTools array", () => { + const input: OrganizationDefaultSettings = { + disabledTools: [], + } + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(true) + expect(result.data?.disabledTools).toEqual([]) + }) + + it("should accept omitted disabledTools", () => { + const input: OrganizationDefaultSettings = {} + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(true) + expect(result.data?.disabledTools).toBeUndefined() + }) + + it("should reject invalid tool names in disabledTools", () => { + const input = { + disabledTools: ["not_a_real_tool"], + } + const result = organizationDefaultSettingsSchema.safeParse(input) + expect(result.success).toBe(false) + }) +}) diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 206a5647b3e..2de8ce9168c 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -101,6 +101,7 @@ export const organizationDefaultSettingsSchema = globalSettingsSchema terminalShellIntegrationDisabled: true, terminalShellIntegrationTimeout: true, terminalZshClearEolMark: true, + disabledTools: true, }) // Add stronger validations for some fields. .merge( diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 11b9fe148d1..fce48cfb5d5 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -13,6 +13,7 @@ import { experimentsSchema } from "./experiment.js" import { telemetrySettingsSchema } from "./telemetry.js" import { modeConfigSchema } from "./mode.js" import { customModePromptsSchema, customSupportPromptsSchema } from "./mode.js" +import { toolNamesSchema } from "./tool.js" import { languagesSchema } from "./vscode.js" /** @@ -232,6 +233,12 @@ export const globalSettingsSchema = z.object({ * @default true */ showWorktreesInHomeScreen: z.boolean().optional(), + + /** + * List of native tool names to globally disable. + * Tools in this list will be excluded from prompt generation and rejected at execution time. + */ + disabledTools: z.array(toolNamesSchema).optional(), }) export type GlobalSettings = z.infer diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index fa2f04c0e5d..51c7fa49d5e 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -334,6 +334,7 @@ export type ExtensionState = Pick< | "maxGitStatusFiles" | "requestDelaySeconds" | "showWorktreesInHomeScreen" + | "disabledTools" > & { version: string clineMessages: ClineMessage[] diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index c22c369b42d..c183d51ca53 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -335,7 +335,7 @@ export async function presentAssistantMessage(cline: Task) { // Fetch state early so it's available for toolDescription and validation const state = await cline.providerRef.deref()?.getState() - const { mode, customModes, experiments: stateExperiments } = state ?? {} + const { mode, customModes, experiments: stateExperiments, disabledTools } = state ?? {} const toolDescription = (): string => { switch (block.name) { @@ -625,11 +625,20 @@ export async function presentAssistantMessage(cline: Task) { const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool)) try { + const toolRequirements = + disabledTools?.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) ?? {} + validateToolUse( block.name as ToolName, mode ?? defaultModeSlug, customModes ?? [], - {}, + toolRequirements, block.params, stateExperiments, includedTools, diff --git a/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts new file mode 100644 index 00000000000..8c6d7ede172 --- /dev/null +++ b/src/core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts @@ -0,0 +1,80 @@ +// npx vitest run core/prompts/tools/__tests__/filter-tools-for-mode.spec.ts + +import type OpenAI from "openai" + +import { filterNativeToolsForMode } from "../filter-tools-for-mode" + +function makeTool(name: string): OpenAI.Chat.ChatCompletionTool { + return { + type: "function", + function: { + name, + description: `${name} tool`, + parameters: { type: "object", properties: {} }, + }, + } as OpenAI.Chat.ChatCompletionTool +} + +describe("filterNativeToolsForMode - disabledTools", () => { + const nativeTools: OpenAI.Chat.ChatCompletionTool[] = [ + makeTool("execute_command"), + makeTool("read_file"), + makeTool("write_to_file"), + makeTool("browser_action"), + makeTool("apply_diff"), + ] + + it("removes tools listed in settings.disabledTools", () => { + const settings = { + disabledTools: ["execute_command", "browser_action"], + } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).not.toContain("execute_command") + expect(resultNames).not.toContain("browser_action") + expect(resultNames).toContain("read_file") + expect(resultNames).toContain("write_to_file") + expect(resultNames).toContain("apply_diff") + }) + + it("does not remove any tools when disabledTools is empty", () => { + const settings = { + disabledTools: [], + } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).toContain("execute_command") + expect(resultNames).toContain("read_file") + expect(resultNames).toContain("write_to_file") + expect(resultNames).toContain("browser_action") + expect(resultNames).toContain("apply_diff") + }) + + it("does not remove any tools when disabledTools is undefined", () => { + const settings = {} + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).toContain("execute_command") + expect(resultNames).toContain("read_file") + }) + + it("combines disabledTools with other setting-based exclusions", () => { + const settings = { + browserToolEnabled: false, + disabledTools: ["execute_command"], + } + + const result = filterNativeToolsForMode(nativeTools, "code", undefined, undefined, undefined, settings) + + const resultNames = result.map((t) => (t as any).function.name) + expect(resultNames).not.toContain("execute_command") + expect(resultNames).not.toContain("browser_action") + expect(resultNames).toContain("read_file") + }) +}) diff --git a/src/core/prompts/tools/filter-tools-for-mode.ts b/src/core/prompts/tools/filter-tools-for-mode.ts index 5560fe9bc6d..c034b972d6a 100644 --- a/src/core/prompts/tools/filter-tools-for-mode.ts +++ b/src/core/prompts/tools/filter-tools-for-mode.ts @@ -296,6 +296,13 @@ export function filterNativeToolsForMode( allowedToolNames.delete("browser_action") } + // Remove tools that are explicitly disabled via the disabledTools setting + if (settings?.disabledTools?.length) { + for (const toolName of settings.disabledTools) { + allowedToolNames.delete(toolName) + } + } + // Conditionally exclude access_mcp_resource if MCP is not enabled or there are no resources if (!mcpHub || !hasAnyMcpResources(mcpHub)) { allowedToolNames.delete("access_mcp_resource") diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index d5e9aa0cfb6..f4e41c1bfd7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1787,6 +1787,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -3888,6 +3889,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -4102,6 +4104,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: false, }) @@ -4266,6 +4269,7 @@ export class Task extends EventEmitter implements TaskLike { experiments: state?.experiments, apiConfiguration, browserToolEnabled: state?.browserToolEnabled ?? true, + disabledTools: state?.disabledTools, modelInfo, includeAllToolsWithRestrictions: supportsAllowedFunctionNames, }) diff --git a/src/core/task/build-tools.ts b/src/core/task/build-tools.ts index 0206df71c44..ab74f9443ca 100644 --- a/src/core/task/build-tools.ts +++ b/src/core/task/build-tools.ts @@ -23,6 +23,7 @@ interface BuildToolsOptions { experiments: Record | undefined apiConfiguration: ProviderSettings | undefined browserToolEnabled: boolean + disabledTools?: string[] modelInfo?: ModelInfo /** * If true, returns all tools without mode filtering, but also includes @@ -88,6 +89,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO experiments, apiConfiguration, browserToolEnabled, + disabledTools, modelInfo, includeAllToolsWithRestrictions, } = options @@ -102,6 +104,7 @@ export async function buildNativeToolsArrayWithRestrictions(options: BuildToolsO const filterSettings = { todoListEnabled: apiConfiguration?.todoListEnabled ?? true, browserToolEnabled: browserToolEnabled ?? true, + disabledTools, modelInfo, } diff --git a/src/core/tools/__tests__/validateToolUse.spec.ts b/src/core/tools/__tests__/validateToolUse.spec.ts index 87aa1594208..b4622096ab8 100644 --- a/src/core/tools/__tests__/validateToolUse.spec.ts +++ b/src/core/tools/__tests__/validateToolUse.spec.ts @@ -163,6 +163,15 @@ describe("mode-validator", () => { // Even in code mode which allows all tools, disabled requirement should take precedence expect(isToolAllowedForMode("apply_diff", codeMode, [], requirements)).toBe(false) }) + + it("prioritizes requirements over ALWAYS_AVAILABLE_TOOLS", () => { + // Tools in ALWAYS_AVAILABLE_TOOLS (switch_mode, new_task, etc.) should still + // be blockable via toolRequirements / disabledTools + const requirements = { switch_mode: false, new_task: false, attempt_completion: false } + expect(isToolAllowedForMode("switch_mode", codeMode, [], requirements)).toBe(false) + expect(isToolAllowedForMode("new_task", codeMode, [], requirements)).toBe(false) + expect(isToolAllowedForMode("attempt_completion", codeMode, [], requirements)).toBe(false) + }) }) }) @@ -200,5 +209,50 @@ describe("mode-validator", () => { it("handles undefined requirements gracefully", () => { expect(() => validateToolUse("apply_diff", codeMode, [], undefined)).not.toThrow() }) + + it("blocks tool when disabledTools is converted to toolRequirements", () => { + const disabledTools = ["execute_command", "browser_action"] + const toolRequirements = disabledTools.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) + + expect(() => validateToolUse("execute_command", codeMode, [], toolRequirements)).toThrow( + 'Tool "execute_command" is not allowed in code mode.', + ) + expect(() => validateToolUse("browser_action", codeMode, [], toolRequirements)).toThrow( + 'Tool "browser_action" is not allowed in code mode.', + ) + }) + + it("allows non-disabled tools when disabledTools is converted to toolRequirements", () => { + const disabledTools = ["execute_command"] + const toolRequirements = disabledTools.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) + + expect(() => validateToolUse("read_file", codeMode, [], toolRequirements)).not.toThrow() + expect(() => validateToolUse("write_to_file", codeMode, [], toolRequirements)).not.toThrow() + }) + + it("handles empty disabledTools array converted to toolRequirements", () => { + const disabledTools: string[] = [] + const toolRequirements = disabledTools.reduce( + (acc: Record, tool: string) => { + acc[tool] = false + return acc + }, + {} as Record, + ) + + expect(() => validateToolUse("execute_command", codeMode, [], toolRequirements)).not.toThrow() + }) }) }) diff --git a/src/core/tools/validateToolUse.ts b/src/core/tools/validateToolUse.ts index 3579fde32cf..ab261af722e 100644 --- a/src/core/tools/validateToolUse.ts +++ b/src/core/tools/validateToolUse.ts @@ -126,7 +126,19 @@ export function isToolAllowedForMode( experiments?: Record, includedTools?: string[], // Opt-in tools explicitly included (e.g., from modelInfo) ): boolean { - // Always allow these tools + // Check tool requirements first — explicit disabling takes priority over everything, + // including ALWAYS_AVAILABLE_TOOLS. This ensures disabledTools works consistently + // at both the filtering layer and the execution-time validation layer. + if (toolRequirements && typeof toolRequirements === "object") { + if (tool in toolRequirements && !toolRequirements[tool]) { + return false + } + } else if (toolRequirements === false) { + // If toolRequirements is a boolean false, all tools are disabled + return false + } + + // Always allow these tools (unless explicitly disabled above) if (ALWAYS_AVAILABLE_TOOLS.includes(tool as any)) { return true } @@ -147,16 +159,6 @@ export function isToolAllowedForMode( } } - // Check tool requirements if any exist - if (toolRequirements && typeof toolRequirements === "object") { - if (tool in toolRequirements && !toolRequirements[tool]) { - return false - } - } else if (toolRequirements === false) { - // If toolRequirements is a boolean false, all tools are disabled - return false - } - const mode = getModeBySlug(modeSlug, customModes) if (!mode) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 84cc76825f7..b598a27c272 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2037,6 +2037,7 @@ export class ClineProvider maxOpenTabsContext, maxWorkspaceFiles, browserToolEnabled, + disabledTools, telemetrySetting, showRooIgnoredFiles, enableSubfolderRules, @@ -2174,6 +2175,7 @@ export class ClineProvider maxWorkspaceFiles: maxWorkspaceFiles ?? 200, cwd, browserToolEnabled: browserToolEnabled ?? true, + disabledTools, telemetrySetting, telemetryKey, machineId, @@ -2416,6 +2418,7 @@ export class ClineProvider maxOpenTabsContext: stateValues.maxOpenTabsContext ?? 20, maxWorkspaceFiles: stateValues.maxWorkspaceFiles ?? 200, browserToolEnabled: stateValues.browserToolEnabled ?? true, + disabledTools: stateValues.disabledTools, telemetrySetting: stateValues.telemetrySetting || "unset", showRooIgnoredFiles: stateValues.showRooIgnoredFiles ?? false, enableSubfolderRules: stateValues.enableSubfolderRules ?? false, From 9b39d2242afb84cd9f6cb9a866089da54f3700cf Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Fri, 6 Feb 2026 16:29:49 -0800 Subject: [PATCH 10/31] feat: add IPC query handlers for commands, modes, and models (#11279) Add GetCommands, GetModes, and GetModels to the IPC protocol so external clients can fetch slash commands, available modes, and Roo provider models without going through the internal webview message channel. Co-authored-by: Claude Opus 4.6 --- packages/types/src/events.ts | 37 ++++++++++++++++++++++ packages/types/src/ipc.ts | 12 ++++++++ src/extension/api.ts | 60 ++++++++++++++++++++++++++++++++++-- 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index d4a05f8e3e6..54267d67e4e 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -1,6 +1,7 @@ import { z } from "zod" import { clineMessageSchema, queuedMessageSchema, tokenUsageSchema } from "./message.js" +import { modelInfoSchema } from "./model.js" import { toolNamesSchema, toolUsageSchema } from "./tool.js" /** @@ -45,6 +46,11 @@ export enum RooCodeEventName { ModeChanged = "modeChanged", ProviderProfileChanged = "providerProfileChanged", + // Query Responses + CommandsResponse = "commandsResponse", + ModesResponse = "modesResponse", + ModelsResponse = "modelsResponse", + // Evals EvalPass = "evalPass", EvalFail = "evalFail", @@ -108,6 +114,20 @@ export const rooCodeEventsSchema = z.object({ [RooCodeEventName.ModeChanged]: z.tuple([z.string()]), [RooCodeEventName.ProviderProfileChanged]: z.tuple([z.object({ name: z.string(), provider: z.string() })]), + + [RooCodeEventName.CommandsResponse]: z.tuple([ + z.array( + z.object({ + name: z.string(), + source: z.enum(["global", "project", "built-in"]), + filePath: z.string().optional(), + description: z.string().optional(), + argumentHint: z.string().optional(), + }), + ), + ]), + [RooCodeEventName.ModesResponse]: z.tuple([z.array(z.object({ slug: z.string(), name: z.string() }))]), + [RooCodeEventName.ModelsResponse]: z.tuple([z.record(z.string(), modelInfoSchema)]), }) export type RooCodeEvents = z.infer @@ -237,6 +257,23 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ taskId: z.number().optional(), }), + // Query Responses + z.object({ + eventName: z.literal(RooCodeEventName.CommandsResponse), + payload: rooCodeEventsSchema.shape[RooCodeEventName.CommandsResponse], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.ModesResponse), + payload: rooCodeEventsSchema.shape[RooCodeEventName.ModesResponse], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.ModelsResponse), + payload: rooCodeEventsSchema.shape[RooCodeEventName.ModelsResponse], + taskId: z.number().optional(), + }), + // Evals z.object({ eventName: z.literal(RooCodeEventName.EvalPass), diff --git a/packages/types/src/ipc.ts b/packages/types/src/ipc.ts index 9f6d2de04db..90a1478a4db 100644 --- a/packages/types/src/ipc.ts +++ b/packages/types/src/ipc.ts @@ -46,6 +46,9 @@ export enum TaskCommandName { CloseTask = "CloseTask", ResumeTask = "ResumeTask", SendMessage = "SendMessage", + GetCommands = "GetCommands", + GetModes = "GetModes", + GetModels = "GetModels", } /** @@ -79,6 +82,15 @@ export const taskCommandSchema = z.discriminatedUnion("commandName", [ images: z.array(z.string()).optional(), }), }), + z.object({ + commandName: z.literal(TaskCommandName.GetCommands), + }), + z.object({ + commandName: z.literal(TaskCommandName.GetModes), + }), + z.object({ + commandName: z.literal(TaskCommandName.GetModels), + }), ]) export type TaskCommand = z.infer diff --git a/src/extension/api.ts b/src/extension/api.ts index be78a09cb92..a2b389abdc6 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -20,10 +20,13 @@ import { IpcMessageType, } from "@roo-code/types" import { IpcServer } from "@roo-code/ipc" +import { CloudService } from "@roo-code/cloud" import { Package } from "../shared/package" import { ClineProvider } from "../core/webview/ClineProvider" import { openClineInNewTab } from "../activate/registerCommands" +import { getCommands } from "../services/command/commands" +import { getModels } from "../api/providers/fetchers/modelCache" export class API extends EventEmitter implements RooCodeAPI { private readonly outputChannel: vscode.OutputChannel @@ -64,7 +67,15 @@ export class API extends EventEmitter implements RooCodeAPI { ipc.listen() this.log(`[API] ipc server started: socketPath=${socketPath}, pid=${process.pid}, ppid=${process.ppid}`) - ipc.on(IpcMessageType.TaskCommand, async (_clientId, command) => { + ipc.on(IpcMessageType.TaskCommand, async (clientId, command) => { + const sendResponse = (eventName: RooCodeEventName, payload: unknown[]) => { + ipc.send(clientId, { + type: IpcMessageType.TaskEvent, + origin: IpcOrigin.Server, + data: { eventName, payload } as TaskEvent, + }) + } + switch (command.commandName) { case TaskCommandName.StartNewTask: this.log( @@ -88,13 +99,56 @@ export class API extends EventEmitter implements RooCodeAPI { } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) this.log(`[API] ResumeTask failed for taskId ${command.data}: ${errorMessage}`) - // Don't rethrow - we want to prevent IPC server crashes - // The error is logged for debugging purposes + // Don't rethrow - we want to prevent IPC server crashes. + // The error is logged for debugging purposes. } break case TaskCommandName.SendMessage: this.log(`[API] SendMessage -> ${command.data.text}`) await this.sendMessage(command.data.text, command.data.images) + break + case TaskCommandName.GetCommands: + try { + const commands = await getCommands(this.sidebarProvider.cwd) + + sendResponse(RooCodeEventName.CommandsResponse, [ + commands.map((cmd) => ({ + name: cmd.name, + source: cmd.source, + filePath: cmd.filePath, + description: cmd.description, + argumentHint: cmd.argumentHint, + })), + ]) + } catch (error) { + sendResponse(RooCodeEventName.CommandsResponse, [[]]) + } + + break + case TaskCommandName.GetModes: + try { + const modes = await this.sidebarProvider.getModes() + sendResponse(RooCodeEventName.ModesResponse, [modes]) + } catch (error) { + sendResponse(RooCodeEventName.ModesResponse, [[]]) + } + + break + case TaskCommandName.GetModels: + try { + const models = await getModels({ + provider: "roo" as const, + baseUrl: process.env.ROO_CODE_PROVIDER_URL ?? "https://api.roocode.com/proxy", + apiKey: CloudService.hasInstance() + ? CloudService.instance.authService?.getSessionToken() + : undefined, + }) + + sendResponse(RooCodeEventName.ModelsResponse, [models]) + } catch (error) { + sendResponse(RooCodeEventName.ModelsResponse, [{}]) + } + break } }) From f279537892a857e41490c73bd3c0e2c725aa8359 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:55:50 -0800 Subject: [PATCH 11/31] feat(web): replace Roomote Control with Linear Integration in cloud features grid (#11280) Co-authored-by: Roo Code --- apps/web-roo-code/src/app/cloud/page.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web-roo-code/src/app/cloud/page.tsx b/apps/web-roo-code/src/app/cloud/page.tsx index 68d3c3d2bc2..51df0642eec 100644 --- a/apps/web-roo-code/src/app/cloud/page.tsx +++ b/apps/web-roo-code/src/app/cloud/page.tsx @@ -5,9 +5,9 @@ import { ChartLine, Github, History, + ListChecks, LucideIcon, Pencil, - Router, Share2, Slack, Users, @@ -112,9 +112,9 @@ const features: Feature[] = [ description: "Start tasks, get updates, and collaborate with agents directly from your team's Slack channels.", }, { - icon: Router, - title: "Roomote Control", - description: "Connect to your local VS Code instance and control the extension remotely from the browser.", + icon: ListChecks, + title: "Linear Integration", + description: "Assign issues to Roo Code directly from Linear. Get PRs back without switching tools.", }, { icon: Users, From 43a30735459aa43fe6f12ad9f9461ef3824d03dc Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:02:17 -0500 Subject: [PATCH 12/31] refactor: migrate baseten provider to AI SDK (#11261) * refactor: migrate baseten provider to AI SDK * refactor(baseten): migrate to native @ai-sdk/baseten package Replace OpenAICompatibleHandler with dedicated @ai-sdk/baseten package, following the same pattern used by other native AI SDK providers (groq, deepseek, etc.). This uses createBaseten() for provider initialization and extends BaseProvider directly instead of the generic OpenAI-compatible handler. --- pnpm-lock.yaml | 196 +++++++++ src/api/providers/__tests__/baseten.spec.ts | 446 ++++++++++++++++++++ src/api/providers/baseten.ts | 158 ++++++- src/esbuild.mjs | 16 + src/package.json | 1 + 5 files changed, 807 insertions(+), 10 deletions(-) create mode 100644 src/api/providers/__tests__/baseten.spec.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58f6354f62d..c231b43e53a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -749,6 +749,9 @@ importers: '@ai-sdk/amazon-bedrock': specifier: ^4.0.50 version: 4.0.50(zod@3.25.76) + '@ai-sdk/baseten': + specifier: ^1.0.31 + version: 1.0.31(zod@3.25.76) '@ai-sdk/cerebras': specifier: ^1.0.0 version: 1.0.35(zod@3.25.76) @@ -1435,6 +1438,12 @@ packages: peerDependencies: zod: 3.25.76 + '@ai-sdk/baseten@1.0.31': + resolution: {integrity: sha512-tGbV96WBb5nnfyUYFrPyBxrhw53YlKSJbMC+rH3HhQlUaIs8+m/Bm4M0isrek9owIIf4MmmSDZ5VZL08zz7eFQ==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/cerebras@1.0.35': resolution: {integrity: sha512-JrNdMYptrOUjNthibgBeAcBjZ/H+fXb49sSrWhOx5Aq8eUcrYvwQ2DtSAi8VraHssZu78NAnBMrgFWSUOTXFxw==} engines: {node: '>=18'} @@ -1513,6 +1522,12 @@ packages: peerDependencies: zod: 3.25.76 + '@ai-sdk/openai-compatible@2.0.28': + resolution: {integrity: sha512-WzDnU0B13FMSSupDtm2lksFZvWGXnOfhG5S0HoPI0pkX5uVkr6N1UTATMyVaxLCG0MRkMhXCjkg4NXgEbb330Q==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/provider-utils@3.0.20': resolution: {integrity: sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==} engines: {node: '>=18'} @@ -1543,6 +1558,12 @@ packages: peerDependencies: zod: 3.25.76 + '@ai-sdk/provider-utils@4.0.14': + resolution: {integrity: sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==} + engines: {node: '>=18'} + peerDependencies: + zod: 3.25.76 + '@ai-sdk/provider@2.0.0': resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} @@ -1563,6 +1584,10 @@ packages: resolution: {integrity: sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw==} engines: {node: '>=18'} + '@ai-sdk/provider@3.0.8': + resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} + engines: {node: '>=18'} + '@ai-sdk/xai@3.0.46': resolution: {integrity: sha512-26qM/jYcFhF5krTM7bQT1CiZcdz22EQmA+r5me1hKYFM/yM20sSUMHnAcUzvzuuG9oQVKF0tziU2IcC0HX5huQ==} engines: {node: '>=18'} @@ -1880,6 +1905,93 @@ packages: resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} + '@basetenlabs/performance-client-android-arm-eabi@0.0.10': + resolution: {integrity: sha512-gwDZ6GDJA0AAmQAHxt2vaCz0tYTaLjxJKZnoYt+0Eji4gy231JZZFAwvbAqNdQCrGEQ9lXnk7SNM1Apet4NlYg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@basetenlabs/performance-client-android-arm64@0.0.10': + resolution: {integrity: sha512-oGRB/6hH89majhsmoVmj1IAZv4C7F2aLeTSebevBelmdYO4CFkn5qewxLzU1pDkkmxVVk2k+TRpYa1Dt4B96qQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@basetenlabs/performance-client-darwin-arm64@0.0.10': + resolution: {integrity: sha512-QpBOUjeO05tWgFWkDw2RUQZa3BMplX5jNiBBTi5mH1lIL/m1sm2vkxoc0iorEESp1mMPstYFS/fr4ssBuO7wyA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@basetenlabs/performance-client-darwin-universal@0.0.10': + resolution: {integrity: sha512-CBM38GAhekjylrlf7jW/0WNyFAGnAMBCNHZxaPnAjjhDNzJh1tcrwhvtOs66XbAqCOjO/tkt5Pdu6mg2Ui2Pjw==} + engines: {node: '>= 10'} + os: [darwin] + + '@basetenlabs/performance-client-darwin-x64@0.0.10': + resolution: {integrity: sha512-R+NsA72Axclh1CUpmaWOCLTWCqXn5/tFMj2z9BnHVSRTelx/pYFlx6ZngVTB1HYp1n21m3upPXGo8CHF8R7Itw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@basetenlabs/performance-client-linux-arm-gnueabihf@0.0.10': + resolution: {integrity: sha512-96kEo0Eas4GVQdFkxIB1aAv6dy5Ga57j+RIg5l0Yiawv+AYIEmgk9BsGkqcwayp8Iiu6LN22Z+AUsGY2gstNrg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@basetenlabs/performance-client-linux-arm-musleabihf@0.0.10': + resolution: {integrity: sha512-lzEHeu+/BWDl2q+QZcqCkg1rDGF4MeyM3HgYwX+07t+vGZoqtM2we9vEV68wXMpl6ToEHQr7ML2KHA1Gb6ogxg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@basetenlabs/performance-client-linux-arm64-gnu@0.0.10': + resolution: {integrity: sha512-MnY2cIRY/cQOYERWIHhh5CoaS2wgmmXtGDVGSLYyZvjwizrXZvjkEz7Whv2jaQ21T5S56VER67RABjz2TItrHQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@basetenlabs/performance-client-linux-riscv64-gnu@0.0.10': + resolution: {integrity: sha512-2KUvdK4wuoZdIqNnJhx7cu6ybXCwtiwGAtlrEvhai3FOkUQ3wE2Xa+TQ33mNGSyFbw6wAvLawYtKVFmmw27gJw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@basetenlabs/performance-client-linux-x64-gnu@0.0.10': + resolution: {integrity: sha512-9jjQPjHLiVOGwUPlmhnBl7OmmO7hQ8WMt+v3mJuxkS5JTNDmVOngfmgGlbN9NjBhQMENjdcMUVOquVo7HeybGQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@basetenlabs/performance-client-linux-x64-musl@0.0.10': + resolution: {integrity: sha512-bjYB8FKcPvEa251Ep2Gm3tvywADL9eavVjZsikdf0AvJ1K5pT+vLLvJBU9ihBsTPWnbF4pJgxVjwS6UjVObsQA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@basetenlabs/performance-client-win32-arm64-msvc@0.0.10': + resolution: {integrity: sha512-Vxq5UXEmfh3C3hpwXdp3Daaf0dnLR9zFH2x8MJ1Hf/TcilmOP1clneewNpIv0e7MrnT56Z4pM6P3d8VFMZqBKg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@basetenlabs/performance-client-win32-ia32-msvc@0.0.10': + resolution: {integrity: sha512-KJrm7CgZdP/UDC5+tHtqE6w9XMfY5YUfMOxJfBZGSsLMqS2OGsakQsaF0a55k+58l29X5w/nAkjHrI1BcQO03w==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@basetenlabs/performance-client-win32-x64-msvc@0.0.10': + resolution: {integrity: sha512-M/mhvfTItUcUX+aeXRb5g5MbRlndfg6yelV7tSYfLU4YixMIe5yoGaAP3iDilpFJjcC99f+EU4l4+yLbPtpXig==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@basetenlabs/performance-client@0.0.10': + resolution: {integrity: sha512-H6bpd1JcDbuJsOS2dNft+CCGLzBqHJO/ST/4mMKhLAW641J6PpVJUw1szYsk/dTetdedbWxHpMkvFObOKeP8nw==} + engines: {node: '>= 10'} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -11016,6 +11128,14 @@ snapshots: '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/baseten@1.0.31(zod@3.25.76)': + dependencies: + '@ai-sdk/openai-compatible': 2.0.28(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + '@basetenlabs/performance-client': 0.0.10 + zod: 3.25.76 + '@ai-sdk/cerebras@1.0.35(zod@3.25.76)': dependencies: '@ai-sdk/openai-compatible': 1.0.31(zod@3.25.76) @@ -11102,6 +11222,12 @@ snapshots: '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/openai-compatible@2.0.28(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/provider-utils@3.0.20(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.1 @@ -11138,6 +11264,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 3.25.76 + '@ai-sdk/provider-utils@4.0.14(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + '@ai-sdk/provider@2.0.0': dependencies: json-schema: 0.4.0 @@ -11158,6 +11291,10 @@ snapshots: dependencies: json-schema: 0.4.0 + '@ai-sdk/provider@3.0.8': + dependencies: + json-schema: 0.4.0 + '@ai-sdk/xai@3.0.46(zod@3.25.76)': dependencies: '@ai-sdk/openai-compatible': 2.0.26(zod@3.25.76) @@ -11893,6 +12030,65 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@basetenlabs/performance-client-android-arm-eabi@0.0.10': + optional: true + + '@basetenlabs/performance-client-android-arm64@0.0.10': + optional: true + + '@basetenlabs/performance-client-darwin-arm64@0.0.10': + optional: true + + '@basetenlabs/performance-client-darwin-universal@0.0.10': + optional: true + + '@basetenlabs/performance-client-darwin-x64@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-arm-gnueabihf@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-arm-musleabihf@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-arm64-gnu@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-riscv64-gnu@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-x64-gnu@0.0.10': + optional: true + + '@basetenlabs/performance-client-linux-x64-musl@0.0.10': + optional: true + + '@basetenlabs/performance-client-win32-arm64-msvc@0.0.10': + optional: true + + '@basetenlabs/performance-client-win32-ia32-msvc@0.0.10': + optional: true + + '@basetenlabs/performance-client-win32-x64-msvc@0.0.10': + optional: true + + '@basetenlabs/performance-client@0.0.10': + optionalDependencies: + '@basetenlabs/performance-client-android-arm-eabi': 0.0.10 + '@basetenlabs/performance-client-android-arm64': 0.0.10 + '@basetenlabs/performance-client-darwin-arm64': 0.0.10 + '@basetenlabs/performance-client-darwin-universal': 0.0.10 + '@basetenlabs/performance-client-darwin-x64': 0.0.10 + '@basetenlabs/performance-client-linux-arm-gnueabihf': 0.0.10 + '@basetenlabs/performance-client-linux-arm-musleabihf': 0.0.10 + '@basetenlabs/performance-client-linux-arm64-gnu': 0.0.10 + '@basetenlabs/performance-client-linux-riscv64-gnu': 0.0.10 + '@basetenlabs/performance-client-linux-x64-gnu': 0.0.10 + '@basetenlabs/performance-client-linux-x64-musl': 0.0.10 + '@basetenlabs/performance-client-win32-arm64-msvc': 0.0.10 + '@basetenlabs/performance-client-win32-ia32-msvc': 0.0.10 + '@basetenlabs/performance-client-win32-x64-msvc': 0.0.10 + '@bcoe/v8-coverage@0.2.3': {} '@braintree/sanitize-url@7.1.1': {} diff --git a/src/api/providers/__tests__/baseten.spec.ts b/src/api/providers/__tests__/baseten.spec.ts new file mode 100644 index 00000000000..e44b201f291 --- /dev/null +++ b/src/api/providers/__tests__/baseten.spec.ts @@ -0,0 +1,446 @@ +// npx vitest run src/api/providers/__tests__/baseten.spec.ts + +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("@ai-sdk/baseten", () => ({ + createBaseten: vi.fn(() => { + return vi.fn(() => ({ + modelId: "zai-org/GLM-4.6", + provider: "baseten", + })) + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" + +import { basetenDefaultModelId, basetenModels, type BasetenModelId } from "@roo-code/types" + +import type { ApiHandlerOptions } from "../../../shared/api" + +import { BasetenHandler } from "../baseten" + +describe("BasetenHandler", () => { + let handler: BasetenHandler + let mockOptions: ApiHandlerOptions + + beforeEach(() => { + mockOptions = { + basetenApiKey: "test-baseten-api-key", + apiModelId: "zai-org/GLM-4.6", + } + handler = new BasetenHandler(mockOptions) + vi.clearAllMocks() + }) + + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(BasetenHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) + }) + + it("should use default model ID if not provided", () => { + const handlerWithoutModel = new BasetenHandler({ + ...mockOptions, + apiModelId: undefined, + }) + expect(handlerWithoutModel.getModel().id).toBe(basetenDefaultModelId) + }) + }) + + describe("getModel", () => { + it("should return default model when no model is specified", () => { + const handlerWithoutModel = new BasetenHandler({ + basetenApiKey: "test-baseten-api-key", + }) + const model = handlerWithoutModel.getModel() + expect(model.id).toBe(basetenDefaultModelId) + expect(model.info).toEqual(basetenModels[basetenDefaultModelId]) + }) + + it("should return specified model when valid model is provided", () => { + const testModelId: BasetenModelId = "deepseek-ai/DeepSeek-R1" + const handlerWithModel = new BasetenHandler({ + apiModelId: testModelId, + basetenApiKey: "test-baseten-api-key", + }) + const model = handlerWithModel.getModel() + expect(model.id).toBe(testModelId) + expect(model.info).toEqual(basetenModels[testModelId]) + }) + + it("should return provided model ID with default model info if model does not exist", () => { + const handlerWithInvalidModel = new BasetenHandler({ + ...mockOptions, + apiModelId: "invalid-model", + }) + const model = handlerWithInvalidModel.getModel() + expect(model.id).toBe("invalid-model") + expect(model.info).toBeDefined() + expect(model.info).toBe(basetenModels[basetenDefaultModelId]) + }) + + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") + }) + }) + + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [ + { + type: "text" as const, + text: "Hello!", + }, + ], + }, + ] + + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response from Baseten" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response from Baseten") + }) + + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) + }) + + it("should pass correct temperature (0.5 default) to streamText", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const handlerWithDefaultTemp = new BasetenHandler({ + basetenApiKey: "test-key", + apiModelId: "zai-org/GLM-4.6", + }) + + const stream = handlerWithDefaultTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + }), + ) + }) + + it("should use user-specified temperature over default", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const handlerWithCustomTemp = new BasetenHandler({ + basetenApiKey: "test-key", + apiModelId: "zai-org/GLM-4.6", + modelTemperature: 0.9, + }) + + const stream = handlerWithCustomTemp.createMessage(systemPrompt, messages) + for await (const _ of stream) { + // consume stream + } + + expect(mockStreamText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.9, + }), + ) + }) + + it("should handle stream with multiple chunks", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Hello" } + yield { type: "text-delta", text: " world" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 5, outputTokens: 10 }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks[0]).toEqual({ type: "text", text: "Hello" }) + expect(textChunks[1]).toEqual({ type: "text", text: " world" }) + + const usageChunks = chunks.filter((c) => c.type === "usage") + expect(usageChunks[0]).toMatchObject({ type: "usage", inputTokens: 5, outputTokens: 10 }) + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from Baseten", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from Baseten") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + + it("should use default temperature in completePrompt", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion", + }) + + await handler.completePrompt("Test prompt") + + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + temperature: 0.5, + }), + ) + }) + }) + + describe("tool handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle tool calls in streaming", async () => { + async function* mockFullStream() { + yield { + type: "tool-input-start", + id: "tool-call-1", + toolName: "read_file", + } + yield { + type: "tool-input-delta", + id: "tool-call-1", + delta: '{"path":"test.ts"}', + } + yield { + type: "tool-input-end", + id: "tool-call-1", + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages, { + taskId: "test-task", + tools: [ + { + type: "function", + function: { + name: "read_file", + description: "Read a file", + parameters: { + type: "object", + properties: { path: { type: "string" } }, + required: ["path"], + }, + }, + }, + ], + }) + + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallStartChunks = chunks.filter((c) => c.type === "tool_call_start") + const toolCallDeltaChunks = chunks.filter((c) => c.type === "tool_call_delta") + const toolCallEndChunks = chunks.filter((c) => c.type === "tool_call_end") + + expect(toolCallStartChunks.length).toBe(1) + expect(toolCallStartChunks[0].id).toBe("tool-call-1") + expect(toolCallStartChunks[0].name).toBe("read_file") + + expect(toolCallDeltaChunks.length).toBe(1) + expect(toolCallDeltaChunks[0].delta).toBe('{"path":"test.ts"}') + + expect(toolCallEndChunks.length).toBe(1) + expect(toolCallEndChunks[0].id).toBe("tool-call-1") + }) + + it("should ignore tool-call events to prevent duplicate tools in UI", async () => { + async function* mockFullStream() { + yield { + type: "tool-call", + toolCallId: "tool-call-1", + toolName: "read_file", + input: { path: "test.ts" }, + } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) + + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const toolCallChunks = chunks.filter( + (c) => c.type === "tool_call_start" || c.type === "tool_call_delta" || c.type === "tool_call_end", + ) + expect(toolCallChunks.length).toBe(0) + }) + }) + + describe("error handling", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle AI SDK errors with handleAiSdkError", async () => { + // eslint-disable-next-line require-yield + async function* mockFullStream(): AsyncGenerator { + throw new Error("API Error") + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + await expect(async () => { + for await (const _ of stream) { + // consume stream + } + }).rejects.toThrow("Baseten: API Error") + }) + + it("should preserve status codes in error handling", async () => { + const apiError = new Error("Rate limit exceeded") + ;(apiError as any).status = 429 + + // eslint-disable-next-line require-yield + async function* mockFullStream(): AsyncGenerator { + throw apiError + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = handler.createMessage(systemPrompt, messages) + + try { + for await (const _ of stream) { + // consume stream + } + expect.fail("Should have thrown an error") + } catch (error: any) { + expect(error.message).toContain("Baseten") + expect(error.status).toBe(429) + } + }) + }) +}) diff --git a/src/api/providers/baseten.ts b/src/api/providers/baseten.ts index ca0c2867756..2e63f3d52c1 100644 --- a/src/api/providers/baseten.ts +++ b/src/api/providers/baseten.ts @@ -1,18 +1,156 @@ -import { type BasetenModelId, basetenDefaultModelId, basetenModels } from "@roo-code/types" +import { Anthropic } from "@anthropic-ai/sdk" +import { createBaseten } from "@ai-sdk/baseten" +import { streamText, generateText, ToolSet } from "ai" + +import { basetenModels, basetenDefaultModelId, type ModelInfo } from "@roo-code/types" import type { ApiHandlerOptions } from "../../shared/api" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" -export class BasetenHandler extends BaseOpenAiCompatibleProvider { +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream, ApiStreamUsageChunk } from "../transform/stream" +import { getModelParams } from "../transform/model-params" + +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" + +const BASETEN_DEFAULT_TEMPERATURE = 0.5 + +/** + * Baseten provider using the dedicated @ai-sdk/baseten package. + * Provides native support for Baseten's inference API. + */ +export class BasetenHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected provider: ReturnType + constructor(options: ApiHandlerOptions) { - super({ - ...options, - providerName: "Baseten", + super() + this.options = options + + this.provider = createBaseten({ baseURL: "https://inference.baseten.co/v1", - apiKey: options.basetenApiKey, - defaultProviderModelId: basetenDefaultModelId, - providerModels: basetenModels, - defaultTemperature: 0.5, + apiKey: options.basetenApiKey ?? "not-provided", + headers: DEFAULT_HEADERS, + }) + } + + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const id = this.options.apiModelId ?? basetenDefaultModelId + const info = basetenModels[id as keyof typeof basetenModels] || basetenModels[basetenDefaultModelId] + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, + defaultTemperature: BASETEN_DEFAULT_TEMPERATURE, + }) + return { id, info, ...params } + } + + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) + } + + /** + * Process usage metrics from the AI SDK response. + */ + protected processUsageMetrics(usage: { + inputTokens?: number + outputTokens?: number + details?: { + cachedInputTokens?: number + reasoningTokens?: number + } + }): ApiStreamUsageChunk { + return { + type: "usage", + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + reasoningTokens: usage.details?.reasoningTokens, + } + } + + /** + * Get the max tokens parameter to include in the request. + */ + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + */ + override async *createMessage( + systemPrompt: string, + messages: Anthropic.Messages.MessageParam[], + metadata?: ApiHandlerCreateMessageMetadata, + ): ApiStream { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const aiSdkMessages = convertToAiSdkMessages(messages) + + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? BASETEN_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + } + + const result = streamText(requestOptions) + + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage) + } + } catch (error) { + throw handleAiSdkError(error, "Baseten") + } + } + + /** + * Complete a prompt using the AI SDK generateText. + */ + async completePrompt(prompt: string): Promise { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? BASETEN_DEFAULT_TEMPERATURE, }) + + return text + } + + override isAiSdkProvider(): boolean { + return true } } diff --git a/src/esbuild.mjs b/src/esbuild.mjs index aabacfcee99..fb7b1866797 100644 --- a/src/esbuild.mjs +++ b/src/esbuild.mjs @@ -43,6 +43,22 @@ async function main() { * @type {import('esbuild').Plugin[]} */ const plugins = [ + { + // Stub out @basetenlabs/performance-client which contains native .node + // binaries that esbuild cannot bundle. This module is only used by + // @ai-sdk/baseten for embedding models, not for chat completions. + name: "stub-baseten-native", + setup(build) { + build.onResolve({ filter: /^@basetenlabs\/performance-client/ }, (args) => ({ + path: args.path, + namespace: "stub-baseten-native", + })) + build.onLoad({ filter: /.*/, namespace: "stub-baseten-native" }, () => ({ + contents: "module.exports = { PerformanceClient: class PerformanceClient {} };", + loader: "js", + })) + }, + }, { name: "copyFiles", setup(build) { diff --git a/src/package.json b/src/package.json index f2b574c2626..eb67c0d1d7d 100644 --- a/src/package.json +++ b/src/package.json @@ -451,6 +451,7 @@ }, "dependencies": { "@ai-sdk/amazon-bedrock": "^4.0.50", + "@ai-sdk/baseten": "^1.0.31", "@ai-sdk/cerebras": "^1.0.0", "@ai-sdk/deepseek": "^2.0.14", "@ai-sdk/fireworks": "^2.0.26", From d7714e4e07a8ada57a5ec8ed0adb33109b282b6f Mon Sep 17 00:00:00 2001 From: John Richmond <5629+jr@users.noreply.github.com> Date: Fri, 6 Feb 2026 17:52:48 -0800 Subject: [PATCH 13/31] chore: bump version to v1.110.0 (#11278) --- packages/types/npm/package.metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index 33cae5c3263..50a8c6512f6 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.109.0", + "version": "1.110.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", From 4d87a004a500dd55142ac282eee34e7cde911236 Mon Sep 17 00:00:00 2001 From: "roomote[bot]" <219738659+roomote[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:11:08 -0500 Subject: [PATCH 14/31] fix: add stub-baseten-native esbuild plugin to nightly build config (#11285) Co-authored-by: Roo Code --- apps/vscode-nightly/esbuild.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/apps/vscode-nightly/esbuild.mjs b/apps/vscode-nightly/esbuild.mjs index e45dbd3c3e8..92b80b50d4f 100644 --- a/apps/vscode-nightly/esbuild.mjs +++ b/apps/vscode-nightly/esbuild.mjs @@ -57,6 +57,22 @@ async function main() { * @type {import('esbuild').Plugin[]} */ const plugins = [ + { + // Stub out @basetenlabs/performance-client which contains native .node + // binaries that esbuild cannot bundle. This module is only used by + // @ai-sdk/baseten for embedding models, not for chat completions. + name: "stub-baseten-native", + setup(build) { + build.onResolve({ filter: /^@basetenlabs\/performance-client/ }, (args) => ({ + path: args.path, + namespace: "stub-baseten-native", + })) + build.onLoad({ filter: /.*/, namespace: "stub-baseten-native" }, () => ({ + contents: "module.exports = { PerformanceClient: class PerformanceClient {} };", + loader: "js", + })) + }, + }, { name: "copyPaths", setup(build) { From 7fc42d70ea213b80f78612e5da48d73bbfde8177 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sat, 7 Feb 2026 00:53:27 -0500 Subject: [PATCH 15/31] Add new code owners to CODEOWNERS file --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a3daa0f144e..e2e8fa34b63 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,2 +1,2 @@ # These owners will be the default owners for everything in the repo -* @mrubens @cte @jr +* @mrubens @cte @jr @hannesrudolph @daniel-lxs From 97c10387eed113ea165b097f1076fda7de949af0 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 7 Feb 2026 08:16:38 -0700 Subject: [PATCH 16/31] chore: update AI SDK packages to latest versions (#11286) --- pnpm-lock.yaml | 328 ++++++++++-------------------------- src/api/providers/vertex.ts | 6 +- src/package.json | 24 +-- 3 files changed, 107 insertions(+), 251 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c231b43e53a..de90f8c927f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -747,35 +747,35 @@ importers: src: dependencies: '@ai-sdk/amazon-bedrock': - specifier: ^4.0.50 - version: 4.0.50(zod@3.25.76) + specifier: ^4.0.51 + version: 4.0.51(zod@3.25.76) '@ai-sdk/baseten': specifier: ^1.0.31 version: 1.0.31(zod@3.25.76) '@ai-sdk/cerebras': - specifier: ^1.0.0 - version: 1.0.35(zod@3.25.76) + specifier: ^2.0.31 + version: 2.0.31(zod@3.25.76) '@ai-sdk/deepseek': - specifier: ^2.0.14 - version: 2.0.14(zod@3.25.76) + specifier: ^2.0.18 + version: 2.0.18(zod@3.25.76) '@ai-sdk/fireworks': - specifier: ^2.0.26 - version: 2.0.26(zod@3.25.76) + specifier: ^2.0.32 + version: 2.0.32(zod@3.25.76) '@ai-sdk/google': - specifier: ^3.0.20 - version: 3.0.20(zod@3.25.76) + specifier: ^3.0.22 + version: 3.0.22(zod@3.25.76) '@ai-sdk/google-vertex': - specifier: ^3.0.20 - version: 3.0.98(zod@3.25.76) + specifier: ^4.0.45 + version: 4.0.45(zod@3.25.76) '@ai-sdk/groq': + specifier: ^3.0.22 + version: 3.0.22(zod@3.25.76) + '@ai-sdk/mistral': specifier: ^3.0.19 version: 3.0.19(zod@3.25.76) - '@ai-sdk/mistral': - specifier: ^3.0.0 - version: 3.0.18(zod@3.25.76) '@ai-sdk/xai': - specifier: ^3.0.46 - version: 3.0.46(zod@3.25.76) + specifier: ^3.0.48 + version: 3.0.48(zod@3.25.76) '@anthropic-ai/sdk': specifier: ^0.37.0 version: 0.37.0 @@ -1024,11 +1024,11 @@ importers: version: 3.25.76 devDependencies: '@ai-sdk/openai-compatible': - specifier: ^1.0.0 - version: 1.0.31(zod@3.25.76) + specifier: ^2.0.28 + version: 2.0.28(zod@3.25.76) '@openrouter/ai-sdk-provider': - specifier: ^2.0.4 - version: 2.1.1(ai@6.0.57(zod@3.25.76))(zod@3.25.76) + specifier: ^2.1.1 + version: 2.1.1(ai@6.0.77(zod@3.25.76))(zod@3.25.76) '@roo-code/build': specifier: workspace:^ version: link:../packages/build @@ -1102,8 +1102,8 @@ importers: specifier: 3.3.2 version: 3.3.2 ai: - specifier: ^6.0.0 - version: 6.0.57(zod@3.25.76) + specifier: ^6.0.75 + version: 6.0.77(zod@3.25.76) esbuild-wasm: specifier: ^0.25.0 version: 0.25.12 @@ -1420,20 +1420,14 @@ packages: '@adobe/css-tools@4.4.2': resolution: {integrity: sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==} - '@ai-sdk/amazon-bedrock@4.0.50': - resolution: {integrity: sha512-DsIxaUHPbDUY0DfxYMz6GL9tO/z7ISiwACSiYupcYImqrcdLtIGFujPgszOf92ed3olfhjdkhTwKBHaf6Yh6Qw==} + '@ai-sdk/amazon-bedrock@4.0.51': + resolution: {integrity: sha512-r2vDm4XiGUoxWiLQzhbfqYtVUdPvaBIJFKaeYXpIr+kfFIHD+ksMHMZJb687epcJ+bCQ1TpQxFbMkfP3YZUvDg==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/anthropic@2.0.58': - resolution: {integrity: sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ==} - engines: {node: '>=18'} - peerDependencies: - zod: 3.25.76 - - '@ai-sdk/anthropic@3.0.37': - resolution: {integrity: sha512-tEgcJPw+a6obbF+SHrEiZsx3DNxOHqeY8bK4IpiNsZ8YPZD141R34g3lEAaQnmNN5mGsEJ8SXoEDabuzi8wFJQ==} + '@ai-sdk/anthropic@3.0.38': + resolution: {integrity: sha512-9MchyPRPni0WzrFeIGNevZpQVfWxaS+MQFupIXYQo9VgHnuO1Vyrp9SBmjkkuoAdBs7GomsWqLZCcNMJAVbdFA==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 @@ -1444,56 +1438,50 @@ packages: peerDependencies: zod: 3.25.76 - '@ai-sdk/cerebras@1.0.35': - resolution: {integrity: sha512-JrNdMYptrOUjNthibgBeAcBjZ/H+fXb49sSrWhOx5Aq8eUcrYvwQ2DtSAi8VraHssZu78NAnBMrgFWSUOTXFxw==} - engines: {node: '>=18'} - peerDependencies: - zod: 3.25.76 - - '@ai-sdk/deepseek@2.0.14': - resolution: {integrity: sha512-1vXh8sVwRJYd1JO57qdy1rACucaNLDoBRCwOER3EbPgSF2vNVPcdJywGutA01Bhn7Cta+UJQ+k5y/yzMAIpP2w==} + '@ai-sdk/cerebras@2.0.31': + resolution: {integrity: sha512-s7o4BRsbG2RFina4VwHs46RWlQPGCL1CrfOoMomYneJeA0CgpxPigPqwlrupaWWB42KIDDHN5gNOIsLst0oOPg==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/fireworks@2.0.26': - resolution: {integrity: sha512-vBqSSksHhDGrSNYnmEmVGvLicHFjL4yAxFZfCb6ydrg+qgnlW2bdyTQDMI69BKG4spNZ1/iHMxRNIQpx19Yf6w==} + '@ai-sdk/deepseek@2.0.18': + resolution: {integrity: sha512-AwtmFm7acnCsz3z82Yu5QKklSZz+cBwtxrc2hbw47tPF/38xr1zX3Vf/pP627EHwWkLV18UWivIxg0SHPP2w3A==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/gateway@3.0.25': - resolution: {integrity: sha512-j0AQeA7hOVqwImykQlganf/Euj3uEXf0h3G0O4qKTDpEwE+EZGIPnVimCWht5W91lAetPZSfavDyvfpuPDd2PQ==} + '@ai-sdk/fireworks@2.0.32': + resolution: {integrity: sha512-2qOEvocoRxUND086pjgliSBFKTyy6LUKbHZvXr++zlHm8ZbMT4dES78f5MHbOP9UVvRCPfTKmlPsUFUP/EVhJQ==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/google-vertex@3.0.98': - resolution: {integrity: sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ==} + '@ai-sdk/gateway@3.0.39': + resolution: {integrity: sha512-SeCZBAdDNbWpVUXiYgOAqis22p5MEYfrjRw0hiBa5hM+7sDGYQpMinUjkM8kbPXMkY+AhKLrHleBl+SuqpzlgA==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/google@2.0.52': - resolution: {integrity: sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g==} + '@ai-sdk/google-vertex@4.0.45': + resolution: {integrity: sha512-KkOsYd9DiyNatqxr/dSKzC6qrxwxOXZ63vu6Yfz2A7bPCsrwKzcN9SQRuhbVkBa1j0C78YiSDKuQvclfOk/0Kw==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/google@3.0.20': - resolution: {integrity: sha512-bVGsulEr6JiipAFlclo9bjL5WaUV0iCSiiekLt+PY6pwmtJeuU2GaD9DoE3OqR8LN2W779mU13IhVEzlTupf8g==} + '@ai-sdk/google@3.0.22': + resolution: {integrity: sha512-g1N5P/jfTiH4qwdv4WT3hkKzzAbITFz457NomtBfjP8Q3SCzdbU9oPK5ACBMG8RN5mc2QPL6DLtM3Hf5T8KPmw==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/groq@3.0.19': - resolution: {integrity: sha512-WAeGVnp9rvU3RUvu6S1HiD8hAjKgNlhq+z3m4j5Z1fIKRXqcKjOscVZGwL36If8qxsqXNVCtG3ltXawM5UAa8w==} + '@ai-sdk/groq@3.0.22': + resolution: {integrity: sha512-QBkqBmlts2qz2vX54gXeP9IdztMFxZw7xPNwjOjHYhEL7RynzB2aFafPIbAYTVNosrU0YEETxhw9LISjS2TtXw==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/mistral@3.0.18': - resolution: {integrity: sha512-k8nCBBVGOzBigNwBO5kREzsP/e+C3npcL7jt19ZdicIbZ6rvmnSIRI90iENyS9T10vM7sjrXoCpgZSYgJB2pJQ==} + '@ai-sdk/mistral@3.0.19': + resolution: {integrity: sha512-yd0OJ3fm2YKdwxh1pd9m720sENVVcylAD+Bki8C80QqVpUxGNL1/C4N4JJGb56eCCWr6VU/3gHFe9PKui9n/Hg==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 @@ -1504,60 +1492,18 @@ packages: peerDependencies: zod: 3.25.76 - '@ai-sdk/openai-compatible@1.0.31': - resolution: {integrity: sha512-znBvaVHM0M6yWNerIEy3hR+O8ZK2sPcE7e2cxfb6kYLEX3k//JH5VDnRnajseVofg7LXtTCFFdjsB7WLf1BdeQ==} - engines: {node: '>=18'} - peerDependencies: - zod: 3.25.76 - - '@ai-sdk/openai-compatible@2.0.24': - resolution: {integrity: sha512-3QrCKpQCn3g6sIMoFGuEroaqk7Xg+qfsohRp4dKszjto5stjBg4SdtOKqHg+CpE3X4woj2O62w2qr5dSekMZeQ==} - engines: {node: '>=18'} - peerDependencies: - zod: 3.25.76 - - '@ai-sdk/openai-compatible@2.0.26': - resolution: {integrity: sha512-l6jdFjI1C2eDAEm7oo+dnRn0oG1EkcyqfbEZ7ozT0TnYrah6amX2JkftYMP1GRzNtAeCB3WNN8XspXdmi6ZNlQ==} - engines: {node: '>=18'} - peerDependencies: - zod: 3.25.76 - '@ai-sdk/openai-compatible@2.0.28': resolution: {integrity: sha512-WzDnU0B13FMSSupDtm2lksFZvWGXnOfhG5S0HoPI0pkX5uVkr6N1UTATMyVaxLCG0MRkMhXCjkg4NXgEbb330Q==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/provider-utils@3.0.20': - resolution: {integrity: sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ==} - engines: {node: '>=18'} - peerDependencies: - zod: 3.25.76 - '@ai-sdk/provider-utils@3.0.5': resolution: {integrity: sha512-HliwB/yzufw3iwczbFVE2Fiwf1XqROB/I6ng8EKUsPM5+2wnIa8f4VbljZcDx+grhFrPV+PnRZH7zBqi8WZM7Q==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.10': - resolution: {integrity: sha512-VeDAiCH+ZK8Xs4hb9Cw7pHlujWNL52RKe8TExOkrw6Ir1AmfajBZTb9XUdKOZO08RwQElIKA8+Ltm+Gqfo8djQ==} - engines: {node: '>=18'} - peerDependencies: - zod: 3.25.76 - - '@ai-sdk/provider-utils@4.0.11': - resolution: {integrity: sha512-y/WOPpcZaBjvNaogy83mBsCRPvbtaK0y1sY9ckRrrbTGMvG2HC/9Y/huqNXKnLAxUIME2PGa2uvF2CDwIsxoXQ==} - engines: {node: '>=18'} - peerDependencies: - zod: 3.25.76 - - '@ai-sdk/provider-utils@4.0.13': - resolution: {integrity: sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og==} - engines: {node: '>=18'} - peerDependencies: - zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.14': resolution: {integrity: sha512-7bzKd9lgiDeXM7O4U4nQ8iTxguAOkg8LZGD9AfDVZYjO5cKYRwBPwVjboFcVrxncRHu0tYxZtXZtiLKpG4pEng==} engines: {node: '>=18'} @@ -1568,28 +1514,12 @@ packages: resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==} engines: {node: '>=18'} - '@ai-sdk/provider@2.0.1': - resolution: {integrity: sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng==} - engines: {node: '>=18'} - - '@ai-sdk/provider@3.0.5': - resolution: {integrity: sha512-2Xmoq6DBJqmSl80U6V9z5jJSJP7ehaJJQMy2iFUqTay06wdCqTnPVBBQbtEL8RCChenL+q5DC5H5WzU3vV3v8w==} - engines: {node: '>=18'} - - '@ai-sdk/provider@3.0.6': - resolution: {integrity: sha512-hSfoJtLtpMd7YxKM+iTqlJ0ZB+kJ83WESMiWuWrNVey3X8gg97x0OdAAaeAeclZByCX3UdPOTqhvJdK8qYA3ww==} - engines: {node: '>=18'} - - '@ai-sdk/provider@3.0.7': - resolution: {integrity: sha512-VkPLrutM6VdA924/mG8OS+5frbVTcu6e046D2bgDo00tehBANR1QBJ/mPcZ9tXMFOsVcm6SQArOregxePzTFPw==} - engines: {node: '>=18'} - '@ai-sdk/provider@3.0.8': resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==} engines: {node: '>=18'} - '@ai-sdk/xai@3.0.46': - resolution: {integrity: sha512-26qM/jYcFhF5krTM7bQT1CiZcdz22EQmA+r5me1hKYFM/yM20sSUMHnAcUzvzuuG9oQVKF0tziU2IcC0HX5huQ==} + '@ai-sdk/xai@3.0.48': + resolution: {integrity: sha512-fUefjg7TwngHUtv0s+8j+GSPBiQRSETOPpICpaubz0CDNj0inBw/bZ6DKskQol7O20BIcoz0eKweedtC+F5iyQ==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 @@ -4948,8 +4878,8 @@ packages: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} - ai@6.0.57: - resolution: {integrity: sha512-5wYcMQmOaNU71wGv4XX1db3zvn4uLjLbTKIo6cQZPWOJElA0882XI7Eawx6TCd5jbjOvKMIP+KLWbpVomAFT2g==} + ai@6.0.77: + resolution: {integrity: sha512-tyyhrRpCRFVlivdNIFLK8cexSBB2jwTqO0z1qJQagk+UxZ+MW8h5V8xsvvb+xdKDY482Y8KAm0mr7TDnPKvvlw==} engines: {node: '>=18'} peerDependencies: zod: 3.25.76 @@ -11106,26 +11036,20 @@ snapshots: '@adobe/css-tools@4.4.2': {} - '@ai-sdk/amazon-bedrock@4.0.50(zod@3.25.76)': + '@ai-sdk/amazon-bedrock@4.0.51(zod@3.25.76)': dependencies: - '@ai-sdk/anthropic': 3.0.37(zod@3.25.76) - '@ai-sdk/provider': 3.0.7 - '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) + '@ai-sdk/anthropic': 3.0.38(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) '@smithy/eventstream-codec': 4.2.4 '@smithy/util-utf8': 4.2.0 aws4fetch: 1.0.20 zod: 3.25.76 - '@ai-sdk/anthropic@2.0.58(zod@3.25.76)': + '@ai-sdk/anthropic@3.0.38(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) - zod: 3.25.76 - - '@ai-sdk/anthropic@3.0.37(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.7 - '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 '@ai-sdk/baseten@1.0.31(zod@3.25.76)': @@ -11136,66 +11060,60 @@ snapshots: '@basetenlabs/performance-client': 0.0.10 zod: 3.25.76 - '@ai-sdk/cerebras@1.0.35(zod@3.25.76)': + '@ai-sdk/cerebras@2.0.31(zod@3.25.76)': dependencies: - '@ai-sdk/openai-compatible': 1.0.31(zod@3.25.76) - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + '@ai-sdk/openai-compatible': 2.0.28(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/deepseek@2.0.14(zod@3.25.76)': + '@ai-sdk/deepseek@2.0.18(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.5 - '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/fireworks@2.0.26(zod@3.25.76)': + '@ai-sdk/fireworks@2.0.32(zod@3.25.76)': dependencies: - '@ai-sdk/openai-compatible': 2.0.24(zod@3.25.76) - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) + '@ai-sdk/openai-compatible': 2.0.28(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/gateway@3.0.25(zod@3.25.76)': + '@ai-sdk/gateway@3.0.39(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.5 - '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) '@vercel/oidc': 3.1.0 zod: 3.25.76 - '@ai-sdk/google-vertex@3.0.98(zod@3.25.76)': + '@ai-sdk/google-vertex@4.0.45(zod@3.25.76)': dependencies: - '@ai-sdk/anthropic': 2.0.58(zod@3.25.76) - '@ai-sdk/google': 2.0.52(zod@3.25.76) - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + '@ai-sdk/anthropic': 3.0.38(zod@3.25.76) + '@ai-sdk/google': 3.0.22(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) google-auth-library: 10.5.0 zod: 3.25.76 transitivePeerDependencies: - supports-color - '@ai-sdk/google@2.0.52(zod@3.25.76)': + '@ai-sdk/google@3.0.22(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) - zod: 3.25.76 - - '@ai-sdk/google@3.0.20(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.7 - '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/groq@3.0.19(zod@3.25.76)': + '@ai-sdk/groq@3.0.22(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/mistral@3.0.18(zod@3.25.76)': + '@ai-sdk/mistral@3.0.19(zod@3.25.76)': dependencies: - '@ai-sdk/provider': 3.0.7 - '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 '@ai-sdk/openai-compatible@1.0.11(zod@3.25.76)': @@ -11204,37 +11122,12 @@ snapshots: '@ai-sdk/provider-utils': 3.0.5(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/openai-compatible@1.0.31(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) - zod: 3.25.76 - - '@ai-sdk/openai-compatible@2.0.24(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.6 - '@ai-sdk/provider-utils': 4.0.11(zod@3.25.76) - zod: 3.25.76 - - '@ai-sdk/openai-compatible@2.0.26(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.7 - '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) - zod: 3.25.76 - '@ai-sdk/openai-compatible@2.0.28(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.8 '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 - '@ai-sdk/provider-utils@3.0.20(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 2.0.1 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 3.25.76 - '@ai-sdk/provider-utils@3.0.5(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -11243,27 +11136,6 @@ snapshots: zod: 3.25.76 zod-to-json-schema: 3.24.5(zod@3.25.76) - '@ai-sdk/provider-utils@4.0.10(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.5 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 3.25.76 - - '@ai-sdk/provider-utils@4.0.11(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.6 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 3.25.76 - - '@ai-sdk/provider-utils@4.0.13(zod@3.25.76)': - dependencies: - '@ai-sdk/provider': 3.0.7 - '@standard-schema/spec': 1.1.0 - eventsource-parser: 3.0.6 - zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.14(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.8 @@ -11275,31 +11147,15 @@ snapshots: dependencies: json-schema: 0.4.0 - '@ai-sdk/provider@2.0.1': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/provider@3.0.5': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/provider@3.0.6': - dependencies: - json-schema: 0.4.0 - - '@ai-sdk/provider@3.0.7': - dependencies: - json-schema: 0.4.0 - '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 - '@ai-sdk/xai@3.0.46(zod@3.25.76)': + '@ai-sdk/xai@3.0.48(zod@3.25.76)': dependencies: - '@ai-sdk/openai-compatible': 2.0.26(zod@3.25.76) - '@ai-sdk/provider': 3.0.7 - '@ai-sdk/provider-utils': 4.0.13(zod@3.25.76) + '@ai-sdk/openai-compatible': 2.0.28(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) zod: 3.25.76 '@alcalzone/ansi-tokenize@0.2.3': @@ -13018,9 +12874,9 @@ snapshots: '@open-draft/until@2.1.0': {} - '@openrouter/ai-sdk-provider@2.1.1(ai@6.0.57(zod@3.25.76))(zod@3.25.76)': + '@openrouter/ai-sdk-provider@2.1.1(ai@6.0.77(zod@3.25.76))(zod@3.25.76)': dependencies: - ai: 6.0.57(zod@3.25.76) + ai: 6.0.77(zod@3.25.76) zod: 3.25.76 '@opentelemetry/api-logs@0.208.0': @@ -15245,11 +15101,11 @@ snapshots: dependencies: humanize-ms: 1.2.1 - ai@6.0.57(zod@3.25.76): + ai@6.0.77(zod@3.25.76): dependencies: - '@ai-sdk/gateway': 3.0.25(zod@3.25.76) - '@ai-sdk/provider': 3.0.5 - '@ai-sdk/provider-utils': 4.0.10(zod@3.25.76) + '@ai-sdk/gateway': 3.0.39(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.14(zod@3.25.76) '@opentelemetry/api': 1.9.0 zod: 3.25.76 diff --git a/src/api/providers/vertex.ts b/src/api/providers/vertex.ts index 17f7a4a99e1..c772741e6a0 100644 --- a/src/api/providers/vertex.ts +++ b/src/api/providers/vertex.ts @@ -134,7 +134,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl // Add thinking/reasoning configuration if present // Cast to any to bypass strict JSONObject typing - the AI SDK accepts the correct runtime values ...(thinkingConfig && { - providerOptions: { google: { thinkingConfig } } as any, + providerOptions: { vertex: { thinkingConfig } } as any, }), } @@ -166,7 +166,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl // Extract grounding sources from providerMetadata if available const providerMetadata = await result.providerMetadata - const groundingMetadata = providerMetadata?.google as + const groundingMetadata = (providerMetadata?.vertex ?? providerMetadata?.google) as | { groundingMetadata?: { groundingChunks?: Array<{ @@ -318,7 +318,7 @@ export class VertexHandler extends BaseProvider implements SingleCompletionHandl // Extract grounding citations from providerMetadata if available const providerMetadata = result.providerMetadata - const groundingMetadata = providerMetadata?.google as + const groundingMetadata = (providerMetadata?.vertex ?? providerMetadata?.google) as | { groundingMetadata?: { groundingChunks?: Array<{ diff --git a/src/package.json b/src/package.json index eb67c0d1d7d..77349f65953 100644 --- a/src/package.json +++ b/src/package.json @@ -450,16 +450,16 @@ "clean": "rimraf README.md CHANGELOG.md LICENSE dist logs mock .turbo" }, "dependencies": { - "@ai-sdk/amazon-bedrock": "^4.0.50", + "@ai-sdk/amazon-bedrock": "^4.0.51", "@ai-sdk/baseten": "^1.0.31", - "@ai-sdk/cerebras": "^1.0.0", - "@ai-sdk/deepseek": "^2.0.14", - "@ai-sdk/fireworks": "^2.0.26", - "@ai-sdk/google": "^3.0.20", - "@ai-sdk/google-vertex": "^3.0.20", - "@ai-sdk/groq": "^3.0.19", - "@ai-sdk/mistral": "^3.0.0", - "@ai-sdk/xai": "^3.0.46", + "@ai-sdk/cerebras": "^2.0.31", + "@ai-sdk/deepseek": "^2.0.18", + "@ai-sdk/fireworks": "^2.0.32", + "@ai-sdk/google": "^3.0.22", + "@ai-sdk/google-vertex": "^4.0.45", + "@ai-sdk/groq": "^3.0.22", + "@ai-sdk/mistral": "^3.0.19", + "@ai-sdk/xai": "^3.0.48", "@anthropic-ai/sdk": "^0.37.0", "@anthropic-ai/vertex-sdk": "^0.7.0", "@aws-sdk/client-bedrock-runtime": "^3.922.0", @@ -544,8 +544,8 @@ "zod": "3.25.76" }, "devDependencies": { - "@ai-sdk/openai-compatible": "^1.0.0", - "@openrouter/ai-sdk-provider": "^2.0.4", + "@ai-sdk/openai-compatible": "^2.0.28", + "@openrouter/ai-sdk-provider": "^2.1.1", "@roo-code/build": "workspace:^", "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", @@ -570,7 +570,7 @@ "@types/vscode": "^1.84.0", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "3.3.2", - "ai": "^6.0.0", + "ai": "^6.0.75", "esbuild-wasm": "^0.25.0", "execa": "^9.5.2", "glob": "^11.1.0", From f179ba1b9ed99384310d2785e9d7f161cc6179d5 Mon Sep 17 00:00:00 2001 From: Daniel <57051444+daniel-lxs@users.noreply.github.com> Date: Sat, 7 Feb 2026 12:27:11 -0500 Subject: [PATCH 17/31] refactor: migrate zai provider to AI SDK (#11263) * refactor: migrate zai provider to AI SDK using zhipu-ai-provider * Update src/api/providers/zai.ts Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> * fix: remove unused zai-format.ts (knip) --------- Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> --- pnpm-lock.yaml | 16 +- src/api/providers/__tests__/zai.spec.ts | 473 ++++++++++++------------ src/api/providers/zai.ts | 200 ++++++---- src/api/transform/zai-format.ts | 242 ------------ src/package.json | 1 + 5 files changed, 387 insertions(+), 545 deletions(-) delete mode 100644 src/api/transform/zai-format.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de90f8c927f..ff809db9add 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1019,6 +1019,9 @@ importers: yaml: specifier: ^2.8.0 version: 2.8.0 + zhipu-ai-provider: + specifier: ^0.2.2 + version: 0.2.2(zod@3.25.76) zod: specifier: 3.25.76 version: 3.25.76 @@ -10983,6 +10986,10 @@ packages: yoga-layout@3.2.1: resolution: {integrity: sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==} + zhipu-ai-provider@0.2.2: + resolution: {integrity: sha512-UjX1ho4DI9ICUv/mrpAnzmrRe5/LXrGkS5hF6h4WDY2aup5GketWWopFzWYCqsbArXAM5wbzzdH9QzZusgGiBg==} + engines: {node: '>=18'} + zip-stream@4.1.1: resolution: {integrity: sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==} engines: {node: '>= 10'} @@ -14951,7 +14958,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -22242,6 +22249,13 @@ snapshots: yoga-layout@3.2.1: {} + zhipu-ai-provider@0.2.2(zod@3.25.76): + dependencies: + '@ai-sdk/provider': 2.0.1 + '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + transitivePeerDependencies: + - zod + zip-stream@4.1.1: dependencies: archiver-utils: 3.0.4 diff --git a/src/api/providers/__tests__/zai.spec.ts b/src/api/providers/__tests__/zai.spec.ts index 34323b108d3..af3154e7783 100644 --- a/src/api/providers/__tests__/zai.spec.ts +++ b/src/api/providers/__tests__/zai.spec.ts @@ -1,7 +1,30 @@ // npx vitest run src/api/providers/__tests__/zai.spec.ts -import OpenAI from "openai" -import { Anthropic } from "@anthropic-ai/sdk" +// Use vi.hoisted to define mock functions that can be referenced in hoisted vi.mock() calls +const { mockStreamText, mockGenerateText } = vi.hoisted(() => ({ + mockStreamText: vi.fn(), + mockGenerateText: vi.fn(), +})) + +vi.mock("ai", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + streamText: mockStreamText, + generateText: mockGenerateText, + } +}) + +vi.mock("zhipu-ai-provider", () => ({ + createZhipu: vi.fn(() => { + return vi.fn(() => ({ + modelId: "glm-4.6", + provider: "zhipu", + })) + }), +})) + +import type { Anthropic } from "@anthropic-ai/sdk" import { type InternationalZAiModelId, @@ -13,42 +36,41 @@ import { ZAI_DEFAULT_TEMPERATURE, } from "@roo-code/types" -import { ZAiHandler } from "../zai" +import type { ApiHandlerOptions } from "../../../shared/api" -vitest.mock("openai", () => { - const createMock = vitest.fn() - return { - default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })), - } -}) +import { ZAiHandler } from "../zai" describe("ZAiHandler", () => { let handler: ZAiHandler - let mockCreate: any + let mockOptions: ApiHandlerOptions beforeEach(() => { - vitest.clearAllMocks() - mockCreate = (OpenAI as unknown as any)().chat.completions.create + mockOptions = { + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + apiModelId: "glm-4.6", + } + handler = new ZAiHandler(mockOptions) + vi.clearAllMocks() }) - describe("International Z AI", () => { - beforeEach(() => { - handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" }) + describe("constructor", () => { + it("should initialize with provided options", () => { + expect(handler).toBeInstanceOf(ZAiHandler) + expect(handler.getModel().id).toBe(mockOptions.apiModelId) }) - it("should use the correct international Z AI base URL", () => { - new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.z.ai/api/coding/paas/v4", - }), - ) + it("should default to international when no zaiApiLine is specified", () => { + const handlerDefault = new ZAiHandler({ zaiApiKey: "test-zai-api-key" }) + const model = handlerDefault.getModel() + expect(model.id).toBe(internationalZAiDefaultModelId) + expect(model.info).toEqual(internationalZAiModels[internationalZAiDefaultModelId]) }) + }) - it("should use the provided API key for international", () => { - const zaiApiKey = "test-zai-api-key" - new ZAiHandler({ zaiApiKey, zaiApiLine: "international_coding" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey })) + describe("International Z AI", () => { + beforeEach(() => { + handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" }) }) it("should return international default model when no model is specified", () => { @@ -119,19 +141,6 @@ describe("ZAiHandler", () => { handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_coding" }) }) - it("should use the correct China Z AI base URL", () => { - new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_coding" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ baseURL: "https://open.bigmodel.cn/api/coding/paas/v4" }), - ) - }) - - it("should use the provided API key for China", () => { - const zaiApiKey = "test-zai-api-key" - new ZAiHandler({ zaiApiKey, zaiApiLine: "china_coding" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey })) - }) - it("should return China default model when no model is specified", () => { const model = handler.getModel() expect(model.id).toBe(mainlandZAiDefaultModelId) @@ -200,21 +209,6 @@ describe("ZAiHandler", () => { handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_api" }) }) - it("should use the correct international API base URL", () => { - new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_api" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.z.ai/api/paas/v4", - }), - ) - }) - - it("should use the provided API key for international API", () => { - const zaiApiKey = "test-zai-api-key" - new ZAiHandler({ zaiApiKey, zaiApiLine: "international_api" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey })) - }) - it("should return international default model when no model is specified", () => { const model = handler.getModel() expect(model.id).toBe(internationalZAiDefaultModelId) @@ -239,21 +233,6 @@ describe("ZAiHandler", () => { handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_api" }) }) - it("should use the correct China API base URL", () => { - new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "china_api" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://open.bigmodel.cn/api/paas/v4", - }), - ) - }) - - it("should use the provided API key for China API", () => { - const zaiApiKey = "test-zai-api-key" - new ZAiHandler({ zaiApiKey, zaiApiLine: "china_api" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: zaiApiKey })) - }) - it("should return China default model when no model is specified", () => { const model = handler.getModel() expect(model.id).toBe(mainlandZAiDefaultModelId) @@ -273,133 +252,98 @@ describe("ZAiHandler", () => { }) }) - describe("Default behavior", () => { - it("should default to international when no zaiApiLine is specified", () => { - const handlerDefault = new ZAiHandler({ zaiApiKey: "test-zai-api-key" }) - expect(OpenAI).toHaveBeenCalledWith( - expect.objectContaining({ - baseURL: "https://api.z.ai/api/coding/paas/v4", - }), - ) - - const model = handlerDefault.getModel() - expect(model.id).toBe(internationalZAiDefaultModelId) - expect(model.info).toEqual(internationalZAiModels[internationalZAiDefaultModelId]) - }) - - it("should use 'not-provided' as default API key when none is specified", () => { - new ZAiHandler({ zaiApiLine: "international_coding" }) - expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: "not-provided" })) + describe("getModel", () => { + it("should include model parameters from getModelParams", () => { + const model = handler.getModel() + expect(model).toHaveProperty("temperature") + expect(model).toHaveProperty("maxTokens") }) }) - describe("API Methods", () => { - beforeEach(() => { - handler = new ZAiHandler({ zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding" }) - }) - - it("completePrompt method should return text from Z AI API", async () => { - const expectedResponse = "This is a test response from Z AI" - mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] }) - const result = await handler.completePrompt("test prompt") - expect(result).toBe(expectedResponse) - }) - - it("should handle errors in completePrompt", async () => { - const errorMessage = "Z AI API error" - mockCreate.mockRejectedValueOnce(new Error(errorMessage)) - await expect(handler.completePrompt("test prompt")).rejects.toThrow( - `Z.ai completion error: ${errorMessage}`, - ) - }) + describe("createMessage", () => { + const systemPrompt = "You are a helpful assistant." + const messages: Anthropic.Messages.MessageParam[] = [ + { + role: "user", + content: [{ type: "text" as const, text: "Hello!" }], + }, + ] + + it("should handle streaming responses", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response from Z.ai" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 5, + }) - it("createMessage should yield text content from stream", async () => { - const testContent = "This is test content from Z AI stream" - - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { choices: [{ delta: { content: testContent } }] }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toEqual({ type: "text", text: testContent }) + expect(chunks.length).toBeGreaterThan(0) + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Test response from Z.ai") }) - it("createMessage should yield usage data from stream", async () => { - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - next: vitest - .fn() - .mockResolvedValueOnce({ - done: false, - value: { - choices: [{ delta: {} }], - usage: { prompt_tokens: 10, completion_tokens: 20 }, - }, - }) - .mockResolvedValueOnce({ done: true }), - }), - } + it("should include usage information", async () => { + async function* mockFullStream() { + yield { type: "text-delta", text: "Test response" } + } + + const mockUsage = Promise.resolve({ + inputTokens: 10, + outputTokens: 20, }) - const stream = handler.createMessage("system prompt", []) - const firstChunk = await stream.next() + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: mockUsage, + }) - expect(firstChunk.done).toBe(false) - expect(firstChunk.value).toMatchObject({ type: "usage", inputTokens: 10, outputTokens: 20 }) + const stream = handler.createMessage(systemPrompt, messages) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunks = chunks.filter((chunk) => chunk.type === "usage") + expect(usageChunks.length).toBeGreaterThan(0) + expect(usageChunks[0].inputTokens).toBe(10) + expect(usageChunks[0].outputTokens).toBe(20) }) - it("createMessage should pass correct parameters to Z AI client", async () => { - const modelId: InternationalZAiModelId = "glm-4.5" - const modelInfo = internationalZAiModels[modelId] - const handlerWithModel = new ZAiHandler({ - apiModelId: modelId, - zaiApiKey: "test-zai-api-key", - zaiApiLine: "international_coding", - }) + it("should pass correct parameters to streamText", async () => { + async function* mockFullStream() { + // empty stream + } - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - const systemPrompt = "Test system prompt for Z AI" - const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for Z AI" }] - - const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages) - await messageGenerator.next() + const stream = handler.createMessage(systemPrompt, messages) + // Consume the stream + for await (const _chunk of stream) { + // drain + } - // Centralized 20% cap should apply to OpenAI-compatible providers like Z AI - const expectedMaxTokens = Math.min(modelInfo.maxTokens, Math.ceil(modelInfo.contextWindow * 0.2)) - - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: modelId, - max_tokens: expectedMaxTokens, - temperature: ZAI_DEFAULT_TEMPERATURE, - messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]), - stream: true, - stream_options: { include_usage: true }, + system: systemPrompt, + temperature: expect.any(Number), }), - undefined, ) }) }) @@ -410,27 +354,29 @@ describe("ZAiHandler", () => { apiModelId: "glm-4.7", zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding", - // No reasoningEffort setting - should use model default (medium) }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - const messageGenerator = handlerWithModel.createMessage("system prompt", []) - await messageGenerator.next() + const stream = handlerWithModel.createMessage("system prompt", []) + for await (const _chunk of stream) { + // drain + } - // For GLM-4.7 with default reasoning (medium), thinking should be enabled - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: "glm-4.7", - thinking: { type: "enabled" }, + providerOptions: { + zhipu: { + thinking: { type: "enabled" }, + }, + }, }), ) }) @@ -444,24 +390,27 @@ describe("ZAiHandler", () => { reasoningEffort: "disable", }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - const messageGenerator = handlerWithModel.createMessage("system prompt", []) - await messageGenerator.next() + const stream = handlerWithModel.createMessage("system prompt", []) + for await (const _chunk of stream) { + // drain + } - // For GLM-4.7 with reasoning disabled, thinking should be disabled - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: "glm-4.7", - thinking: { type: "disabled" }, + providerOptions: { + zhipu: { + thinking: { type: "disabled" }, + }, + }, }), ) }) @@ -475,51 +424,109 @@ describe("ZAiHandler", () => { reasoningEffort: "medium", }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), }) - const messageGenerator = handlerWithModel.createMessage("system prompt", []) - await messageGenerator.next() + const stream = handlerWithModel.createMessage("system prompt", []) + for await (const _chunk of stream) { + // drain + } - // For GLM-4.7 with reasoning set to medium, thinking should be enabled - expect(mockCreate).toHaveBeenCalledWith( + expect(mockStreamText).toHaveBeenCalledWith( expect.objectContaining({ - model: "glm-4.7", - thinking: { type: "enabled" }, + providerOptions: { + zhipu: { + thinking: { type: "enabled" }, + }, + }, }), ) }) - it("should NOT add thinking parameter for non-thinking models like GLM-4.6", async () => { + it("should NOT add providerOptions for non-thinking models like GLM-4.6", async () => { const handlerWithModel = new ZAiHandler({ apiModelId: "glm-4.6", zaiApiKey: "test-zai-api-key", zaiApiLine: "international_coding", }) - mockCreate.mockImplementationOnce(() => { - return { - [Symbol.asyncIterator]: () => ({ - async next() { - return { done: true } - }, - }), - } + async function* mockFullStream() { + yield { type: "text-delta", text: "response" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }), + }) + + const stream = handlerWithModel.createMessage("system prompt", []) + for await (const _chunk of stream) { + // drain + } + + const callArgs = mockStreamText.mock.calls[0][0] + expect(callArgs.providerOptions).toBeUndefined() + }) + + it("should handle reasoning content in streaming responses", async () => { + const handlerWithModel = new ZAiHandler({ + apiModelId: "glm-4.7", + zaiApiKey: "test-zai-api-key", + zaiApiLine: "international_coding", + }) + + async function* mockFullStream() { + yield { type: "reasoning", text: "Let me think about this..." } + yield { type: "text-delta", text: "Here is my answer" } + } + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream(), + usage: Promise.resolve({ inputTokens: 10, outputTokens: 20 }), }) - const messageGenerator = handlerWithModel.createMessage("system prompt", []) - await messageGenerator.next() + const stream = handlerWithModel.createMessage("system prompt", []) + const chunks: any[] = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const reasoningChunks = chunks.filter((chunk) => chunk.type === "reasoning") + expect(reasoningChunks).toHaveLength(1) + expect(reasoningChunks[0].text).toBe("Let me think about this...") + + const textChunks = chunks.filter((chunk) => chunk.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0].text).toBe("Here is my answer") + }) + }) + + describe("completePrompt", () => { + it("should complete a prompt using generateText", async () => { + mockGenerateText.mockResolvedValue({ + text: "Test completion from Z.ai", + }) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("Test completion from Z.ai") + expect(mockGenerateText).toHaveBeenCalledWith( + expect.objectContaining({ + prompt: "Test prompt", + }), + ) + }) + }) - // For GLM-4.6 (no thinking support), thinking parameter should not be present - const callArgs = mockCreate.mock.calls[0][0] - expect(callArgs.thinking).toBeUndefined() + describe("isAiSdkProvider", () => { + it("should return true", () => { + expect(handler.isAiSdkProvider()).toBe(true) }) }) }) diff --git a/src/api/providers/zai.ts b/src/api/providers/zai.ts index a2e3740c56f..acfdd811292 100644 --- a/src/api/providers/zai.ts +++ b/src/api/providers/zai.ts @@ -1,5 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" +import { createZhipu } from "zhipu-ai-provider" +import { streamText, generateText, ToolSet } from "ai" import { internationalZAiModels, @@ -11,101 +12,162 @@ import { zaiApiLineConfigs, } from "@roo-code/types" -import { type ApiHandlerOptions, getModelMaxOutputTokens, shouldUseReasoningEffort } from "../../shared/api" -import { convertToZAiFormat } from "../transform/zai-format" +import { type ApiHandlerOptions, shouldUseReasoningEffort } from "../../shared/api" -import type { ApiHandlerCreateMessageMetadata } from "../index" -import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider" - -// Custom interface for Z.ai params to support thinking mode -type ZAiChatCompletionParams = OpenAI.Chat.ChatCompletionCreateParamsStreaming & { - thinking?: { type: "enabled" | "disabled" } -} +import { + convertToAiSdkMessages, + convertToolsForAiSdk, + processAiSdkStreamPart, + mapToolChoice, + handleAiSdkError, +} from "../transform/ai-sdk" +import { ApiStream } from "../transform/stream" +import { getModelParams } from "../transform/model-params" + +import { DEFAULT_HEADERS } from "./constants" +import { BaseProvider } from "./base-provider" +import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index" + +/** + * Z.ai provider using the dedicated zhipu-ai-provider package. + * Provides native support for GLM-4.7 thinking mode and region-based model selection. + */ +export class ZAiHandler extends BaseProvider implements SingleCompletionHandler { + protected options: ApiHandlerOptions + protected provider: ReturnType + private isChina: boolean -export class ZAiHandler extends BaseOpenAiCompatibleProvider { constructor(options: ApiHandlerOptions) { - const isChina = zaiApiLineConfigs[options.zaiApiLine ?? "international_coding"].isChina - const models = (isChina ? mainlandZAiModels : internationalZAiModels) as unknown as Record - const defaultModelId = (isChina ? mainlandZAiDefaultModelId : internationalZAiDefaultModelId) as string + super() + this.options = options + this.isChina = zaiApiLineConfigs[options.zaiApiLine ?? "international_coding"].isChina - super({ - ...options, - providerName: "Z.ai", + this.provider = createZhipu({ baseURL: zaiApiLineConfigs[options.zaiApiLine ?? "international_coding"].baseUrl, apiKey: options.zaiApiKey ?? "not-provided", - defaultProviderModelId: defaultModelId, - providerModels: models, + headers: DEFAULT_HEADERS, + }) + } + + override getModel(): { id: string; info: ModelInfo; maxTokens?: number; temperature?: number } { + const models = (this.isChina ? mainlandZAiModels : internationalZAiModels) as unknown as Record< + string, + ModelInfo + > + const defaultModelId = (this.isChina ? mainlandZAiDefaultModelId : internationalZAiDefaultModelId) as string + + const id = this.options.apiModelId ?? defaultModelId + const info = models[id] || models[defaultModelId] + const params = getModelParams({ + format: "openai", + modelId: id, + model: info, + settings: this.options, defaultTemperature: ZAI_DEFAULT_TEMPERATURE, }) + + return { id, info, ...params } + } + + /** + * Get the language model for the configured model ID. + */ + protected getLanguageModel() { + const { id } = this.getModel() + return this.provider(id) } /** - * Override createStream to handle GLM-4.7's thinking mode. - * GLM-4.7 has thinking enabled by default in the API, so we need to - * explicitly send { type: "disabled" } when the user turns off reasoning. + * Get the max tokens parameter to include in the request. */ - protected override createStream( + protected getMaxOutputTokens(): number | undefined { + const { info } = this.getModel() + return this.options.modelMaxTokens || info.maxTokens || undefined + } + + /** + * Create a message stream using the AI SDK. + * For GLM-4.7, passes the thinking parameter via providerOptions. + */ + override async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], metadata?: ApiHandlerCreateMessageMetadata, - requestOptions?: OpenAI.RequestOptions, - ) { - const { id: modelId, info } = this.getModel() + ): ApiStream { + const { id: modelId, info, temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + const aiSdkMessages = convertToAiSdkMessages(messages) + + const openAiTools = this.convertToolsForOpenAI(metadata?.tools) + const aiSdkTools = convertToolsForAiSdk(openAiTools) as ToolSet | undefined + + const requestOptions: Parameters[0] = { + model: languageModel, + system: systemPrompt, + messages: aiSdkMessages, + temperature: this.options.modelTemperature ?? temperature ?? ZAI_DEFAULT_TEMPERATURE, + maxOutputTokens: this.getMaxOutputTokens(), + tools: aiSdkTools, + toolChoice: mapToolChoice(metadata?.tool_choice), + } - // Check if this is a GLM-4.7 model with thinking support + // GLM-4.7 thinking mode: pass thinking parameter via providerOptions const isThinkingModel = modelId === "glm-4.7" && Array.isArray(info.supportsReasoningEffort) if (isThinkingModel) { - // For GLM-4.7, thinking is ON by default in the API. - // We need to explicitly disable it when reasoning is off. const useReasoning = shouldUseReasoningEffort({ model: info, settings: this.options }) - - // Create the stream with our custom thinking parameter - return this.createStreamWithThinking(systemPrompt, messages, metadata, useReasoning) + requestOptions.providerOptions = { + zhipu: { + thinking: useReasoning ? { type: "enabled" } : { type: "disabled" }, + }, + } } - // For non-thinking models, use the default behavior - return super.createStream(systemPrompt, messages, metadata, requestOptions) + const result = streamText(requestOptions) + + try { + for await (const part of result.fullStream) { + for (const chunk of processAiSdkStreamPart(part)) { + yield chunk + } + } + + const usage = await result.usage + if (usage) { + yield { + type: "usage" as const, + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + } + } + } catch (error) { + throw handleAiSdkError(error, "Z.ai") + } } /** - * Creates a stream with explicit thinking control for GLM-4.7 + * Complete a prompt using the AI SDK generateText. */ - private createStreamWithThinking( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - useReasoning?: boolean, - ) { - const { id: model, info } = this.getModel() - - const max_tokens = - getModelMaxOutputTokens({ - modelId: model, - model: info, - settings: this.options, - format: "openai", - }) ?? undefined - - const temperature = this.options.modelTemperature ?? this.defaultTemperature - - // Use Z.ai format to preserve reasoning_content and merge post-tool text into tool messages - const convertedMessages = convertToZAiFormat(messages, { mergeToolResultText: true }) - - const params: ZAiChatCompletionParams = { - model, - max_tokens, - temperature, - messages: [{ role: "system", content: systemPrompt }, ...convertedMessages], - stream: true, - stream_options: { include_usage: true }, - // For GLM-4.7: thinking is ON by default, so we explicitly disable when needed - thinking: useReasoning ? { type: "enabled" } : { type: "disabled" }, - tools: this.convertToolsForOpenAI(metadata?.tools), - tool_choice: metadata?.tool_choice, - parallel_tool_calls: metadata?.parallelToolCalls ?? true, + async completePrompt(prompt: string): Promise { + const { temperature } = this.getModel() + const languageModel = this.getLanguageModel() + + try { + const { text } = await generateText({ + model: languageModel, + prompt, + maxOutputTokens: this.getMaxOutputTokens(), + temperature: this.options.modelTemperature ?? temperature ?? ZAI_DEFAULT_TEMPERATURE, + }) + + return text + } catch (error) { + throw handleAiSdkError(error, "Z.ai") } + } - return this.client.chat.completions.create(params) + override isAiSdkProvider(): boolean { + return true } } diff --git a/src/api/transform/zai-format.ts b/src/api/transform/zai-format.ts deleted file mode 100644 index 79b2e88aeb2..00000000000 --- a/src/api/transform/zai-format.ts +++ /dev/null @@ -1,242 +0,0 @@ -import { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" - -type ContentPartText = OpenAI.Chat.ChatCompletionContentPartText -type ContentPartImage = OpenAI.Chat.ChatCompletionContentPartImage -type UserMessage = OpenAI.Chat.ChatCompletionUserMessageParam -type AssistantMessage = OpenAI.Chat.ChatCompletionAssistantMessageParam -type SystemMessage = OpenAI.Chat.ChatCompletionSystemMessageParam -type ToolMessage = OpenAI.Chat.ChatCompletionToolMessageParam -type Message = OpenAI.Chat.ChatCompletionMessageParam -type AnthropicMessage = Anthropic.Messages.MessageParam - -/** - * Extended assistant message type to support Z.ai's interleaved thinking. - * Z.ai's API returns reasoning_content alongside content and tool_calls, - * and requires it to be passed back in subsequent requests for preserved thinking. - */ -export type ZAiAssistantMessage = AssistantMessage & { - reasoning_content?: string -} - -/** - * Converts Anthropic messages to OpenAI format optimized for Z.ai's GLM-4.7 thinking mode. - * - * Key differences from standard OpenAI format: - * - Preserves reasoning_content on assistant messages for interleaved thinking - * - Text content after tool_results (like environment_details) is merged into the last tool message - * to avoid creating user messages that would cause reasoning_content to be dropped - * - * @param messages Array of Anthropic messages - * @param options Optional configuration for message conversion - * @param options.mergeToolResultText If true, merge text content after tool_results into the last - * tool message instead of creating a separate user message. - * This is critical for Z.ai's interleaved thinking mode. - * @returns Array of OpenAI messages optimized for Z.ai's thinking mode - */ -export function convertToZAiFormat( - messages: AnthropicMessage[], - options?: { mergeToolResultText?: boolean }, -): Message[] { - const result: Message[] = [] - - for (const message of messages) { - // Check if the message has reasoning_content (for Z.ai interleaved thinking) - const messageWithReasoning = message as AnthropicMessage & { reasoning_content?: string } - const reasoningContent = messageWithReasoning.reasoning_content - - if (message.role === "user") { - // Handle user messages - may contain tool_result blocks - if (Array.isArray(message.content)) { - const textParts: string[] = [] - const imageParts: ContentPartImage[] = [] - const toolResults: { tool_use_id: string; content: string }[] = [] - - for (const part of message.content) { - if (part.type === "text") { - textParts.push(part.text) - } else if (part.type === "image") { - imageParts.push({ - type: "image_url", - image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` }, - }) - } else if (part.type === "tool_result") { - // Convert tool_result to OpenAI tool message format - let content: string - if (typeof part.content === "string") { - content = part.content - } else if (Array.isArray(part.content)) { - content = - part.content - ?.map((c) => { - if (c.type === "text") return c.text - if (c.type === "image") return "(image)" - return "" - }) - .join("\n") ?? "" - } else { - content = "" - } - toolResults.push({ - tool_use_id: part.tool_use_id, - content, - }) - } - } - - // Add tool messages first (they must follow assistant tool_use) - for (const toolResult of toolResults) { - const toolMessage: ToolMessage = { - role: "tool", - tool_call_id: toolResult.tool_use_id, - content: toolResult.content, - } - result.push(toolMessage) - } - - // Handle text/image content after tool results - if (textParts.length > 0 || imageParts.length > 0) { - // For Z.ai interleaved thinking: when mergeToolResultText is enabled and we have - // tool results followed by text, merge the text into the last tool message to avoid - // creating a user message that would cause reasoning_content to be dropped. - // This is critical because Z.ai drops all reasoning_content when it sees a user message. - const shouldMergeIntoToolMessage = - options?.mergeToolResultText && toolResults.length > 0 && imageParts.length === 0 - - if (shouldMergeIntoToolMessage) { - // Merge text content into the last tool message - const lastToolMessage = result[result.length - 1] as ToolMessage - if (lastToolMessage?.role === "tool") { - const additionalText = textParts.join("\n") - lastToolMessage.content = `${lastToolMessage.content}\n\n${additionalText}` - } - } else { - // Standard behavior: add user message with text/image content - let content: UserMessage["content"] - if (imageParts.length > 0) { - const parts: (ContentPartText | ContentPartImage)[] = [] - if (textParts.length > 0) { - parts.push({ type: "text", text: textParts.join("\n") }) - } - parts.push(...imageParts) - content = parts - } else { - content = textParts.join("\n") - } - - // Check if we can merge with the last message - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "user") { - // Merge with existing user message - if (typeof lastMessage.content === "string" && typeof content === "string") { - lastMessage.content += `\n${content}` - } else { - const lastContent = Array.isArray(lastMessage.content) - ? lastMessage.content - : [{ type: "text" as const, text: lastMessage.content || "" }] - const newContent = Array.isArray(content) - ? content - : [{ type: "text" as const, text: content }] - lastMessage.content = [...lastContent, ...newContent] as UserMessage["content"] - } - } else { - result.push({ role: "user", content }) - } - } - } - } else { - // Simple string content - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "user") { - if (typeof lastMessage.content === "string") { - lastMessage.content += `\n${message.content}` - } else { - ;(lastMessage.content as (ContentPartText | ContentPartImage)[]).push({ - type: "text", - text: message.content, - }) - } - } else { - result.push({ role: "user", content: message.content }) - } - } - } else if (message.role === "assistant") { - // Handle assistant messages - may contain tool_use blocks and reasoning blocks - if (Array.isArray(message.content)) { - const textParts: string[] = [] - const toolCalls: OpenAI.Chat.ChatCompletionMessageToolCall[] = [] - let extractedReasoning: string | undefined - - for (const part of message.content) { - if (part.type === "text") { - textParts.push(part.text) - } else if (part.type === "tool_use") { - toolCalls.push({ - id: part.id, - type: "function", - function: { - name: part.name, - arguments: JSON.stringify(part.input), - }, - }) - } else if ((part as any).type === "reasoning" && (part as any).text) { - // Extract reasoning from content blocks (Task stores it this way) - extractedReasoning = (part as any).text - } - } - - // Use reasoning from content blocks if not provided at top level - const finalReasoning = reasoningContent || extractedReasoning - - const assistantMessage: ZAiAssistantMessage = { - role: "assistant", - content: textParts.length > 0 ? textParts.join("\n") : null, - ...(toolCalls.length > 0 && { tool_calls: toolCalls }), - // Preserve reasoning_content for Z.ai interleaved thinking - ...(finalReasoning && { reasoning_content: finalReasoning }), - } - - // Check if we can merge with the last message (only if no tool calls) - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "assistant" && !toolCalls.length && !(lastMessage as any).tool_calls) { - // Merge text content - if (typeof lastMessage.content === "string" && typeof assistantMessage.content === "string") { - lastMessage.content += `\n${assistantMessage.content}` - } else if (assistantMessage.content) { - const lastContent = lastMessage.content || "" - lastMessage.content = `${lastContent}\n${assistantMessage.content}` - } - // Preserve reasoning_content from the new message if present - if (finalReasoning) { - ;(lastMessage as ZAiAssistantMessage).reasoning_content = finalReasoning - } - } else { - result.push(assistantMessage) - } - } else { - // Simple string content - const lastMessage = result[result.length - 1] - if (lastMessage?.role === "assistant" && !(lastMessage as any).tool_calls) { - if (typeof lastMessage.content === "string") { - lastMessage.content += `\n${message.content}` - } else { - lastMessage.content = message.content - } - // Preserve reasoning_content from the new message if present - if (reasoningContent) { - ;(lastMessage as ZAiAssistantMessage).reasoning_content = reasoningContent - } - } else { - const assistantMessage: ZAiAssistantMessage = { - role: "assistant", - content: message.content, - ...(reasoningContent && { reasoning_content: reasoningContent }), - } - result.push(assistantMessage) - } - } - } - } - - return result -} diff --git a/src/package.json b/src/package.json index 77349f65953..70cc99ba731 100644 --- a/src/package.json +++ b/src/package.json @@ -541,6 +541,7 @@ "web-tree-sitter": "^0.25.6", "workerpool": "^9.2.0", "yaml": "^2.8.0", + "zhipu-ai-provider": "^0.2.2", "zod": "3.25.76" }, "devDependencies": { From 6826e20da29db4ec2e1f41196ad01b0f7755be72 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 7 Feb 2026 15:24:54 -0700 Subject: [PATCH 18/31] fix: prevent parent task state loss during orchestrator delegation (#11281) --- .../__tests__/apiMessages.spec.ts | 86 ++++ .../__tests__/taskMessages.spec.ts | 35 +- src/core/task-persistence/apiMessages.ts | 30 +- src/core/task-persistence/taskMessages.ts | 16 +- src/core/task/Task.ts | 57 ++- .../task/__tests__/Task.persistence.spec.ts | 471 ++++++++++++++++++ .../flushPendingToolResultsToHistory.spec.ts | 4 + src/core/webview/ClineProvider.ts | 69 ++- .../webview/__tests__/ClineProvider.spec.ts | 49 ++ 9 files changed, 772 insertions(+), 45 deletions(-) create mode 100644 src/core/task-persistence/__tests__/apiMessages.spec.ts create mode 100644 src/core/task/__tests__/Task.persistence.spec.ts diff --git a/src/core/task-persistence/__tests__/apiMessages.spec.ts b/src/core/task-persistence/__tests__/apiMessages.spec.ts new file mode 100644 index 00000000000..aa725f47442 --- /dev/null +++ b/src/core/task-persistence/__tests__/apiMessages.spec.ts @@ -0,0 +1,86 @@ +// cd src && npx vitest run core/task-persistence/__tests__/apiMessages.spec.ts + +import * as os from "os" +import * as path from "path" +import * as fs from "fs/promises" + +import { readApiMessages } from "../apiMessages" + +let tmpBaseDir: string + +beforeEach(async () => { + tmpBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-api-")) +}) + +describe("apiMessages.readApiMessages", () => { + it("returns empty array when api_conversation_history.json contains invalid JSON", async () => { + const taskId = "task-corrupt-api" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "api_conversation_history.json") + await fs.writeFile(filePath, "<<>>", "utf8") + + const result = await readApiMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + }) + + it("returns empty array when claude_messages.json fallback contains invalid JSON", async () => { + const taskId = "task-corrupt-fallback" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + + // Only write the old fallback file (claude_messages.json), NOT the new one + const oldPath = path.join(taskDir, "claude_messages.json") + await fs.writeFile(oldPath, "not json at all {[!", "utf8") + + const result = await readApiMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + + // The corrupted fallback file should NOT be deleted + const stillExists = await fs + .access(oldPath) + .then(() => true) + .catch(() => false) + expect(stillExists).toBe(true) + }) + + it("returns [] when file contains valid JSON that is not an array", async () => { + const taskId = "task-non-array-api" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "api_conversation_history.json") + await fs.writeFile(filePath, JSON.stringify("hello"), "utf8") + + const result = await readApiMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + }) + + it("returns [] when fallback file contains valid JSON that is not an array", async () => { + const taskId = "task-non-array-fallback" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + + // Only write the old fallback file, NOT the new one + const oldPath = path.join(taskDir, "claude_messages.json") + await fs.writeFile(oldPath, JSON.stringify({ key: "value" }), "utf8") + + const result = await readApiMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + }) +}) diff --git a/src/core/task-persistence/__tests__/taskMessages.spec.ts b/src/core/task-persistence/__tests__/taskMessages.spec.ts index 98148d6ed61..c6bc360c052 100644 --- a/src/core/task-persistence/__tests__/taskMessages.spec.ts +++ b/src/core/task-persistence/__tests__/taskMessages.spec.ts @@ -12,7 +12,7 @@ vi.mock("../../../utils/safeWriteJson", () => ({ })) // Import after mocks -import { saveTaskMessages } from "../taskMessages" +import { saveTaskMessages, readTaskMessages } from "../taskMessages" let tmpBaseDir: string @@ -66,3 +66,36 @@ describe("taskMessages.saveTaskMessages", () => { expect(persisted).toEqual(messages) }) }) + +describe("taskMessages.readTaskMessages", () => { + it("returns empty array when file contains invalid JSON", async () => { + const taskId = "task-corrupt-json" + // Manually create the task directory and write corrupted JSON + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "ui_messages.json") + await fs.writeFile(filePath, "{not valid json!!!", "utf8") + + const result = await readTaskMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + }) + + it("returns [] when file contains valid JSON that is not an array", async () => { + const taskId = "task-non-array-json" + const taskDir = path.join(tmpBaseDir, "tasks", taskId) + await fs.mkdir(taskDir, { recursive: true }) + const filePath = path.join(taskDir, "ui_messages.json") + await fs.writeFile(filePath, JSON.stringify("hello"), "utf8") + + const result = await readTaskMessages({ + taskId, + globalStoragePath: tmpBaseDir, + }) + + expect(result).toEqual([]) + }) +}) diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 097679e4a7b..7672f6f7ee6 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -51,17 +51,23 @@ export async function readApiMessages({ const fileContent = await fs.readFile(filePath, "utf8") try { const parsedData = JSON.parse(fileContent) - if (Array.isArray(parsedData) && parsedData.length === 0) { + if (!Array.isArray(parsedData)) { + console.warn( + `[readApiMessages] Parsed data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${filePath}`, + ) + return [] + } + if (parsedData.length === 0) { console.error( `[Roo-Debug] readApiMessages: Found API conversation history file, but it's empty (parsed as []). TaskId: ${taskId}, Path: ${filePath}`, ) } return parsedData } catch (error) { - console.error( - `[Roo-Debug] readApiMessages: Error parsing API conversation history file. TaskId: ${taskId}, Path: ${filePath}, Error: ${error}`, + console.warn( + `[readApiMessages] Error parsing API conversation history file, returning empty. TaskId: ${taskId}, Path: ${filePath}, Error: ${error}`, ) - throw error + return [] } } else { const oldPath = path.join(taskDir, "claude_messages.json") @@ -70,7 +76,13 @@ export async function readApiMessages({ const fileContent = await fs.readFile(oldPath, "utf8") try { const parsedData = JSON.parse(fileContent) - if (Array.isArray(parsedData) && parsedData.length === 0) { + if (!Array.isArray(parsedData)) { + console.warn( + `[readApiMessages] Parsed OLD data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${oldPath}`, + ) + return [] + } + if (parsedData.length === 0) { console.error( `[Roo-Debug] readApiMessages: Found OLD API conversation history file (claude_messages.json), but it's empty (parsed as []). TaskId: ${taskId}, Path: ${oldPath}`, ) @@ -78,11 +90,11 @@ export async function readApiMessages({ await fs.unlink(oldPath) return parsedData } catch (error) { - console.error( - `[Roo-Debug] readApiMessages: Error parsing OLD API conversation history file (claude_messages.json). TaskId: ${taskId}, Path: ${oldPath}, Error: ${error}`, + console.warn( + `[readApiMessages] Error parsing OLD API conversation history file (claude_messages.json), returning empty. TaskId: ${taskId}, Path: ${oldPath}, Error: ${error}`, ) - // DO NOT unlink oldPath if parsing failed, throw error instead. - throw error + // DO NOT unlink oldPath if parsing failed. + return [] } } } diff --git a/src/core/task-persistence/taskMessages.ts b/src/core/task-persistence/taskMessages.ts index 63a2eefbaae..cee66432d9d 100644 --- a/src/core/task-persistence/taskMessages.ts +++ b/src/core/task-persistence/taskMessages.ts @@ -23,7 +23,21 @@ export async function readTaskMessages({ const fileExists = await fileExistsAtPath(filePath) if (fileExists) { - return JSON.parse(await fs.readFile(filePath, "utf8")) + try { + const parsedData = JSON.parse(await fs.readFile(filePath, "utf8")) + if (!Array.isArray(parsedData)) { + console.warn( + `[readTaskMessages] Parsed data is not an array (got ${typeof parsedData}), returning empty. TaskId: ${taskId}, Path: ${filePath}`, + ) + return [] + } + return parsedData + } catch (error) { + console.warn( + `[readTaskMessages] Failed to parse ${filePath} for task ${taskId}, returning empty: ${error instanceof Error ? error.message : String(error)}`, + ) + return [] + } } return [] diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index f4e41c1bfd7..ef6e956dff8 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1203,10 +1203,10 @@ export class Task extends EventEmitter implements TaskLike { * tools execute (added in recursivelyMakeClineRequests after streaming completes). * So we usually only need to flush the pending user message with tool_results. */ - public async flushPendingToolResultsToHistory(): Promise { + public async flushPendingToolResultsToHistory(): Promise { // Only flush if there's actually pending content to save if (this.userMessageContent.length === 0) { - return + return true } // CRITICAL: Wait for the assistant message to be saved to API history first. @@ -1236,7 +1236,7 @@ export class Task extends EventEmitter implements TaskLike { // If task was aborted while waiting, don't flush if (this.abort) { - return + return false } // Save the user message with tool_result blocks @@ -1253,25 +1253,58 @@ export class Task extends EventEmitter implements TaskLike { const userMessageWithTs = { ...validatedMessage, ts: Date.now() } this.apiConversationHistory.push(userMessageWithTs as ApiMessage) - await this.saveApiConversationHistory() + const saved = await this.saveApiConversationHistory() + + if (saved) { + // Clear the pending content since it's now saved + this.userMessageContent = [] + } else { + console.warn( + `[Task#${this.taskId}] flushPendingToolResultsToHistory: save failed, retaining pending tool results in memory`, + ) + } - // Clear the pending content since it's now saved - this.userMessageContent = [] + return saved } - private async saveApiConversationHistory() { + private async saveApiConversationHistory(): Promise { try { await saveApiMessages({ - messages: this.apiConversationHistory, + messages: structuredClone(this.apiConversationHistory), taskId: this.taskId, globalStoragePath: this.globalStoragePath, }) + return true } catch (error) { - // In the off chance this fails, we don't want to stop the task. console.error("Failed to save API conversation history:", error) + return false } } + /** + * Public wrapper to retry saving the API conversation history. + * Uses exponential backoff: up to 3 attempts with delays of 100 ms, 500 ms, 1500 ms. + * Used by delegation flow when flushPendingToolResultsToHistory reports failure. + */ + public async retrySaveApiConversationHistory(): Promise { + const delays = [100, 500, 1500] + + for (let attempt = 0; attempt < delays.length; attempt++) { + await new Promise((resolve) => setTimeout(resolve, delays[attempt])) + console.warn( + `[Task#${this.taskId}] retrySaveApiConversationHistory: retry attempt ${attempt + 1}/${delays.length}`, + ) + + const success = await this.saveApiConversationHistory() + + if (success) { + return true + } + } + + return false + } + // Cline Messages private async getSavedClineMessages(): Promise { @@ -1333,10 +1366,10 @@ export class Task extends EventEmitter implements TaskLike { } } - private async saveClineMessages() { + private async saveClineMessages(): Promise { try { await saveTaskMessages({ - messages: this.clineMessages, + messages: structuredClone(this.clineMessages), taskId: this.taskId, globalStoragePath: this.globalStoragePath, }) @@ -1366,8 +1399,10 @@ export class Task extends EventEmitter implements TaskLike { this.debouncedEmitTokenUsage(tokenUsage, this.toolUsage) await this.providerRef.deref()?.updateTaskHistory(historyItem) + return true } catch (error) { console.error("Failed to save Roo messages:", error) + return false } } diff --git a/src/core/task/__tests__/Task.persistence.spec.ts b/src/core/task/__tests__/Task.persistence.spec.ts new file mode 100644 index 00000000000..1e4acc9713b --- /dev/null +++ b/src/core/task/__tests__/Task.persistence.spec.ts @@ -0,0 +1,471 @@ +// cd src && npx vitest run core/task/__tests__/Task.persistence.spec.ts + +import * as os from "os" +import * as path from "path" +import * as vscode from "vscode" + +import type { GlobalState, ProviderSettings } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" + +import { Task } from "../Task" +import { ClineProvider } from "../../webview/ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" + +// ─── Hoisted mocks ─────────────────────────────────────────────────────────── + +const { + mockSaveApiMessages, + mockSaveTaskMessages, + mockReadApiMessages, + mockReadTaskMessages, + mockTaskMetadata, + mockPWaitFor, +} = vi.hoisted(() => ({ + mockSaveApiMessages: vi.fn().mockResolvedValue(undefined), + mockSaveTaskMessages: vi.fn().mockResolvedValue(undefined), + mockReadApiMessages: vi.fn().mockResolvedValue([]), + mockReadTaskMessages: vi.fn().mockResolvedValue([]), + mockTaskMetadata: vi.fn().mockResolvedValue({ + historyItem: { id: "test-id", ts: Date.now(), task: "test" }, + tokenUsage: { + totalTokensIn: 0, + totalTokensOut: 0, + totalCacheWrites: 0, + totalCacheReads: 0, + totalCost: 0, + contextTokens: 0, + }, + }), + mockPWaitFor: vi.fn().mockResolvedValue(undefined), +})) + +// ─── Module mocks ──────────────────────────────────────────────────────────── + +vi.mock("delay", () => ({ + __esModule: true, + default: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("execa", () => ({ + execa: vi.fn(), +})) + +vi.mock("fs/promises", async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue("[]"), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + default: { + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue("[]"), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + }, + } +}) + +vi.mock("p-wait-for", () => ({ + default: mockPWaitFor, +})) + +vi.mock("../../task-persistence", () => ({ + saveApiMessages: mockSaveApiMessages, + saveTaskMessages: mockSaveTaskMessages, + readApiMessages: mockReadApiMessages, + readTaskMessages: mockReadTaskMessages, + taskMetadata: mockTaskMetadata, +})) + +vi.mock("vscode", () => { + const mockDisposable = { dispose: vi.fn() } + const mockEventEmitter = { event: vi.fn(), fire: vi.fn() } + const mockTextDocument = { uri: { fsPath: "/mock/workspace/path/file.ts" } } + const mockTextEditor = { document: mockTextDocument } + const mockTab = { input: { uri: { fsPath: "/mock/workspace/path/file.ts" } } } + const mockTabGroup = { tabs: [mockTab] } + + return { + TabInputTextDiff: vi.fn(), + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + window: { + createTextEditorDecorationType: vi.fn().mockReturnValue({ dispose: vi.fn() }), + visibleTextEditors: [mockTextEditor], + tabGroups: { + all: [mockTabGroup], + close: vi.fn(), + onDidChangeTabs: vi.fn(() => ({ dispose: vi.fn() })), + }, + showErrorMessage: vi.fn(), + }, + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/mock/workspace/path" }, + name: "mock-workspace", + index: 0, + }, + ], + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(() => mockDisposable), + onDidDelete: vi.fn(() => mockDisposable), + onDidChange: vi.fn(() => mockDisposable), + dispose: vi.fn(), + })), + fs: { + stat: vi.fn().mockResolvedValue({ type: 1 }), + }, + onDidSaveTextDocument: vi.fn(() => mockDisposable), + getConfiguration: vi.fn(() => ({ get: (_key: string, defaultValue: unknown) => defaultValue })), + }, + env: { + uriScheme: "vscode", + language: "en", + }, + EventEmitter: vi.fn().mockImplementation(() => mockEventEmitter), + Disposable: { + from: vi.fn(), + }, + TabInputText: vi.fn(), + } +}) + +vi.mock("../../mentions", () => ({ + parseMentions: vi.fn().mockImplementation((text) => { + return Promise.resolve({ text: `processed: ${text}`, mode: undefined, contentBlocks: [] }) + }), + openMention: vi.fn(), + getLatestTerminalOutput: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("../../environment/getEnvironmentDetails", () => ({ + getEnvironmentDetails: vi.fn().mockResolvedValue(""), +})) + +vi.mock("../../ignore/RooIgnoreController") + +vi.mock("../../condense", async (importOriginal) => { + const actual = (await importOriginal()) as Record + return { + ...actual, + summarizeConversation: vi.fn().mockResolvedValue({ + messages: [{ role: "user", content: [{ type: "text", text: "continued" }], ts: Date.now() }], + summary: "summary", + cost: 0, + newContextTokens: 1, + }), + } +}) + +vi.mock("../../../utils/storage", () => ({ + getTaskDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath, taskId) => Promise.resolve(`${globalStoragePath}/tasks/${taskId}`)), + getSettingsDirectoryPath: vi + .fn() + .mockImplementation((globalStoragePath) => Promise.resolve(`${globalStoragePath}/settings`)), +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockReturnValue(false), +})) + +// ─── Test suite ────────────────────────────────────────────────────────────── + +describe("Task persistence", () => { + let mockProvider: ClineProvider & Record + let mockApiConfig: ProviderSettings + let mockOutputChannel: vscode.OutputChannel + let mockExtensionContext: vscode.ExtensionContext + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const storageUri = { fsPath: path.join(os.tmpdir(), "test-storage") } + + mockExtensionContext = { + globalState: { + get: vi.fn().mockImplementation((_key: keyof GlobalState) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + globalStorageUri: storageUri, + workspaceState: { + get: vi.fn().mockImplementation((_key) => undefined), + update: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + keys: vi.fn().mockReturnValue([]), + }, + secrets: { + get: vi.fn().mockImplementation((_key) => Promise.resolve(undefined)), + store: vi.fn().mockImplementation((_key, _value) => Promise.resolve()), + delete: vi.fn().mockImplementation((_key) => Promise.resolve()), + }, + extensionUri: { fsPath: "/mock/extension/path" }, + extension: { packageJSON: { version: "1.0.0" } }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + append: vi.fn(), + clear: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + + mockProvider = new ClineProvider( + mockExtensionContext, + mockOutputChannel, + "sidebar", + new ContextProxy(mockExtensionContext), + ) as ClineProvider & Record + + mockApiConfig = { + apiProvider: "anthropic", + apiModelId: "claude-3-5-sonnet-20241022", + apiKey: "test-api-key", + } + + mockProvider.postMessageToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebview = vi.fn().mockResolvedValue(undefined) + mockProvider.postStateToWebviewWithoutTaskHistory = vi.fn().mockResolvedValue(undefined) + mockProvider.updateTaskHistory = vi.fn().mockResolvedValue(undefined) + }) + + // ── saveApiConversationHistory (via retrySaveApiConversationHistory) ── + + describe("saveApiConversationHistory", () => { + it("returns true on success", async () => { + mockSaveApiMessages.mockResolvedValueOnce(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + task.apiConversationHistory.push({ + role: "user", + content: [{ type: "text", text: "hello" }], + }) + + const result = await task.retrySaveApiConversationHistory() + expect(result).toBe(true) + }) + + it("returns false on failure", async () => { + vi.useFakeTimers() + + // All 3 retry attempts must fail for retrySaveApiConversationHistory to return false + mockSaveApiMessages + .mockRejectedValueOnce(new Error("fail 1")) + .mockRejectedValueOnce(new Error("fail 2")) + .mockRejectedValueOnce(new Error("fail 3")) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const promise = task.retrySaveApiConversationHistory() + await vi.runAllTimersAsync() + const result = await promise + + expect(result).toBe(false) + expect(mockSaveApiMessages).toHaveBeenCalledTimes(3) + + vi.useRealTimers() + }) + + it("succeeds on 2nd retry attempt", async () => { + vi.useFakeTimers() + + mockSaveApiMessages.mockRejectedValueOnce(new Error("fail 1")).mockResolvedValueOnce(undefined) // succeeds on 2nd try + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const promise = task.retrySaveApiConversationHistory() + await vi.runAllTimersAsync() + const result = await promise + + expect(result).toBe(true) + expect(mockSaveApiMessages).toHaveBeenCalledTimes(2) + + vi.useRealTimers() + }) + + it("snapshots the array before passing to saveApiMessages", async () => { + mockSaveApiMessages.mockResolvedValueOnce(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const originalMsg = { + role: "user" as const, + content: [{ type: "text" as const, text: "snapshot test" }], + } + task.apiConversationHistory.push(originalMsg) + + await task.retrySaveApiConversationHistory() + + expect(mockSaveApiMessages).toHaveBeenCalledTimes(1) + + const callArgs = mockSaveApiMessages.mock.calls[0][0] + // The messages passed should be a COPY, not the live reference + expect(callArgs.messages).not.toBe(task.apiConversationHistory) + // But the content should be the same + expect(callArgs.messages).toEqual(task.apiConversationHistory) + }) + }) + + // ── saveClineMessages ──────────────────────────────────────────────── + + describe("saveClineMessages", () => { + it("returns true on success", async () => { + mockSaveTaskMessages.mockResolvedValueOnce(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const result = await (task as Record).saveClineMessages() + expect(result).toBe(true) + }) + + it("returns false on failure", async () => { + mockSaveTaskMessages.mockRejectedValueOnce(new Error("write error")) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + const result = await (task as Record).saveClineMessages() + expect(result).toBe(false) + }) + + it("snapshots the array before passing to saveTaskMessages", async () => { + mockSaveTaskMessages.mockResolvedValueOnce(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + task.clineMessages.push({ + type: "say", + say: "text", + text: "snapshot test", + ts: Date.now(), + }) + + await (task as Record).saveClineMessages() + + expect(mockSaveTaskMessages).toHaveBeenCalledTimes(1) + + const callArgs = mockSaveTaskMessages.mock.calls[0][0] + // The messages passed should be a COPY, not the live reference + expect(callArgs.messages).not.toBe(task.clineMessages) + // But the content should be the same + expect(callArgs.messages).toEqual(task.clineMessages) + }) + }) + + // ── flushPendingToolResultsToHistory — save failure/success ─────────── + + describe("flushPendingToolResultsToHistory persistence", () => { + it("retains userMessageContent on save failure", async () => { + mockSaveApiMessages.mockRejectedValueOnce(new Error("disk full")) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Skip waiting for assistant message + task.assistantMessageSavedToHistory = true + + task.userMessageContent = [ + { + type: "tool_result", + tool_use_id: "tool-fail", + content: "Result that should be retained", + }, + ] + + const saved = await task.flushPendingToolResultsToHistory() + + expect(saved).toBe(false) + // userMessageContent should NOT be cleared on failure + expect(task.userMessageContent.length).toBeGreaterThan(0) + expect(task.userMessageContent[0]).toMatchObject({ + type: "tool_result", + tool_use_id: "tool-fail", + }) + }) + + it("clears userMessageContent on save success", async () => { + mockSaveApiMessages.mockResolvedValueOnce(undefined) + + const task = new Task({ + provider: mockProvider, + apiConfiguration: mockApiConfig, + task: "test task", + startTask: false, + }) + + // Skip waiting for assistant message + task.assistantMessageSavedToHistory = true + + task.userMessageContent = [ + { + type: "tool_result", + tool_use_id: "tool-ok", + content: "Result that should be cleared", + }, + ] + + const saved = await task.flushPendingToolResultsToHistory() + + expect(saved).toBe(true) + // userMessageContent should be cleared on success + expect(task.userMessageContent).toEqual([]) + }) + }) +}) diff --git a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts index f4d78802d29..f19645d9697 100644 --- a/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts +++ b/src/core/task/__tests__/flushPendingToolResultsToHistory.spec.ts @@ -21,6 +21,10 @@ vi.mock("execa", () => ({ execa: vi.fn(), })) +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn().mockResolvedValue(undefined), +})) + vi.mock("fs/promises", async (importOriginal) => { const actual = (await importOriginal()) as Record const mockFunctions = { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b598a27c272..5ff0f1c3658 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1665,31 +1665,40 @@ export class ClineProvider const history = this.getGlobalState("taskHistory") ?? [] const historyItem = history.find((item) => item.id === id) - if (historyItem) { - const { getTaskDirectoryPath } = await import("../../utils/storage") - const globalStoragePath = this.contextProxy.globalStorageUri.fsPath - const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id) - const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory) - const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) - const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) - - if (fileExists) { - const apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8")) - - return { - historyItem, - taskDirPath, - apiConversationHistoryFilePath, - uiMessagesFilePath, - apiConversationHistory, - } + if (!historyItem) { + throw new Error("Task not found") + } + + const { getTaskDirectoryPath } = await import("../../utils/storage") + const globalStoragePath = this.contextProxy.globalStorageUri.fsPath + const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id) + const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory) + const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages) + const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath) + + let apiConversationHistory: Anthropic.MessageParam[] = [] + + if (fileExists) { + try { + apiConversationHistory = JSON.parse(await fs.readFile(apiConversationHistoryFilePath, "utf8")) + } catch (error) { + console.warn( + `[getTaskWithId] api_conversation_history.json corrupted for task ${id}, returning empty history: ${error instanceof Error ? error.message : String(error)}`, + ) } + } else { + console.warn( + `[getTaskWithId] api_conversation_history.json missing for task ${id}, returning empty history`, + ) } - // if we tried to get a task that doesn't exist, remove it from state - // FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason - await this.deleteTaskFromState(id) - throw new Error("Task not found") + return { + historyItem, + taskDirPath, + apiConversationHistoryFilePath, + uiMessagesFilePath, + apiConversationHistory, + } } async getTaskWithAggregatedCosts(taskId: string): Promise<{ @@ -3182,7 +3191,21 @@ export class ClineProvider // recursivelyMakeClineRequests BEFORE tools start executing. We only need to // flush the pending user message with tool_results. try { - await parent.flushPendingToolResultsToHistory() + const flushSuccess = await parent.flushPendingToolResultsToHistory() + + if (!flushSuccess) { + console.warn(`[delegateParentAndOpenChild] Flush failed for parent ${parentTaskId}, retrying...`) + const retrySuccess = await parent.retrySaveApiConversationHistory() + + if (!retrySuccess) { + console.error( + `[delegateParentAndOpenChild] CRITICAL: Parent ${parentTaskId} API history not persisted to disk. Child return may produce stale state.`, + ) + vscode.window.showWarningMessage( + "Warning: Parent task state could not be saved. The parent task may lose recent context when resumed.", + ) + } + } } catch (error) { this.log( `[delegateParentAndOpenChild] Error flushing pending tool results (non-fatal): ${ diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index b65b137597c..26f6fbd8aba 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -3770,4 +3770,53 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { }) }) }) + + describe("getTaskWithId", () => { + it("returns empty apiConversationHistory when file is missing", async () => { + const historyItem = { id: "missing-api-file-task", task: "test task", ts: Date.now() } + vi.mocked(mockContext.globalState.get).mockImplementation((key: string) => { + if (key === "taskHistory") { + return [historyItem] + } + return undefined + }) + + const deleteTaskSpy = vi.spyOn(provider, "deleteTaskFromState") + + const result = await (provider as any).getTaskWithId("missing-api-file-task") + + expect(result.historyItem).toEqual(historyItem) + expect(result.apiConversationHistory).toEqual([]) + expect(deleteTaskSpy).not.toHaveBeenCalled() + }) + + it("returns empty apiConversationHistory when file contains invalid JSON", async () => { + const historyItem = { id: "corrupt-api-task", task: "test task", ts: Date.now() } + vi.mocked(mockContext.globalState.get).mockImplementation((key: string) => { + if (key === "taskHistory") { + return [historyItem] + } + return undefined + }) + + // Make fileExistsAtPath return true so the read path is exercised + const fsUtils = await import("../../../utils/fs") + vi.spyOn(fsUtils, "fileExistsAtPath").mockResolvedValue(true) + + // Make readFile return corrupted JSON + const fsp = await import("fs/promises") + vi.mocked(fsp.readFile).mockResolvedValueOnce("{not valid json!!!" as never) + + const deleteTaskSpy = vi.spyOn(provider, "deleteTaskFromState") + + const result = await (provider as any).getTaskWithId("corrupt-api-task") + + expect(result.historyItem).toEqual(historyItem) + expect(result.apiConversationHistory).toEqual([]) + expect(deleteTaskSpy).not.toHaveBeenCalled() + + // Restore the spy + vi.mocked(fsUtils.fileExistsAtPath).mockRestore() + }) + }) }) From 5d17f56db749cdbacb39878df514b6f811105154 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 7 Feb 2026 20:31:22 -0700 Subject: [PATCH 19/31] feat: add lock toggle to pin API config across all modes in workspace (#11295) * feat: add lock toggle to pin API config across all modes in workspace Add a lock/unlock toggle inside the API config selector popover (next to the settings gear) that, when enabled, applies the selected API configuration to all modes in the current workspace. - Add lockApiConfigAcrossModes to ExtensionState and WebviewMessage types - Store setting in workspaceState (per-workspace, not global) - When locked, activateProviderProfile sets config for all modes - Lock icon in ApiConfigSelector popover bottom bar next to gear - Full i18n: English + 17 locale translations (all mention workspace scope) - 9 new tests: 2 ClineProvider, 2 handler, 5 UI (77 total pass) * refactor: replace write-fan-out with read-time override for lock API config The original lock implementation used setModeConfig() fan-out to write the locked config to ALL modes globally. Since the lock flag lives in workspace- scoped workspaceState but modeApiConfigs are in global secrets, this caused cross-workspace data destruction. Replaced with read-time guards: - handleModeSwitch: early return when lock is on (skip per-mode config load) - createTaskWithHistoryItem: skip mode-based config restoration under lock - activateProviderProfile: removed fan-out block - lockApiConfigAcrossModes handler: simplified to flag + state post only - Fixed pre-existing workspaceState mock gap in ClineProvider.spec.ts and ClineProvider.sticky-profile.spec.ts --- packages/types/src/vscode-extension-host.ts | 2 + pnpm-lock.yaml | 6 +- src/core/webview/ClineProvider.ts | 13 +- .../ClineProvider.apiHandlerRebuild.spec.ts | 5 + .../ClineProvider.lockApiConfig.spec.ts | 372 ++++++++++++++++++ .../webview/__tests__/ClineProvider.spec.ts | 30 ++ .../ClineProvider.sticky-mode.spec.ts | 5 + .../ClineProvider.sticky-profile.spec.ts | 5 + .../ClineProvider.taskHistory.spec.ts | 5 + ...ebviewMessageHandler.lockApiConfig.spec.ts | 68 ++++ src/core/webview/webviewMessageHandler.ts | 8 + .../src/components/chat/ApiConfigSelector.tsx | 14 + .../src/components/chat/ChatTextArea.tsx | 8 + .../chat/__tests__/ApiConfigSelector.spec.tsx | 2 + .../ChatTextArea.lockApiConfig.spec.tsx | 156 ++++++++ .../src/context/ExtensionStateContext.tsx | 1 + webview-ui/src/i18n/locales/ca/chat.json | 2 + webview-ui/src/i18n/locales/de/chat.json | 2 + webview-ui/src/i18n/locales/en/chat.json | 2 + webview-ui/src/i18n/locales/es/chat.json | 2 + webview-ui/src/i18n/locales/fr/chat.json | 2 + webview-ui/src/i18n/locales/hi/chat.json | 2 + webview-ui/src/i18n/locales/id/chat.json | 2 + webview-ui/src/i18n/locales/it/chat.json | 2 + webview-ui/src/i18n/locales/ja/chat.json | 2 + webview-ui/src/i18n/locales/ko/chat.json | 2 + webview-ui/src/i18n/locales/nl/chat.json | 2 + webview-ui/src/i18n/locales/pl/chat.json | 2 + webview-ui/src/i18n/locales/pt-BR/chat.json | 2 + webview-ui/src/i18n/locales/ru/chat.json | 2 + webview-ui/src/i18n/locales/tr/chat.json | 2 + webview-ui/src/i18n/locales/vi/chat.json | 2 + webview-ui/src/i18n/locales/zh-CN/chat.json | 2 + webview-ui/src/i18n/locales/zh-TW/chat.json | 2 + 34 files changed, 732 insertions(+), 4 deletions(-) create mode 100644 src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts create mode 100644 src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts create mode 100644 webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 51c7fa49d5e..49a63c3ae48 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -336,6 +336,7 @@ export type ExtensionState = Pick< | "showWorktreesInHomeScreen" | "disabledTools" > & { + lockApiConfigAcrossModes?: boolean version: string clineMessages: ClineMessage[] currentTaskItem?: HistoryItem @@ -524,6 +525,7 @@ export interface WebviewMessage { | "searchFiles" | "toggleApiConfigPin" | "hasOpenedModeSelector" + | "lockApiConfigAcrossModes" | "clearCloudAuthSkipModel" | "cloudButtonClicked" | "rooCloudSignIn" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ff809db9add..7f48e153c9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14958,7 +14958,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) '@vitest/utils@3.2.4': dependencies: @@ -22251,8 +22251,8 @@ snapshots: zhipu-ai-provider@0.2.2(zod@3.25.76): dependencies: - '@ai-sdk/provider': 2.0.1 - '@ai-sdk/provider-utils': 3.0.20(zod@3.25.76) + '@ai-sdk/provider': 2.0.0 + '@ai-sdk/provider-utils': 3.0.5(zod@3.25.76) transitivePeerDependencies: - zod diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5ff0f1c3658..fc15a8dd5c3 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -899,7 +899,8 @@ export class ClineProvider // Load the saved API config for the restored mode if it exists. // Skip mode-based profile activation if historyItem.apiConfigName exists, // since the task's specific provider profile will override it anyway. - if (!historyItem.apiConfigName) { + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (!historyItem.apiConfigName && !lockApiConfigAcrossModes) { const savedConfigId = await this.providerSettingsManager.getModeConfigId(historyItem.mode) const listApiConfig = await this.providerSettingsManager.listConfig() @@ -1316,6 +1317,13 @@ export class ClineProvider this.emit(RooCodeEventName.ModeChanged, newMode) + // If workspace lock is on, keep the current API config — don't load mode-specific config + const lockApiConfigAcrossModes = this.context.workspaceState.get("lockApiConfigAcrossModes", false) + if (lockApiConfigAcrossModes) { + await this.postStateToWebview() + return + } + // Load the saved API config for the new mode if it exists. const savedConfigId = await this.providerSettingsManager.getModeConfigId(newMode) const listApiConfig = await this.providerSettingsManager.listConfig() @@ -2081,6 +2089,7 @@ export class ClineProvider openRouterImageGenerationSelectedModel, featureRoomoteControlEnabled, isBrowserSessionActive, + lockApiConfigAcrossModes, } = await this.getState() let cloudOrganizations: CloudOrganizationMembership[] = [] @@ -2229,6 +2238,7 @@ export class ClineProvider profileThresholds: profileThresholds ?? {}, cloudApiUrl: getRooCodeApiUrl(), hasOpenedModeSelector: this.getGlobalState("hasOpenedModeSelector") ?? false, + lockApiConfigAcrossModes: lockApiConfigAcrossModes ?? false, alwaysAllowFollowupQuestions: alwaysAllowFollowupQuestions ?? false, followupAutoApproveTimeoutMs: followupAutoApproveTimeoutMs ?? 60000, includeDiagnosticMessages: includeDiagnosticMessages ?? true, @@ -2464,6 +2474,7 @@ export class ClineProvider stateValues.codebaseIndexConfig?.codebaseIndexOpenRouterSpecificProvider, }, profileThresholds: stateValues.profileThresholds ?? {}, + lockApiConfigAcrossModes: this.context.workspaceState.get("lockApiConfigAcrossModes", false), includeDiagnosticMessages: stateValues.includeDiagnosticMessages ?? true, maxDiagnosticMessages: stateValues.maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: stateValues.includeTaskHistoryInEnhance ?? true, diff --git a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts index 04f5d577929..9e57ae94b81 100644 --- a/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.apiHandlerRebuild.spec.ts @@ -171,6 +171,11 @@ describe("ClineProvider - API Handler Rebuild Guard", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts new file mode 100644 index 00000000000..9b5e3b16ee6 --- /dev/null +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -0,0 +1,372 @@ +// npx vitest run core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts + +import * as vscode from "vscode" +import { TelemetryService } from "@roo-code/telemetry" +import { ClineProvider } from "../ClineProvider" +import { ContextProxy } from "../../config/ContextProxy" + +vi.mock("vscode", () => ({ + ExtensionContext: vi.fn(), + OutputChannel: vi.fn(), + WebviewView: vi.fn(), + Uri: { + joinPath: vi.fn(), + file: vi.fn(), + }, + CodeActionKind: { + QuickFix: { value: "quickfix" }, + RefactorRewrite: { value: "refactor.rewrite" }, + }, + commands: { + executeCommand: vi.fn().mockResolvedValue(undefined), + }, + window: { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), + }, + workspace: { + getConfiguration: vi.fn().mockReturnValue({ + get: vi.fn().mockReturnValue([]), + update: vi.fn(), + }), + onDidChangeConfiguration: vi.fn().mockImplementation(() => ({ + dispose: vi.fn(), + })), + onDidSaveTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidChangeTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + onDidCloseTextDocument: vi.fn(() => ({ dispose: vi.fn() })), + }, + env: { + uriScheme: "vscode", + language: "en", + appName: "Visual Studio Code", + }, + ExtensionMode: { + Production: 1, + Development: 2, + Test: 3, + }, + version: "1.85.0", +})) + +vi.mock("../../task/Task", () => ({ + Task: vi.fn().mockImplementation((options) => ({ + taskId: options.taskId || "test-task-id", + saveClineMessages: vi.fn(), + clineMessages: [], + apiConversationHistory: [], + overwriteClineMessages: vi.fn(), + overwriteApiConversationHistory: vi.fn(), + abortTask: vi.fn(), + handleWebviewAskResponse: vi.fn(), + getTaskNumber: vi.fn().mockReturnValue(0), + setTaskNumber: vi.fn(), + setParentTask: vi.fn(), + setRootTask: vi.fn(), + emit: vi.fn(), + parentTask: options.parentTask, + updateApiConfiguration: vi.fn(), + setTaskApiConfigName: vi.fn(), + _taskApiConfigName: options.historyItem?.apiConfigName, + taskApiConfigName: options.historyItem?.apiConfigName, + })), +})) + +vi.mock("../../prompts/sections/custom-instructions") + +vi.mock("../../../utils/safeWriteJson") + +vi.mock("../../../api", () => ({ + buildApiHandler: vi.fn().mockReturnValue({ + getModel: vi.fn().mockReturnValue({ + id: "claude-3-sonnet", + }), + }), +})) + +vi.mock("../../../integrations/workspace/WorkspaceTracker", () => ({ + default: vi.fn().mockImplementation(() => ({ + initializeFilePaths: vi.fn(), + dispose: vi.fn(), + })), +})) + +vi.mock("../../diff/strategies/multi-search-replace", () => ({ + MultiSearchReplaceDiffStrategy: vi.fn().mockImplementation(() => ({ + getName: () => "test-strategy", + applyDiff: vi.fn(), + })), +})) + +vi.mock("@roo-code/cloud", () => ({ + CloudService: { + hasInstance: vi.fn().mockReturnValue(true), + get instance() { + return { + isAuthenticated: vi.fn().mockReturnValue(false), + } + }, + }, + BridgeOrchestrator: { + isEnabled: vi.fn().mockReturnValue(false), + }, + getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), +})) + +vi.mock("../../../shared/modes", () => { + const mockModes = [ + { + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit", "browser"], + }, + { + slug: "architect", + name: "Architect Mode", + roleDefinition: "You are an architect", + groups: ["read", "edit"], + }, + { + slug: "ask", + name: "Ask Mode", + roleDefinition: "You are an assistant", + groups: ["read"], + }, + { + slug: "debug", + name: "Debug Mode", + roleDefinition: "You are a debugger", + groups: ["read", "edit"], + }, + { + slug: "orchestrator", + name: "Orchestrator Mode", + roleDefinition: "You are an orchestrator", + groups: [], + }, + ] + + return { + modes: mockModes, + getAllModes: vi.fn((customModes?: Array<{ slug: string }>) => { + if (!customModes?.length) { + return [...mockModes] + } + const allModes = [...mockModes] + customModes.forEach((cm) => { + const idx = allModes.findIndex((m) => m.slug === cm.slug) + if (idx !== -1) { + allModes[idx] = cm as (typeof mockModes)[number] + } else { + allModes.push(cm as (typeof mockModes)[number]) + } + }) + return allModes + }), + getModeBySlug: vi.fn().mockReturnValue({ + slug: "code", + name: "Code Mode", + roleDefinition: "You are a code assistant", + groups: ["read", "edit", "browser"], + }), + defaultModeSlug: "code", + } +}) + +vi.mock("../../prompts/system", () => ({ + SYSTEM_PROMPT: vi.fn().mockResolvedValue("mocked system prompt"), + codeMode: "code", +})) + +vi.mock("../../../api/providers/fetchers/modelCache", () => ({ + getModels: vi.fn().mockResolvedValue({}), + flushModels: vi.fn(), +})) + +vi.mock("../../../integrations/misc/extract-text", () => ({ + extractTextFromFile: vi.fn().mockResolvedValue("Mock file content"), +})) + +vi.mock("p-wait-for", () => ({ + default: vi.fn().mockImplementation(async () => Promise.resolve()), +})) + +vi.mock("fs/promises", () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(""), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), +})) + +vi.mock("@roo-code/telemetry", () => ({ + TelemetryService: { + hasInstance: vi.fn().mockReturnValue(true), + createInstance: vi.fn(), + get instance() { + return { + trackEvent: vi.fn(), + trackError: vi.fn(), + setProvider: vi.fn(), + captureModeSwitch: vi.fn(), + } + }, + }, +})) + +describe("ClineProvider - Lock API Config Across Modes", () => { + let provider: ClineProvider + let mockContext: vscode.ExtensionContext + let mockOutputChannel: vscode.OutputChannel + let mockWebviewView: vscode.WebviewView + + beforeEach(() => { + vi.clearAllMocks() + + if (!TelemetryService.hasInstance()) { + TelemetryService.createInstance([]) + } + + const globalState: Record = { + mode: "code", + currentApiConfigName: "default-profile", + } + + const workspaceState: Record = {} + + const secrets: Record = {} + + mockContext = { + extensionPath: "/test/path", + extensionUri: {} as vscode.Uri, + globalState: { + get: vi.fn().mockImplementation((key: string) => globalState[key]), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + globalState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => Object.keys(globalState)), + }, + secrets: { + get: vi.fn().mockImplementation((key: string) => secrets[key]), + store: vi.fn().mockImplementation((key: string, value: string | undefined) => { + secrets[key] = value + return Promise.resolve() + }), + delete: vi.fn().mockImplementation((key: string) => { + delete secrets[key] + return Promise.resolve() + }), + }, + workspaceState: { + get: vi.fn().mockImplementation((key: string, defaultValue?: unknown) => { + return key in workspaceState ? workspaceState[key] : defaultValue + }), + update: vi.fn().mockImplementation((key: string, value: unknown) => { + workspaceState[key] = value + return Promise.resolve() + }), + keys: vi.fn().mockImplementation(() => Object.keys(workspaceState)), + }, + subscriptions: [], + extension: { + packageJSON: { version: "1.0.0" }, + }, + globalStorageUri: { + fsPath: "/test/storage/path", + }, + } as unknown as vscode.ExtensionContext + + mockOutputChannel = { + appendLine: vi.fn(), + clear: vi.fn(), + dispose: vi.fn(), + } as unknown as vscode.OutputChannel + + const mockPostMessage = vi.fn() + + mockWebviewView = { + webview: { + postMessage: mockPostMessage, + html: "", + options: {}, + onDidReceiveMessage: vi.fn(), + asWebviewUri: vi.fn(), + cspSource: "vscode-webview://test-csp-source", + }, + visible: true, + onDidDispose: vi.fn().mockImplementation((callback) => { + callback() + return { dispose: vi.fn() } + }), + onDidChangeVisibility: vi.fn().mockImplementation(() => ({ dispose: vi.fn() })), + } as unknown as vscode.WebviewView + + provider = new ClineProvider(mockContext, mockOutputChannel, "sidebar", new ContextProxy(mockContext)) + + // Mock getMcpHub method + provider.getMcpHub = vi.fn().mockReturnValue({ + listTools: vi.fn().mockResolvedValue([]), + callTool: vi.fn().mockResolvedValue({ content: [] }), + listResources: vi.fn().mockResolvedValue([]), + readResource: vi.fn().mockResolvedValue({ contents: [] }), + getAllServers: vi.fn().mockReturnValue([]), + }) + }) + + describe("handleModeSwitch honors lockApiConfigAcrossModes as a read-time override", () => { + beforeEach(async () => { + await provider.resolveWebviewView(mockWebviewView) + }) + + it("skips mode-specific config lookup/load when lockApiConfigAcrossModes is true", async () => { + await mockContext.workspaceState.update("lockApiConfigAcrossModes", true) + + const getModeConfigIdSpy = vi + .spyOn(provider.providerSettingsManager, "getModeConfigId") + .mockResolvedValue("architect-profile-id") + const listConfigSpy = vi + .spyOn(provider.providerSettingsManager, "listConfig") + .mockResolvedValue([ + { name: "architect-profile", id: "architect-profile-id", apiProvider: "anthropic" }, + ]) + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + + await provider.handleModeSwitch("architect") + + expect(getModeConfigIdSpy).not.toHaveBeenCalled() + expect(listConfigSpy).not.toHaveBeenCalled() + expect(activateProviderProfileSpy).not.toHaveBeenCalled() + }) + + it("keeps normal mode-specific lookup/load behavior when lockApiConfigAcrossModes is false", async () => { + await mockContext.workspaceState.update("lockApiConfigAcrossModes", false) + + const getModeConfigIdSpy = vi + .spyOn(provider.providerSettingsManager, "getModeConfigId") + .mockResolvedValue("architect-profile-id") + vi.spyOn(provider.providerSettingsManager, "listConfig").mockResolvedValue([ + { name: "architect-profile", id: "architect-profile-id", apiProvider: "anthropic" }, + ]) + vi.spyOn(provider.providerSettingsManager, "getProfile").mockResolvedValue({ + name: "architect-profile", + apiProvider: "anthropic", + }) + + const activateProviderProfileSpy = vi + .spyOn(provider, "activateProviderProfile") + .mockResolvedValue(undefined) + + await provider.handleModeSwitch("architect") + + expect(getModeConfigIdSpy).toHaveBeenCalledWith("architect") + expect(activateProviderProfileSpy).toHaveBeenCalledWith({ name: "architect-profile" }) + }) + }) +}) diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 26f6fbd8aba..4bad630ed56 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -405,6 +405,11 @@ describe("ClineProvider", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -2147,6 +2152,11 @@ describe("Project MCP Settings", () => { store: vi.fn(), delete: vi.fn(), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -2277,6 +2287,11 @@ describe.skip("ContextProxy integration", () => { update: vi.fn(), keys: vi.fn().mockReturnValue([]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() }, extensionUri: {} as vscode.Uri, globalStorageUri: { fsPath: "/test/path" }, @@ -2342,6 +2357,11 @@ describe("getTelemetryProperties", () => { update: vi.fn(), keys: vi.fn().mockReturnValue([]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, secrets: { get: vi.fn(), store: vi.fn(), delete: vi.fn() }, extensionUri: {} as vscode.Uri, globalStorageUri: { fsPath: "/test/path" }, @@ -2504,6 +2524,11 @@ describe("ClineProvider - Router Models", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, @@ -2857,6 +2882,11 @@ describe("ClineProvider - Comprehensive Edit/Delete Edge Cases", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts index 27aab0b7da2..af674d7a5e0 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-mode.spec.ts @@ -227,6 +227,11 @@ describe("ClineProvider - Sticky Mode", () => { return Promise.resolve() }), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts index 80b14746a76..ee63b45b254 100644 --- a/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.sticky-profile.spec.ts @@ -229,6 +229,11 @@ describe("ClineProvider - Sticky Provider Profile", () => { return Promise.resolve() }), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts index f5e6afa7f06..e0f1d2dc29a 100644 --- a/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.taskHistory.spec.ts @@ -287,6 +287,11 @@ describe("ClineProvider Task History Synchronization", () => { store: vi.fn().mockImplementation((key: string, value: string | undefined) => (secrets[key] = value)), delete: vi.fn().mockImplementation((key: string) => delete secrets[key]), }, + workspaceState: { + get: vi.fn().mockReturnValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + keys: vi.fn().mockReturnValue([]), + }, subscriptions: [], extension: { packageJSON: { version: "1.0.0" }, diff --git a/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts new file mode 100644 index 00000000000..fd9b4a77401 --- /dev/null +++ b/src/core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts @@ -0,0 +1,68 @@ +// npx vitest run core/webview/__tests__/webviewMessageHandler.lockApiConfig.spec.ts + +import { webviewMessageHandler } from "../webviewMessageHandler" +import type { ClineProvider } from "../ClineProvider" + +describe("webviewMessageHandler - lockApiConfigAcrossModes", () => { + let mockProvider: { + context: { + workspaceState: { + get: ReturnType + update: ReturnType + } + } + getState: ReturnType + postStateToWebview: ReturnType + providerSettingsManager: { + setModeConfig: ReturnType + } + postMessageToWebview: ReturnType + getCurrentTask: ReturnType + } + + beforeEach(() => { + vi.clearAllMocks() + + mockProvider = { + context: { + workspaceState: { + get: vi.fn(), + update: vi.fn().mockResolvedValue(undefined), + }, + }, + getState: vi.fn().mockResolvedValue({ + currentApiConfigName: "test-config", + listApiConfigMeta: [{ name: "test-config", id: "config-123" }], + customModes: [], + }), + postStateToWebview: vi.fn(), + providerSettingsManager: { + setModeConfig: vi.fn(), + }, + postMessageToWebview: vi.fn(), + getCurrentTask: vi.fn(), + } + }) + + it("sets lockApiConfigAcrossModes to true and posts state without mode config fan-out", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "lockApiConfigAcrossModes", + bool: true, + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("lockApiConfigAcrossModes", true) + expect(mockProvider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) + + it("sets lockApiConfigAcrossModes to false without applying to all modes", async () => { + await webviewMessageHandler(mockProvider as unknown as ClineProvider, { + type: "lockApiConfigAcrossModes", + bool: false, + }) + + expect(mockProvider.context.workspaceState.update).toHaveBeenCalledWith("lockApiConfigAcrossModes", false) + expect(mockProvider.providerSettingsManager.setModeConfig).not.toHaveBeenCalled() + expect(mockProvider.postStateToWebview).toHaveBeenCalled() + }) +}) diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 75f1ce0ff4a..ae0da758412 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -1661,6 +1661,14 @@ export const webviewMessageHandler = async ( await provider.postStateToWebview() break + case "lockApiConfigAcrossModes": { + const enabled = message.bool ?? false + await provider.context.workspaceState.update("lockApiConfigAcrossModes", enabled) + + await provider.postStateToWebview() + break + } + case "toggleApiConfigPin": if (message.text) { const currentPinned = getGlobalState("pinnedApiConfigs") ?? {} diff --git a/webview-ui/src/components/chat/ApiConfigSelector.tsx b/webview-ui/src/components/chat/ApiConfigSelector.tsx index 4396019a2d2..e370296ec32 100644 --- a/webview-ui/src/components/chat/ApiConfigSelector.tsx +++ b/webview-ui/src/components/chat/ApiConfigSelector.tsx @@ -20,6 +20,8 @@ interface ApiConfigSelectorProps { listApiConfigMeta: Array<{ id: string; name: string; modelId?: string }> pinnedApiConfigs?: Record togglePinnedApiConfig: (id: string) => void + lockApiConfigAcrossModes: boolean + onToggleLockApiConfig: () => void } export const ApiConfigSelector = ({ @@ -32,6 +34,8 @@ export const ApiConfigSelector = ({ listApiConfigMeta, pinnedApiConfigs, togglePinnedApiConfig, + lockApiConfigAcrossModes, + onToggleLockApiConfig, }: ApiConfigSelectorProps) => { const { t } = useAppTranslation() const [open, setOpen] = useState(false) @@ -228,6 +232,16 @@ export const ApiConfigSelector = ({ onClick={handleEditClick} tooltip={false} /> +
{/* Info icon and title on the right with matching spacing */} diff --git a/webview-ui/src/components/chat/ChatTextArea.tsx b/webview-ui/src/components/chat/ChatTextArea.tsx index 654f2e1011e..4c0b2bbfd08 100644 --- a/webview-ui/src/components/chat/ChatTextArea.tsx +++ b/webview-ui/src/components/chat/ChatTextArea.tsx @@ -103,6 +103,7 @@ export const ChatTextArea = forwardRef( commands, cloudUserInfo, enterBehavior, + lockApiConfigAcrossModes, } = useExtensionState() // Find the ID and display text for the currently selected API configuration. @@ -945,6 +946,11 @@ export const ChatTextArea = forwardRef( vscode.postMessage({ type: "loadApiConfigurationById", text: value }) }, []) + const handleToggleLockApiConfig = useCallback(() => { + const newValue = !lockApiConfigAcrossModes + vscode.postMessage({ type: "lockApiConfigAcrossModes", bool: newValue }) + }, [lockApiConfigAcrossModes]) + return (
( listApiConfigMeta={listApiConfigMeta || []} pinnedApiConfigs={pinnedApiConfigs} togglePinnedApiConfig={togglePinnedApiConfig} + lockApiConfigAcrossModes={!!lockApiConfigAcrossModes} + onToggleLockApiConfig={handleToggleLockApiConfig} />
diff --git a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx index ff1b95f9499..a71216d96f8 100644 --- a/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx +++ b/webview-ui/src/components/chat/__tests__/ApiConfigSelector.spec.tsx @@ -72,6 +72,8 @@ describe("ApiConfigSelector", () => { ], pinnedApiConfigs: { config1: true }, togglePinnedApiConfig: mockTogglePinnedApiConfig, + lockApiConfigAcrossModes: false, + onToggleLockApiConfig: vi.fn(), } beforeEach(() => { diff --git a/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx b/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx new file mode 100644 index 00000000000..d3fb2b6890a --- /dev/null +++ b/webview-ui/src/components/chat/__tests__/ChatTextArea.lockApiConfig.spec.tsx @@ -0,0 +1,156 @@ +import { defaultModeSlug } from "@roo/modes" + +import { render, fireEvent, screen } from "@src/utils/test-utils" +import { useExtensionState } from "@src/context/ExtensionStateContext" +import { vscode } from "@src/utils/vscode" + +import { ChatTextArea } from "../ChatTextArea" + +vi.mock("@src/utils/vscode", () => ({ + vscode: { + postMessage: vi.fn(), + }, +})) + +vi.mock("@src/components/common/CodeBlock") +vi.mock("@src/components/common/MarkdownBlock") +vi.mock("@src/utils/path-mentions", () => ({ + convertToMentionPath: vi.fn((path: string) => path), +})) + +// Mock ExtensionStateContext +vi.mock("@src/context/ExtensionStateContext") + +const mockPostMessage = vscode.postMessage as ReturnType + +describe("ChatTextArea - lockApiConfigAcrossModes toggle", () => { + const defaultProps = { + inputValue: "", + setInputValue: vi.fn(), + onSend: vi.fn(), + sendingDisabled: false, + selectApiConfigDisabled: false, + onSelectImages: vi.fn(), + shouldDisableImages: false, + placeholderText: "Type a message...", + selectedImages: [] as string[], + setSelectedImages: vi.fn(), + onHeightChange: vi.fn(), + mode: defaultModeSlug, + setMode: vi.fn(), + modeShortcutText: "(⌘. for next mode)", + } + + const defaultState = { + filePaths: [], + openedTabs: [], + apiConfiguration: { apiProvider: "anthropic" }, + taskHistory: [], + cwd: "/test/workspace", + listApiConfigMeta: [{ id: "default", name: "Default", modelId: "claude-3" }], + currentApiConfigName: "Default", + pinnedApiConfigs: {}, + togglePinnedApiConfig: vi.fn(), + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + /** + * Helper: Opens the ApiConfigSelector popover by clicking the trigger, + * then returns the lock toggle button by its aria-label. + */ + const openPopoverAndGetLockToggle = (ariaLabel: string) => { + const trigger = screen.getByTestId("dropdown-trigger") + fireEvent.click(trigger) + return screen.getByRole("button", { name: ariaLabel }) + } + + describe("rendering", () => { + it("renders with muted opacity when lockApiConfigAcrossModes is false", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: false, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Unlocked state has muted opacity + expect(button.className).toContain("opacity-60") + expect(button.className).not.toContain("text-vscode-focusBorder") + }) + + it("renders with highlight color when lockApiConfigAcrossModes is true", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: true, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:unlockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Locked state has the focus border highlight color + expect(button.className).toContain("text-vscode-focusBorder") + expect(button.className).not.toContain("opacity-60") + }) + + it("renders in unlocked state when lockApiConfigAcrossModes is undefined (default)", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + }) + + render() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + expect(button).toBeInTheDocument() + // Default (undefined/falsy) renders in unlocked style + expect(button.className).toContain("opacity-60") + }) + }) + + describe("interaction", () => { + it("posts lockApiConfigAcrossModes=true message when locking", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: false, + }) + + render() + + // Clear any initialization messages + mockPostMessage.mockClear() + + const button = openPopoverAndGetLockToggle("chat:lockApiConfigAcrossModes") + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "lockApiConfigAcrossModes", + bool: true, + }) + }) + + it("posts lockApiConfigAcrossModes=false message when unlocking", () => { + ;(useExtensionState as ReturnType).mockReturnValue({ + ...defaultState, + lockApiConfigAcrossModes: true, + }) + + render() + + // Clear any initialization messages + mockPostMessage.mockClear() + + const button = openPopoverAndGetLockToggle("chat:unlockApiConfigAcrossModes") + fireEvent.click(button) + + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "lockApiConfigAcrossModes", + bool: false, + }) + }) + }) +}) diff --git a/webview-ui/src/context/ExtensionStateContext.tsx b/webview-ui/src/context/ExtensionStateContext.tsx index 47110d08751..2378873f010 100644 --- a/webview-ui/src/context/ExtensionStateContext.tsx +++ b/webview-ui/src/context/ExtensionStateContext.tsx @@ -264,6 +264,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode openRouterImageGenerationSelectedModel: "", includeCurrentTime: true, includeCurrentCost: true, + lockApiConfigAcrossModes: false, }) const [didHydrateState, setDidHydrateState] = useState(false) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 5bba7dc4459..070adc265db 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Selecciona el mode d'interacció", "selectApiConfig": "Seleccioneu la configuració de l'API", + "lockApiConfigAcrossModes": "Bloqueja la configuració de l'API a tots els modes en aquest espai de treball", + "unlockApiConfigAcrossModes": "La configuració de l'API està bloquejada a tots els modes en aquest espai de treball (fes clic per desbloquejar)", "enhancePrompt": "Millora la sol·licitud amb context addicional", "addImages": "Afegeix imatges al missatge", "sendMessage": "Envia el missatge", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 58bc85b60cf..bc550520d7a 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Interaktionsmodus auswählen", "selectApiConfig": "API-Konfiguration auswählen", + "lockApiConfigAcrossModes": "API-Konfiguration für alle Modi in diesem Arbeitsbereich sperren", + "unlockApiConfigAcrossModes": "API-Konfiguration ist für alle Modi in diesem Arbeitsbereich gesperrt (klicke zum Entsperren)", "enhancePrompt": "Prompt mit zusätzlichem Kontext verbessern", "addImages": "Bilder zur Nachricht hinzufügen", "sendMessage": "Nachricht senden", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index b9652cfce5c..bd97e041f20 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -122,6 +122,8 @@ }, "selectMode": "Select mode for interaction", "selectApiConfig": "Select API configuration", + "lockApiConfigAcrossModes": "Lock API configuration across all modes in this workspace", + "unlockApiConfigAcrossModes": "API configuration is locked across all modes in this workspace (click to unlock)", "enhancePrompt": "Enhance prompt with additional context", "modeSelector": { "title": "Modes", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 6c894642c88..0687bc7037b 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Seleccionar modo de interacción", "selectApiConfig": "Seleccionar configuración de API", + "lockApiConfigAcrossModes": "Bloquear la configuración de API en todos los modos de este espacio de trabajo", + "unlockApiConfigAcrossModes": "La configuración de API está bloqueada en todos los modos de este espacio de trabajo (clic para desbloquear)", "enhancePrompt": "Mejorar el mensaje con contexto adicional", "addImages": "Agregar imágenes al mensaje", "sendMessage": "Enviar mensaje", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 598344de1d7..f4887b7e6c7 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Sélectionner le mode d'interaction", "selectApiConfig": "Sélectionner la configuration de l'API", + "lockApiConfigAcrossModes": "Verrouiller la configuration API pour tous les modes dans cet espace de travail", + "unlockApiConfigAcrossModes": "La configuration API est verrouillée pour tous les modes dans cet espace de travail (cliquer pour déverrouiller)", "enhancePrompt": "Améliorer la requête avec un contexte supplémentaire", "addImages": "Ajouter des images au message", "sendMessage": "Envoyer le message", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 74d5e4e3eef..580822b82f5 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "इंटरैक्शन मोड चुनें", "selectApiConfig": "एपीआई कॉन्फ़िगरेशन का चयन करें", + "lockApiConfigAcrossModes": "इस कार्यक्षेत्र में सभी मोड के लिए API कॉन्फ़िगरेशन लॉक करें", + "unlockApiConfigAcrossModes": "इस कार्यक्षेत्र में सभी मोड के लिए API कॉन्फ़िगरेशन लॉक है (अनलॉक करने के लिए क्लिक करें)", "enhancePrompt": "अतिरिक्त संदर्भ के साथ प्रॉम्प्ट बढ़ाएँ", "addImages": "संदेश में चित्र जोड़ें", "sendMessage": "संदेश भेजें", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index f814b9d4a9a..600708e4d9c 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -125,6 +125,8 @@ }, "selectMode": "Pilih mode untuk interaksi", "selectApiConfig": "Pilih konfigurasi API", + "lockApiConfigAcrossModes": "Kunci konfigurasi API di semua mode dalam workspace ini", + "unlockApiConfigAcrossModes": "Konfigurasi API terkunci di semua mode dalam workspace ini (klik untuk membuka kunci)", "enhancePrompt": "Tingkatkan prompt dengan konteks tambahan", "enhancePromptDescription": "Tombol 'Tingkatkan Prompt' membantu memperbaiki prompt kamu dengan memberikan konteks tambahan, klarifikasi, atau penyusunan ulang. Coba ketik prompt di sini dan klik tombol lagi untuk melihat cara kerjanya.", "modeSelector": { diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index eca4264df20..ce8e45bf459 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Seleziona modalità di interazione", "selectApiConfig": "Seleziona la configurazione API", + "lockApiConfigAcrossModes": "Blocca la configurazione API per tutte le modalità in questo workspace", + "unlockApiConfigAcrossModes": "La configurazione API è bloccata per tutte le modalità in questo workspace (clicca per sbloccare)", "enhancePrompt": "Migliora prompt con contesto aggiuntivo", "addImages": "Aggiungi immagini al messaggio", "sendMessage": "Invia messaggio", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 9a0b1b2a35c..f3c3d86f7a1 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "対話モードを選択", "selectApiConfig": "API構成を選択", + "lockApiConfigAcrossModes": "このワークスペースのすべてのモードでAPI構成をロック", + "unlockApiConfigAcrossModes": "このワークスペースのすべてのモードでAPI構成がロックされています(クリックで解除)", "enhancePrompt": "追加コンテキストでプロンプトを強化", "addImages": "メッセージに画像を追加", "sendMessage": "メッセージを送信", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 3b26c6e6e19..817768c2753 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "상호작용 모드 선택", "selectApiConfig": "API 구성 선택", + "lockApiConfigAcrossModes": "이 워크스페이스의 모든 모드에서 API 구성 잠금", + "unlockApiConfigAcrossModes": "이 워크스페이스의 모든 모드에서 API 구성이 잠겨 있습니다 (클릭하여 해제)", "enhancePrompt": "추가 컨텍스트로 프롬프트 향상", "addImages": "메시지에 이미지 추가", "sendMessage": "메시지 보내기", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 241e9f22166..0a728aa38e8 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Selecteer modus voor interactie", "selectApiConfig": "Selecteer API-configuratie", + "lockApiConfigAcrossModes": "API-configuratie vergrendelen voor alle modi in deze werkruimte", + "unlockApiConfigAcrossModes": "API-configuratie is vergrendeld voor alle modi in deze werkruimte (klik om te ontgrendelen)", "enhancePrompt": "Prompt verbeteren met extra context", "enhancePromptDescription": "De knop 'Prompt verbeteren' helpt je prompt te verbeteren door extra context, verduidelijking of herformulering te bieden. Probeer hier een prompt te typen en klik opnieuw op de knop om te zien hoe het werkt.", "modeSelector": { diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index ae6b5a96ac7..09a1c994927 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Wybierz tryb interakcji", "selectApiConfig": "Wybierz konfigurację API", + "lockApiConfigAcrossModes": "Zablokuj konfigurację API dla wszystkich trybów w tym obszarze roboczym", + "unlockApiConfigAcrossModes": "Konfiguracja API jest zablokowana dla wszystkich trybów w tym obszarze roboczym (kliknij, aby odblokować)", "enhancePrompt": "Ulepsz podpowiedź dodatkowym kontekstem", "addImages": "Dodaj obrazy do wiadomości", "sendMessage": "Wyślij wiadomość", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 2ca72ebd970..cc4fbbd742e 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Selecionar modo de interação", "selectApiConfig": "Selecionar configuração da API", + "lockApiConfigAcrossModes": "Bloquear configuração da API em todos os modos neste workspace", + "unlockApiConfigAcrossModes": "A configuração da API está bloqueada em todos os modos neste workspace (clique para desbloquear)", "enhancePrompt": "Aprimorar prompt com contexto adicional", "addImages": "Adicionar imagens à mensagem", "sendMessage": "Enviar mensagem", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 347bf1be81e..c5e4a3f0e2a 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Выберите режим взаимодействия", "selectApiConfig": "Выберите конфигурацию API", + "lockApiConfigAcrossModes": "Заблокировать конфигурацию API для всех режимов в этом рабочем пространстве", + "unlockApiConfigAcrossModes": "Конфигурация API заблокирована для всех режимов в этом рабочем пространстве (нажми, чтобы разблокировать)", "enhancePrompt": "Улучшить запрос с дополнительным контекстом", "enhancePromptDescription": "Кнопка 'Улучшить запрос' помогает сделать ваш запрос лучше, предоставляя дополнительный контекст, уточнения или переформулировку. Попробуйте ввести запрос и снова нажать кнопку, чтобы увидеть, как это работает.", "modeSelector": { diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 2301541cdff..b913a5afb2b 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Etkileşim modunu seçin", "selectApiConfig": "API yapılandırmasını seçin", + "lockApiConfigAcrossModes": "Bu çalışma alanındaki tüm modlarda API yapılandırmasını kilitle", + "unlockApiConfigAcrossModes": "Bu çalışma alanındaki tüm modlarda API yapılandırması kilitli (kilidi açmak için tıkla)", "enhancePrompt": "Ek bağlamla istemi geliştir", "addImages": "Mesaja resim ekle", "sendMessage": "Mesaj gönder", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 33b13d9b269..9aecd5c3857 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "Chọn chế độ tương tác", "selectApiConfig": "Chọn cấu hình API", + "lockApiConfigAcrossModes": "Khóa cấu hình API cho tất cả chế độ trong workspace này", + "unlockApiConfigAcrossModes": "Cấu hình API đã bị khóa cho tất cả chế độ trong workspace này (nhấn để mở khóa)", "enhancePrompt": "Nâng cao yêu cầu với ngữ cảnh bổ sung", "addImages": "Thêm hình ảnh vào tin nhắn", "sendMessage": "Gửi tin nhắn", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 7a94bfb48d7..853279506fc 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -111,6 +111,8 @@ }, "selectMode": "选择交互模式", "selectApiConfig": "选择 API 配置", + "lockApiConfigAcrossModes": "锁定此工作区所有模式的 API 配置", + "unlockApiConfigAcrossModes": "此工作区所有模式的 API 配置已锁定(点击解锁)", "enhancePrompt": "增强提示词", "addImages": "添加图片到消息", "sendMessage": "发送消息", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 926cb105fb4..84c54900b68 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -122,6 +122,8 @@ }, "selectMode": "選擇互動模式", "selectApiConfig": "選取 API 設定", + "lockApiConfigAcrossModes": "鎖定此工作區所有模式的 API 設定", + "unlockApiConfigAcrossModes": "此工作區所有模式的 API 設定已鎖定(點擊解鎖)", "enhancePrompt": "使用額外內容強化提示詞", "modeSelector": { "title": "模式", From 7db4bfef5aea71266372cf6199db8fdbc3ed8bf7 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 7 Feb 2026 21:39:45 -0700 Subject: [PATCH 20/31] feat(history): render nested subtasks as recursive tree (#11299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(history): render nested subtasks as recursive tree * fix(lockfile): resolve missing ai-sdk provider entry * fix: address review feedback — dedupe countAll, increase SubtaskRow max-h - HistoryView: replace local countAll with imported countAllSubtasks from types.ts - SubtaskRow: increase nested children max-h from 500px to 2000px to match TaskGroupItem --- .../src/components/history/HistoryPreview.tsx | 1 + .../src/components/history/HistoryView.tsx | 6 +- .../src/components/history/SubtaskRow.tsx | 91 ++++++-- .../src/components/history/TaskGroupItem.tsx | 23 +- .../history/__tests__/SubtaskRow.spec.tsx | 213 ++++++++++++++++++ .../history/__tests__/TaskGroupItem.spec.tsx | 150 ++++++++++-- .../history/__tests__/useGroupedTasks.spec.ts | 213 +++++++++++++++++- webview-ui/src/components/history/types.ts | 29 ++- .../src/components/history/useGroupedTasks.ts | 38 +++- 9 files changed, 687 insertions(+), 77 deletions(-) create mode 100644 webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx diff --git a/webview-ui/src/components/history/HistoryPreview.tsx b/webview-ui/src/components/history/HistoryPreview.tsx index 02464e69c0d..70467c44fba 100644 --- a/webview-ui/src/components/history/HistoryPreview.tsx +++ b/webview-ui/src/components/history/HistoryPreview.tsx @@ -38,6 +38,7 @@ const HistoryPreview = () => { group={group} variant="compact" onToggleExpand={() => toggleExpand(group.parent.id)} + onToggleSubtaskExpand={toggleExpand} /> ))} diff --git a/webview-ui/src/components/history/HistoryView.tsx b/webview-ui/src/components/history/HistoryView.tsx index 88b65518812..1d6de93e64d 100644 --- a/webview-ui/src/components/history/HistoryView.tsx +++ b/webview-ui/src/components/history/HistoryView.tsx @@ -21,6 +21,7 @@ import { useAppTranslation } from "@/i18n/TranslationContext" import { Tab, TabContent, TabHeader } from "../common/Tab" import { useTaskSearch } from "./useTaskSearch" import { useGroupedTasks } from "./useGroupedTasks" +import { countAllSubtasks } from "./types" import TaskItem from "./TaskItem" import TaskGroupItem from "./TaskGroupItem" @@ -52,11 +53,11 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { const [selectedTaskIds, setSelectedTaskIds] = useState([]) const [showBatchDeleteDialog, setShowBatchDeleteDialog] = useState(false) - // Get subtask count for a task + // Get subtask count for a task (recursive total) const getSubtaskCount = useMemo(() => { const countMap = new Map() for (const group of groups) { - countMap.set(group.parent.id, group.subtasks.length) + countMap.set(group.parent.id, countAllSubtasks(group.subtasks)) } return (taskId: string) => countMap.get(taskId) || 0 }, [groups]) @@ -300,6 +301,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => { onToggleSelection={toggleTaskSelection} onDelete={handleDelete} onToggleExpand={() => toggleExpand(group.parent.id)} + onToggleSubtaskExpand={toggleExpand} className="m-2" /> )} diff --git a/webview-ui/src/components/history/SubtaskRow.tsx b/webview-ui/src/components/history/SubtaskRow.tsx index dec227ebc89..0089e1f81db 100644 --- a/webview-ui/src/components/history/SubtaskRow.tsx +++ b/webview-ui/src/components/history/SubtaskRow.tsx @@ -2,46 +2,87 @@ import { memo } from "react" import { ArrowRight } from "lucide-react" import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" -import type { DisplayHistoryItem } from "./types" +import type { SubtaskTreeNode } from "./types" +import { countAllSubtasks } from "./types" import { StandardTooltip } from "../ui" +import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow" interface SubtaskRowProps { - /** The subtask to display */ - item: DisplayHistoryItem + /** The subtask tree node to display */ + node: SubtaskTreeNode + /** Nesting depth (1 = direct child of parent group) */ + depth: number + /** Callback when expand/collapse is toggled for a node */ + onToggleExpand: (taskId: string) => void /** Optional className for styling */ className?: string } /** - * Displays an individual subtask row when the parent's subtask list is expanded. - * Shows the task name and token/cost info in an indented format. + * Displays a subtask row with recursive nesting support. + * Leaf nodes render just the task row. Nodes with children show + * a collapsible section that can be expanded to reveal nested subtasks. */ -const SubtaskRow = ({ item, className }: SubtaskRowProps) => { +const SubtaskRow = ({ node, depth, onToggleExpand, className }: SubtaskRowProps) => { + const { item, children, isExpanded } = node + const hasChildren = children.length > 0 + const handleClick = () => { vscode.postMessage({ type: "showTaskWithId", text: item.id }) } return ( -
+ {/* Task row with depth indentation */} +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + handleClick() + } + }}> + + {item.task} + + +
+ + {/* Nested subtask collapsible section */} + {hasChildren && ( +
+ onToggleExpand(item.id)} + /> +
+ )} + + {/* Expanded nested subtasks */} + {hasChildren && ( +
+ {children.map((child) => ( + + ))} +
)} - onClick={handleClick} - role="button" - tabIndex={0} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault() - handleClick() - } - }}> - - {item.task} - -
) } diff --git a/webview-ui/src/components/history/TaskGroupItem.tsx b/webview-ui/src/components/history/TaskGroupItem.tsx index 6bf2e1a9572..45b8293f016 100644 --- a/webview-ui/src/components/history/TaskGroupItem.tsx +++ b/webview-ui/src/components/history/TaskGroupItem.tsx @@ -1,6 +1,7 @@ import { memo } from "react" import { cn } from "@/lib/utils" import type { TaskGroup } from "./types" +import { countAllSubtasks } from "./types" import TaskItem from "./TaskItem" import SubtaskCollapsibleRow from "./SubtaskCollapsibleRow" import SubtaskRow from "./SubtaskRow" @@ -20,15 +21,17 @@ interface TaskGroupItemProps { onToggleSelection?: (taskId: string, isSelected: boolean) => void /** Callback when delete is requested */ onDelete?: (taskId: string) => void - /** Callback when expand/collapse is toggled */ + /** Callback when the parent group expand/collapse is toggled */ onToggleExpand: () => void + /** Callback when a nested subtask node expand/collapse is toggled */ + onToggleSubtaskExpand: (taskId: string) => void /** Optional className for styling */ className?: string } /** - * Renders a task group consisting of a parent task and its collapsible subtask list. - * When expanded, shows individual subtask rows. + * Renders a task group consisting of a parent task and its collapsible subtask tree. + * When expanded, shows recursively nested subtask rows. */ const TaskGroupItem = ({ group, @@ -39,10 +42,12 @@ const TaskGroupItem = ({ onToggleSelection, onDelete, onToggleExpand, + onToggleSubtaskExpand, className, }: TaskGroupItemProps) => { const { parent, subtasks, isExpanded } = group const hasSubtasks = subtasks.length > 0 + const totalSubtaskCount = hasSubtasks ? countAllSubtasks(subtasks) : 0 return (
- {/* Subtask collapsible row */} + {/* Subtask collapsible row — shows total recursive count */} {hasSubtasks && ( - + )} - {/* Expanded subtasks */} + {/* Expanded subtask tree */} {hasSubtasks && (
- {subtasks.map((subtask) => ( - + {subtasks.map((node) => ( + ))}
)} diff --git a/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx b/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx new file mode 100644 index 00000000000..6337b9f1fa2 --- /dev/null +++ b/webview-ui/src/components/history/__tests__/SubtaskRow.spec.tsx @@ -0,0 +1,213 @@ +import { render, screen, fireEvent } from "@/utils/test-utils" + +import { vscode } from "@src/utils/vscode" + +import SubtaskRow from "../SubtaskRow" +import type { SubtaskTreeNode, DisplayHistoryItem } from "../types" + +vi.mock("@src/utils/vscode") +vi.mock("@src/i18n/TranslationContext", () => ({ + useAppTranslation: () => ({ + t: (key: string, options?: Record) => { + if (key === "history:subtasks" && options?.count !== undefined) { + return `${options.count} Subtask${options.count === 1 ? "" : "s"}` + } + if (key === "history:collapseSubtasks") return "Collapse subtasks" + if (key === "history:expandSubtasks") return "Expand subtasks" + return key + }, + }), +})) + +const createMockDisplayItem = (overrides: Partial = {}): DisplayHistoryItem => ({ + id: "task-1", + number: 1, + task: "Test task", + ts: Date.now(), + tokensIn: 100, + tokensOut: 50, + totalCost: 0.01, + workspace: "/workspace/project", + ...overrides, +}) + +const createMockNode = ( + itemOverrides: Partial = {}, + children: SubtaskTreeNode[] = [], + isExpanded = false, +): SubtaskTreeNode => ({ + item: createMockDisplayItem(itemOverrides), + children, + isExpanded, +}) + +describe("SubtaskRow", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("leaf node rendering", () => { + it("renders leaf node with correct text", () => { + const node = createMockNode({ id: "leaf-1", task: "Leaf task content" }) + + render() + + expect(screen.getByText("Leaf task content")).toBeInTheDocument() + }) + + it("renders with correct depth indentation", () => { + const node = createMockNode({ id: "leaf-1", task: "Indented task" }) + + render() + + const row = screen.getByTestId("subtask-row-leaf-1") + // The clickable row inside should have paddingLeft = depth * 16 = 32px + const clickableRow = row.querySelector("[role='button']") + expect(clickableRow).toHaveStyle({ paddingLeft: "32px" }) + }) + + it("does not render collapsible row for leaf node", () => { + const node = createMockNode({ id: "leaf-1", task: "Leaf only" }) + + render() + + expect(screen.queryByTestId("subtask-collapsible-row")).not.toBeInTheDocument() + }) + }) + + describe("node with children", () => { + it("renders collapsible row with correct child count", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent task" }, + [ + createMockNode({ id: "child-1", task: "Child 1" }), + createMockNode({ id: "child-2", task: "Child 2" }), + ], + false, + ) + + render() + + expect(screen.getByText("2 Subtasks")).toBeInTheDocument() + expect(screen.getByTestId("subtask-collapsible-row")).toBeInTheDocument() + }) + + it("renders nested children count including grandchildren", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent task" }, + [ + createMockNode({ id: "child-1", task: "Child 1" }, [ + createMockNode({ id: "grandchild-1", task: "Grandchild 1" }), + ]), + ], + false, + ) + + render() + + // countAllSubtasks counts child-1 (1) + grandchild-1 (1) = 2 + expect(screen.getByText("2 Subtasks")).toBeInTheDocument() + }) + }) + + describe("click behavior", () => { + it("sends showTaskWithId message when task row is clicked", () => { + const node = createMockNode({ id: "task-42", task: "Clickable task" }) + + render() + + const row = screen.getByRole("button") + fireEvent.click(row) + + expect(vscode.postMessage).toHaveBeenCalledWith({ + type: "showTaskWithId", + text: "task-42", + }) + }) + + it("calls onToggleExpand with correct task ID when collapsible row is clicked", () => { + const onToggleExpand = vi.fn() + const node = createMockNode( + { id: "expandable-1", task: "Expandable task" }, + [createMockNode({ id: "child-1", task: "Child" })], + false, + ) + + render() + + const collapsibleRow = screen.getByTestId("subtask-collapsible-row") + fireEvent.click(collapsibleRow) + + expect(onToggleExpand).toHaveBeenCalledWith("expandable-1") + }) + }) + + describe("expand/collapse behavior", () => { + it("renders child SubtaskRow components when expanded", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent" }, + [ + createMockNode({ id: "child-1", task: "Child 1" }), + createMockNode({ id: "child-2", task: "Child 2" }), + ], + true, // expanded + ) + + render() + + expect(screen.getByTestId("subtask-row-child-1")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-child-2")).toBeInTheDocument() + expect(screen.getByText("Child 1")).toBeInTheDocument() + expect(screen.getByText("Child 2")).toBeInTheDocument() + }) + + it("uses max-h-0 for collapsed node with children", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent" }, + [createMockNode({ id: "child-1", task: "Child 1" })], + false, // collapsed + ) + + const { container } = render() + + // The children wrapper div should have max-h-0 when collapsed + const childrenWrapper = container.querySelector(".max-h-0") + expect(childrenWrapper).toBeInTheDocument() + }) + + it("does not use max-h-0 when node is expanded", () => { + const node = createMockNode( + { id: "parent-1", task: "Parent" }, + [createMockNode({ id: "child-1", task: "Child 1" })], + true, // expanded + ) + + const { container } = render() + + // The children wrapper should NOT have max-h-0 when expanded + const collapsedWrapper = container.querySelector(".max-h-0") + expect(collapsedWrapper).not.toBeInTheDocument() + }) + + it("renders deeply nested recursive structure when all levels expanded", () => { + const node = createMockNode( + { id: "root", task: "Root" }, + [ + createMockNode( + { id: "child", task: "Child" }, + [createMockNode({ id: "grandchild", task: "Grandchild" })], + true, // child expanded + ), + ], + true, // root expanded + ) + + render() + + expect(screen.getByTestId("subtask-row-root")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-child")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-grandchild")).toBeInTheDocument() + expect(screen.getByText("Grandchild")).toBeInTheDocument() + }) + }) +}) diff --git a/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx b/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx index ff40963a87a..b04fac6b543 100644 --- a/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx +++ b/webview-ui/src/components/history/__tests__/TaskGroupItem.spec.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent } from "@/utils/test-utils" import TaskGroupItem from "../TaskGroupItem" -import type { TaskGroup, DisplayHistoryItem } from "../types" +import type { TaskGroup, DisplayHistoryItem, SubtaskTreeNode } from "../types" vi.mock("@src/utils/vscode") vi.mock("@src/i18n/TranslationContext", () => ({ @@ -34,6 +34,16 @@ const createMockDisplayHistoryItem = (overrides: Partial = { ...overrides, }) +const createMockSubtaskNode = ( + itemOverrides: Partial = {}, + children: SubtaskTreeNode[] = [], + isExpanded = false, +): SubtaskTreeNode => ({ + item: createMockDisplayHistoryItem(itemOverrides), + children, + isExpanded, +}) + const createMockGroup = (overrides: Partial = {}): TaskGroup => ({ parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }), subtasks: [], @@ -55,7 +65,9 @@ describe("TaskGroupItem", () => { }), }) - render() + render( + , + ) expect(screen.getByText("Test parent task content")).toBeInTheDocument() }) @@ -65,7 +77,9 @@ describe("TaskGroupItem", () => { parent: createMockDisplayHistoryItem({ id: "my-parent-id" }), }) - render() + render( + , + ) expect(screen.getByTestId("task-group-my-parent-id")).toBeInTheDocument() }) @@ -75,23 +89,27 @@ describe("TaskGroupItem", () => { it("shows correct subtask count", () => { const group = createMockGroup({ subtasks: [ - createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" }), - createMockDisplayHistoryItem({ id: "child-2", task: "Child 2" }), - createMockDisplayHistoryItem({ id: "child-3", task: "Child 3" }), + createMockSubtaskNode({ id: "child-1", task: "Child 1" }), + createMockSubtaskNode({ id: "child-2", task: "Child 2" }), + createMockSubtaskNode({ id: "child-3", task: "Child 3" }), ], }) - render() + render( + , + ) expect(screen.getByText("3 Subtasks")).toBeInTheDocument() }) it("shows singular subtask text for single subtask", () => { const group = createMockGroup({ - subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" })], + subtasks: [createMockSubtaskNode({ id: "child-1", task: "Child 1" })], }) - render() + render( + , + ) expect(screen.getByText("1 Subtask")).toBeInTheDocument() }) @@ -99,20 +117,48 @@ describe("TaskGroupItem", () => { it("does not show subtask row when no subtasks", () => { const group = createMockGroup({ subtasks: [] }) - render() + render( + , + ) expect(screen.queryByTestId("subtask-collapsible-row")).not.toBeInTheDocument() }) + + it("renders correct total subtask count with nested children", () => { + const group = createMockGroup({ + subtasks: [ + createMockSubtaskNode({ id: "child-1", task: "Child 1" }, [ + createMockSubtaskNode({ id: "grandchild-1", task: "Grandchild 1" }), + createMockSubtaskNode({ id: "grandchild-2", task: "Grandchild 2" }), + ]), + createMockSubtaskNode({ id: "child-2", task: "Child 2" }), + ], + }) + + render( + , + ) + + // 2 direct children + 2 grandchildren = 4 total + expect(screen.getByText("4 Subtasks")).toBeInTheDocument() + }) }) describe("expand/collapse behavior", () => { it("calls onToggleExpand when chevron row is clicked", () => { const onToggleExpand = vi.fn() const group = createMockGroup({ - subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Child 1" })], + subtasks: [createMockSubtaskNode({ id: "child-1", task: "Child 1" })], }) - render() + render( + , + ) const collapsibleRow = screen.getByTestId("subtask-collapsible-row") fireEvent.click(collapsibleRow) @@ -124,12 +170,14 @@ describe("TaskGroupItem", () => { const group = createMockGroup({ isExpanded: true, subtasks: [ - createMockDisplayHistoryItem({ id: "child-1", task: "Subtask content 1" }), - createMockDisplayHistoryItem({ id: "child-2", task: "Subtask content 2" }), + createMockSubtaskNode({ id: "child-1", task: "Subtask content 1" }), + createMockSubtaskNode({ id: "child-2", task: "Subtask content 2" }), ], }) - render() + render( + , + ) expect(screen.getByTestId("subtask-list")).toBeInTheDocument() expect(screen.getByText("Subtask content 1")).toBeInTheDocument() @@ -139,16 +187,39 @@ describe("TaskGroupItem", () => { it("hides subtasks when collapsed", () => { const group = createMockGroup({ isExpanded: false, - subtasks: [createMockDisplayHistoryItem({ id: "child-1", task: "Subtask content" })], + subtasks: [createMockSubtaskNode({ id: "child-1", task: "Subtask content" })], }) - render() + render( + , + ) // The subtask-list element is present but collapsed via CSS (max-h-0) const subtaskList = screen.queryByTestId("subtask-list") expect(subtaskList).toBeInTheDocument() expect(subtaskList).toHaveClass("max-h-0") }) + + it("renders nested subtask when a node has children and is expanded", () => { + const group = createMockGroup({ + isExpanded: true, + subtasks: [ + createMockSubtaskNode( + { id: "child-1", task: "Parent subtask" }, + [createMockSubtaskNode({ id: "grandchild-1", task: "Nested subtask" })], + true, // child-1 is expanded + ), + ], + }) + + render( + , + ) + + expect(screen.getByText("Parent subtask")).toBeInTheDocument() + expect(screen.getByText("Nested subtask")).toBeInTheDocument() + expect(screen.getByTestId("subtask-row-grandchild-1")).toBeInTheDocument() + }) }) describe("selection mode", () => { @@ -166,6 +237,7 @@ describe("TaskGroupItem", () => { isSelected={false} onToggleSelection={onToggleSelection} onToggleExpand={vi.fn()} + onToggleSubtaskExpand={vi.fn()} />, ) @@ -188,6 +260,7 @@ describe("TaskGroupItem", () => { isSelected={true} onToggleSelection={vi.fn()} onToggleExpand={vi.fn()} + onToggleSubtaskExpand={vi.fn()} />, ) @@ -201,7 +274,14 @@ describe("TaskGroupItem", () => { it("passes compact variant to TaskItem", () => { const group = createMockGroup() - render() + render( + , + ) // TaskItem should be rendered with compact styling const taskItem = screen.getByTestId("task-item-parent-1") @@ -211,7 +291,9 @@ describe("TaskGroupItem", () => { it("passes full variant to TaskItem", () => { const group = createMockGroup() - render() + render( + , + ) const taskItem = screen.getByTestId("task-item-parent-1") expect(taskItem).toBeInTheDocument() @@ -225,7 +307,15 @@ describe("TaskGroupItem", () => { parent: createMockDisplayHistoryItem({ id: "parent-1", task: "Parent task" }), }) - render() + render( + , + ) // Delete button uses "delete-task-button" as testid const deleteButton = screen.getByTestId("delete-task-button") @@ -244,7 +334,15 @@ describe("TaskGroupItem", () => { }), }) - render() + render( + , + ) // Workspace should be displayed in TaskItem const taskItem = screen.getByTestId("task-item-parent-1") @@ -258,7 +356,15 @@ describe("TaskGroupItem", () => { it("applies custom className to container", () => { const group = createMockGroup() - render() + render( + , + ) const container = screen.getByTestId("task-group-parent-1") expect(container).toHaveClass("custom-class") diff --git a/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts b/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts index 4f280e72d40..8873695c62a 100644 --- a/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts +++ b/webview-ui/src/components/history/__tests__/useGroupedTasks.spec.ts @@ -2,7 +2,8 @@ import { renderHook, act } from "@/utils/test-utils" import type { HistoryItem } from "@roo-code/types" -import { useGroupedTasks } from "../useGroupedTasks" +import { useGroupedTasks, buildSubtree } from "../useGroupedTasks" +import { countAllSubtasks } from "../types" const createMockTask = (overrides: Partial = {}): HistoryItem => ({ id: "task-1", @@ -42,8 +43,8 @@ describe("useGroupedTasks", () => { expect(result.current.groups).toHaveLength(1) expect(result.current.groups[0].parent.id).toBe("parent-1") expect(result.current.groups[0].subtasks).toHaveLength(2) - expect(result.current.groups[0].subtasks[0].id).toBe("child-2") // Newest first - expect(result.current.groups[0].subtasks[1].id).toBe("child-1") + expect(result.current.groups[0].subtasks[0].item.id).toBe("child-2") // Newest first + expect(result.current.groups[0].subtasks[1].item.id).toBe("child-1") }) it("handles tasks with no children", () => { @@ -121,7 +122,7 @@ describe("useGroupedTasks", () => { expect(result.current.isSearchMode).toBe(false) }) - it("handles deeply nested tasks (grandchildren treated as children of their direct parent)", () => { + it("handles deeply nested tasks with recursive tree structure", () => { const rootTask = createMockTask({ id: "root-1", task: "Root task", @@ -146,10 +147,12 @@ describe("useGroupedTasks", () => { expect(result.current.groups).toHaveLength(1) expect(result.current.groups[0].parent.id).toBe("root-1") expect(result.current.groups[0].subtasks).toHaveLength(1) - expect(result.current.groups[0].subtasks[0].id).toBe("child-1") + expect(result.current.groups[0].subtasks[0].item.id).toBe("child-1") - // Note: grandchild is a child of child-1, not root-1 - // The current implementation only shows direct children in subtasks + // Grandchild is nested inside child's children + expect(result.current.groups[0].subtasks[0].children).toHaveLength(1) + expect(result.current.groups[0].subtasks[0].children[0].item.id).toBe("grandchild-1") + expect(result.current.groups[0].subtasks[0].children[0].children).toHaveLength(0) }) }) @@ -395,3 +398,199 @@ describe("useGroupedTasks", () => { }) }) }) + +describe("buildSubtree", () => { + it("builds a leaf node with no children", () => { + const task = createMockTask({ id: "task-1", task: "Leaf task" }) + const childrenMap = new Map() + + const node = buildSubtree(task, childrenMap, new Set()) + + expect(node.item.id).toBe("task-1") + expect(node.children).toHaveLength(0) + expect(node.isExpanded).toBe(false) + }) + + it("builds a node with direct children sorted newest first", () => { + const parent = createMockTask({ id: "parent-1", task: "Parent" }) + const child1 = createMockTask({ + id: "child-1", + task: "Child 1", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T12:00:00").getTime(), + }) + const child2 = createMockTask({ + id: "child-2", + task: "Child 2", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("parent-1", [child1, child2]) + + const node = buildSubtree(parent, childrenMap, new Set()) + + expect(node.item.id).toBe("parent-1") + expect(node.children).toHaveLength(2) + expect(node.children[0].item.id).toBe("child-2") // Newest first + expect(node.children[1].item.id).toBe("child-1") + expect(node.isExpanded).toBe(false) + expect(node.children[0].isExpanded).toBe(false) + expect(node.children[1].isExpanded).toBe(false) + }) + + it("builds a deeply nested tree recursively", () => { + const root = createMockTask({ id: "root", task: "Root" }) + const child = createMockTask({ + id: "child", + task: "Child", + parentTaskId: "root", + ts: new Date("2024-01-15T13:00:00").getTime(), + }) + const grandchild = createMockTask({ + id: "grandchild", + task: "Grandchild", + parentTaskId: "child", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + const greatGrandchild = createMockTask({ + id: "great-grandchild", + task: "Great Grandchild", + parentTaskId: "grandchild", + ts: new Date("2024-01-15T15:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("root", [child]) + childrenMap.set("child", [grandchild]) + childrenMap.set("grandchild", [greatGrandchild]) + + const node = buildSubtree(root, childrenMap, new Set()) + + expect(node.item.id).toBe("root") + expect(node.children).toHaveLength(1) + expect(node.children[0].item.id).toBe("child") + expect(node.children[0].children).toHaveLength(1) + expect(node.children[0].children[0].item.id).toBe("grandchild") + expect(node.children[0].children[0].children).toHaveLength(1) + expect(node.children[0].children[0].children[0].item.id).toBe("great-grandchild") + expect(node.children[0].children[0].children[0].children).toHaveLength(0) + }) + + it("does not mutate the original childrenMap arrays", () => { + const parent = createMockTask({ id: "parent-1", task: "Parent" }) + const child1 = createMockTask({ + id: "child-1", + task: "Child 1", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T12:00:00").getTime(), + }) + const child2 = createMockTask({ + id: "child-2", + task: "Child 2", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + + const originalChildren = [child1, child2] + const childrenMap = new Map() + childrenMap.set("parent-1", originalChildren) + + buildSubtree(parent, childrenMap, new Set()) + + // Original array should not be mutated (sort is on a slice) + expect(originalChildren[0].id).toBe("child-1") + expect(originalChildren[1].id).toBe("child-2") + }) + + it("sets isExpanded: true when task ID is in expandedIds", () => { + const parent = createMockTask({ id: "parent-1", task: "Parent" }) + const child = createMockTask({ + id: "child-1", + task: "Child", + parentTaskId: "parent-1", + ts: new Date("2024-01-15T13:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("parent-1", [child]) + + const expandedIds = new Set(["parent-1"]) + const node = buildSubtree(parent, childrenMap, expandedIds) + + expect(node.isExpanded).toBe(true) + expect(node.children[0].isExpanded).toBe(false) + }) + + it("propagates isExpanded correctly through deeply nested tree", () => { + const root = createMockTask({ id: "root", task: "Root" }) + const child = createMockTask({ + id: "child", + task: "Child", + parentTaskId: "root", + ts: new Date("2024-01-15T13:00:00").getTime(), + }) + const grandchild = createMockTask({ + id: "grandchild", + task: "Grandchild", + parentTaskId: "child", + ts: new Date("2024-01-15T14:00:00").getTime(), + }) + const greatGrandchild = createMockTask({ + id: "great-grandchild", + task: "Great Grandchild", + parentTaskId: "grandchild", + ts: new Date("2024-01-15T15:00:00").getTime(), + }) + + const childrenMap = new Map() + childrenMap.set("root", [child]) + childrenMap.set("child", [grandchild]) + childrenMap.set("grandchild", [greatGrandchild]) + + // Expand root and grandchild, but NOT child + const expandedIds = new Set(["root", "grandchild"]) + const node = buildSubtree(root, childrenMap, expandedIds) + + expect(node.isExpanded).toBe(true) + expect(node.children[0].isExpanded).toBe(false) // child not expanded + expect(node.children[0].children[0].isExpanded).toBe(true) // grandchild expanded + expect(node.children[0].children[0].children[0].isExpanded).toBe(false) // great-grandchild not expanded + }) +}) + +describe("countAllSubtasks", () => { + it("returns 0 for empty array", () => { + expect(countAllSubtasks([])).toBe(0) + }) + + it("returns count of items in flat list (no grandchildren)", () => { + const nodes = [ + { item: createMockTask({ id: "a" }), children: [], isExpanded: false }, + { item: createMockTask({ id: "b" }), children: [], isExpanded: false }, + { item: createMockTask({ id: "c" }), children: [], isExpanded: false }, + ] + expect(countAllSubtasks(nodes)).toBe(3) + }) + + it("returns total count at all nesting levels", () => { + const nodes = [ + { + item: createMockTask({ id: "a" }), + children: [ + { + item: createMockTask({ id: "a1" }), + children: [{ item: createMockTask({ id: "a1i" }), children: [], isExpanded: false }], + isExpanded: false, + }, + { item: createMockTask({ id: "a2" }), children: [], isExpanded: false }, + ], + isExpanded: false, + }, + { item: createMockTask({ id: "b" }), children: [], isExpanded: false }, + ] + // a (1) + a1 (1) + a1i (1) + a2 (1) + b (1) = 5 + expect(countAllSubtasks(nodes)).toBe(5) + }) +}) diff --git a/webview-ui/src/components/history/types.ts b/webview-ui/src/components/history/types.ts index a12dfbce630..0de5e430812 100644 --- a/webview-ui/src/components/history/types.ts +++ b/webview-ui/src/components/history/types.ts @@ -11,13 +11,36 @@ export interface DisplayHistoryItem extends HistoryItem { } /** - * A group of tasks consisting of a parent task and its subtasks + * A node in the subtask tree, representing a task and its recursively nested children. + */ +export interface SubtaskTreeNode { + /** The task at this tree node */ + item: DisplayHistoryItem + /** Recursively nested child subtasks */ + children: SubtaskTreeNode[] + /** Whether this node's children are expanded in the UI */ + isExpanded: boolean +} + +/** + * Recursively counts all subtasks in a tree of SubtaskTreeNodes. + */ +export function countAllSubtasks(nodes: SubtaskTreeNode[]): number { + let count = 0 + for (const node of nodes) { + count += 1 + countAllSubtasks(node.children) + } + return count +} + +/** + * A group of tasks consisting of a parent task and its nested subtask tree */ export interface TaskGroup { /** The parent task */ parent: DisplayHistoryItem - /** List of direct subtasks */ - subtasks: DisplayHistoryItem[] + /** Tree of subtasks (supports arbitrary nesting depth) */ + subtasks: SubtaskTreeNode[] /** Whether the subtask list is expanded */ isExpanded: boolean } diff --git a/webview-ui/src/components/history/useGroupedTasks.ts b/webview-ui/src/components/history/useGroupedTasks.ts index 9d7085881eb..d3f3d4e9531 100644 --- a/webview-ui/src/components/history/useGroupedTasks.ts +++ b/webview-ui/src/components/history/useGroupedTasks.ts @@ -1,6 +1,29 @@ import { useState, useMemo, useCallback } from "react" import type { HistoryItem } from "@roo-code/types" -import type { DisplayHistoryItem, TaskGroup, GroupedTasksResult } from "./types" +import type { DisplayHistoryItem, SubtaskTreeNode, TaskGroup, GroupedTasksResult } from "./types" + +/** + * Recursively builds a subtask tree node for the given task. + * Pure function — exported for independent testing. + * + * @param task - The task to build a tree node for + * @param childrenMap - Map of parentId → direct children + * @param expandedIds - Set of task IDs whose children are currently expanded + * @returns A SubtaskTreeNode with recursively built children sorted by ts (newest first) + */ +export function buildSubtree( + task: HistoryItem, + childrenMap: Map, + expandedIds: Set, +): SubtaskTreeNode { + const directChildren = (childrenMap.get(task.id) || []).slice().sort((a, b) => b.ts - a.ts) + + return { + item: task as DisplayHistoryItem, + children: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)), + isExpanded: expandedIds.has(task.id), + } +} /** * Hook to transform a flat task list into grouped structure based on parent-child relationships. @@ -31,7 +54,7 @@ export function useGroupedTasks(tasks: HistoryItem[], searchQuery: string): Grou return [] } - // Build children map: parentId -> children[] + // Build children map: parentId -> direct children[] const childrenMap = new Map() for (const task of tasks) { @@ -44,19 +67,16 @@ export function useGroupedTasks(tasks: HistoryItem[], searchQuery: string): Grou // Identify root tasks - tasks that either: // 1. Have no parentTaskId - // 2. Have a parentTaskId that doesn't exist in our task list + // 2. Have a parentTaskId that doesn't exist in our task list (orphans promoted to root) const rootTasks = tasks.filter((task) => !task.parentTaskId || !taskMap.has(task.parentTaskId)) - // Build groups from root tasks + // Build groups from root tasks with recursively nested subtask trees const taskGroups: TaskGroup[] = rootTasks.map((parent) => { - // Get direct children (sorted by timestamp, newest first) - const subtasks = (childrenMap.get(parent.id) || []) - .slice() - .sort((a, b) => b.ts - a.ts) as DisplayHistoryItem[] + const directChildren = (childrenMap.get(parent.id) || []).slice().sort((a, b) => b.ts - a.ts) return { parent: parent as DisplayHistoryItem, - subtasks, + subtasks: directChildren.map((child) => buildSubtree(child, childrenMap, expandedIds)), isExpanded: expandedIds.has(parent.id), } }) From 12cddc96971dca86beda687c266a705c23fba0ab Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 7 Feb 2026 22:25:36 -0700 Subject: [PATCH 21/31] fix: validate Gemini thinkingLevel against model capabilities and handle empty streams (#11303) * fix: validate Gemini thinkingLevel against model capabilities and handle empty streams getGeminiReasoning() now validates the selected effort against the model's supportsReasoningEffort array before sending it as thinkingLevel. When a stale settings value (e.g. 'medium' from a different model) is not in the supported set, it falls back to the model's default reasoningEffort. GeminiHandler.createMessage() now tracks whether any text content was yielded during streaming and handles NoOutputGeneratedError gracefully instead of surfacing the cryptic 'No output generated' error. * fix: guard thinkingLevel fallback against 'none' effort and add i18n TODO The array validation fallback in getGeminiReasoning() now only triggers when the selected effort IS a valid Gemini thinking level but not in the model's supported set. Values like 'none' (explicit no-reasoning signal) are no longer overridden by the model default. Also adds a TODO for moving the empty-stream message to i18n. * fix: track tool_call_start in hasContent to avoid false empty-stream warning Tool-only responses (no text) are valid content. Without this, agentic tool-call responses would incorrectly trigger the empty response warning message. --- src/api/providers/__tests__/gemini.spec.ts | 80 ++++++++++++ src/api/providers/gemini.ts | 37 +++++- src/api/transform/__tests__/reasoning.spec.ts | 123 ++++++++++++++++++ src/api/transform/reasoning.ts | 14 +- 4 files changed, 248 insertions(+), 6 deletions(-) diff --git a/src/api/providers/__tests__/gemini.spec.ts b/src/api/providers/__tests__/gemini.spec.ts index ceeb553da3e..13875499ee6 100644 --- a/src/api/providers/__tests__/gemini.spec.ts +++ b/src/api/providers/__tests__/gemini.spec.ts @@ -1,5 +1,7 @@ // npx vitest run src/api/providers/__tests__/gemini.spec.ts +import { NoOutputGeneratedError } from "ai" + const mockCaptureException = vitest.fn() vitest.mock("@roo-code/telemetry", () => ({ @@ -149,6 +151,84 @@ describe("GeminiHandler", () => { ) }) + it("should yield informative message when stream produces no text content", async () => { + // Stream with only reasoning (no text-delta) simulates thinking-only response + const mockFullStream = (async function* () { + yield { type: "reasoning-delta", id: "1", text: "thinking..." } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.resolve({ inputTokens: 10, outputTokens: 0 }), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have: reasoning chunk, empty-stream informative message, usage + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0]).toEqual({ + type: "text", + text: "Model returned an empty response. This may be caused by an unsupported thinking configuration or content filtering.", + }) + }) + + it("should suppress NoOutputGeneratedError when no text content was yielded", async () => { + // Empty stream - nothing yielded at all + const mockFullStream = (async function* () { + // empty stream + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + const chunks = [] + + // Should NOT throw - the error is suppressed + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have the informative empty-stream message only (no usage since it errored) + const textChunks = chunks.filter((c) => c.type === "text") + expect(textChunks).toHaveLength(1) + expect(textChunks[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("empty response"), + }) + }) + + it("should re-throw NoOutputGeneratedError when text content was yielded", async () => { + // Stream yields text content but usage still throws NoOutputGeneratedError (unexpected) + const mockFullStream = (async function* () { + yield { type: "text-delta", text: "Hello" } + })() + + mockStreamText.mockReturnValue({ + fullStream: mockFullStream, + usage: Promise.reject(new NoOutputGeneratedError({ message: "No output generated." })), + providerMetadata: Promise.resolve({}), + }) + + const stream = handler.createMessage(systemPrompt, mockMessages) + + await expect(async () => { + for await (const _chunk of stream) { + // consume stream + } + }).rejects.toThrow() + }) + it("should handle API errors", async () => { const mockError = new Error("Gemini API error") // eslint-disable-next-line require-yield diff --git a/src/api/providers/gemini.ts b/src/api/providers/gemini.ts index 96d7efa92c9..f7ebfdeeb9e 100644 --- a/src/api/providers/gemini.ts +++ b/src/api/providers/gemini.ts @@ -1,6 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" import { createGoogleGenerativeAI, type GoogleGenerativeAIProvider } from "@ai-sdk/google" -import { streamText, generateText, ToolSet } from "ai" +import { streamText, generateText, NoOutputGeneratedError, ToolSet } from "ai" import { type ModelInfo, @@ -131,6 +131,9 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl // Use streamText for streaming responses const result = streamText(requestOptions) + // Track whether any text content was yielded (not just reasoning/thinking) + let hasContent = false + // Process the full stream to get all events including reasoning for await (const part of result.fullStream) { // Capture thoughtSignature from tool-call events (Gemini 3 thought signatures) @@ -143,10 +146,22 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } for (const chunk of processAiSdkStreamPart(part)) { + if (chunk.type === "text" || chunk.type === "tool_call_start") { + hasContent = true + } yield chunk } } + // If the stream completed without yielding any text content, inform the user + // TODO: Move to i18n key common:errors.gemini.empty_response once translation pipeline is updated + if (!hasContent) { + yield { + type: "text" as const, + text: "Model returned an empty response. This may be caused by an unsupported thinking configuration or content filtering.", + } + } + // Extract grounding sources from providerMetadata if available const providerMetadata = await result.providerMetadata const groundingMetadata = providerMetadata?.google as @@ -167,9 +182,23 @@ export class GeminiHandler extends BaseProvider implements SingleCompletionHandl } // Yield usage metrics at the end - const usage = await result.usage - if (usage) { - yield this.processUsageMetrics(usage, info, providerMetadata) + // Wrap in try-catch to handle NoOutputGeneratedError thrown by the AI SDK + // when the stream produces no output (e.g., thinking-only, safety block) + try { + const usage = await result.usage + if (usage) { + yield this.processUsageMetrics(usage, info, providerMetadata) + } + } catch (usageError) { + if (usageError instanceof NoOutputGeneratedError) { + // If we already yielded the empty-stream message, suppress this error + if (hasContent) { + throw usageError + } + // Otherwise the informative message was already yielded above — no-op + } else { + throw usageError + } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error) diff --git a/src/api/transform/__tests__/reasoning.spec.ts b/src/api/transform/__tests__/reasoning.spec.ts index 352aac8e7bb..0b402c6d55c 100644 --- a/src/api/transform/__tests__/reasoning.spec.ts +++ b/src/api/transform/__tests__/reasoning.spec.ts @@ -765,6 +765,7 @@ describe("reasoning.ts", () => { } const result = getGeminiReasoning(options) + // "none" is not a valid GeminiThinkingLevel, so no fallback — returns undefined expect(result).toBeUndefined() }) @@ -838,6 +839,128 @@ describe("reasoning.ts", () => { const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined expect(result).toEqual({ thinkingLevel: "medium", includeThoughts: true }) }) + + it("should fall back to model default when settings effort is not in supportsReasoningEffort array", () => { + // Simulates gemini-3-pro-preview which only supports ["low", "high"] + // but user has reasoningEffort: "medium" from a different model + const geminiModel: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"], + reasoningEffort: "low", + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + reasoningEffort: "medium", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: undefined, + reasoningEffort: "medium", + settings, + } + + const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined + // "medium" is not in ["low", "high"], so falls back to model.reasoningEffort "low" + expect(result).toEqual({ thinkingLevel: "low", includeThoughts: true }) + }) + + it("should return undefined when unsupported effort and model default is also invalid", () => { + const geminiModel: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"], + // No reasoningEffort default set + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + reasoningEffort: "medium", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: undefined, + reasoningEffort: "medium", + settings, + } + + const result = getGeminiReasoning(options) + // "medium" is not in ["low", "high"], fallback is undefined → returns undefined + expect(result).toBeUndefined() + }) + + it("should pass through effort that IS in the supportsReasoningEffort array", () => { + const geminiModel: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"], + reasoningEffort: "low", + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + reasoningEffort: "high", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: undefined, + reasoningEffort: "high", + settings, + } + + const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined + // "high" IS in ["low", "high"], so it should be used directly + expect(result).toEqual({ thinkingLevel: "high", includeThoughts: true }) + }) + + it("should skip validation when supportsReasoningEffort is boolean (not array)", () => { + const geminiModel: ModelInfo = { + ...baseModel, + supportsReasoningEffort: true, + reasoningEffort: "low", + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + reasoningEffort: "medium", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: undefined, + reasoningEffort: "medium", + settings, + } + + const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined + // boolean supportsReasoningEffort should not trigger array validation + expect(result).toEqual({ thinkingLevel: "medium", includeThoughts: true }) + }) + + it("should fall back to model default when settings has 'minimal' but model only supports ['low', 'high']", () => { + const geminiModel: ModelInfo = { + ...baseModel, + supportsReasoningEffort: ["low", "high"] as ModelInfo["supportsReasoningEffort"], + reasoningEffort: "low", + } + + const settings: ProviderSettings = { + apiProvider: "gemini", + reasoningEffort: "minimal", + } + + const options: GetModelReasoningOptions = { + model: geminiModel, + reasoningBudget: undefined, + reasoningEffort: "minimal", + settings, + } + + const result = getGeminiReasoning(options) as GeminiReasoningParams | undefined + // "minimal" is not in ["low", "high"], falls back to "low" + expect(result).toEqual({ thinkingLevel: "low", includeThoughts: true }) + }) }) describe("Integration scenarios", () => { diff --git a/src/api/transform/reasoning.ts b/src/api/transform/reasoning.ts index e726ce32234..446221d256f 100644 --- a/src/api/transform/reasoning.ts +++ b/src/api/transform/reasoning.ts @@ -150,10 +150,20 @@ export const getGeminiReasoning = ({ return undefined } + // Validate that the selected effort is supported by this specific model. + // e.g. gemini-3-pro-preview only supports ["low", "high"] — sending + // "medium" (carried over from a different model's settings) causes errors. + const effortToUse = + Array.isArray(model.supportsReasoningEffort) && + isGeminiThinkingLevel(selectedEffort) && + !model.supportsReasoningEffort.includes(selectedEffort) + ? model.reasoningEffort + : selectedEffort + // Effort-based models on Google GenAI support minimal/low/medium/high levels. - if (!isGeminiThinkingLevel(selectedEffort)) { + if (!effortToUse || !isGeminiThinkingLevel(effortToUse)) { return undefined } - return { thinkingLevel: selectedEffort, includeThoughts: true } + return { thinkingLevel: effortToUse, includeThoughts: true } } From 4bc3d62d1f2130364ff5130f737aa592e92bcc7f Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Sun, 8 Feb 2026 20:20:37 -0800 Subject: [PATCH 22/31] Add linux-arm64 for the roo cli (#11314) --- .github/workflows/cli-release.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 3bcb8995fd1..20961a9f2d3 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -26,6 +26,9 @@ jobs: - os: ubuntu-latest platform: linux-x64 runs-on: ubuntu-latest + - os: ubuntu-24.04-arm + platform: linux-arm64 + runs-on: ubuntu-24.04-arm runs-on: ${{ matrix.runs-on }} @@ -328,7 +331,7 @@ jobs: echo "## Requirements" >> "$NOTES_FILE" echo "" >> "$NOTES_FILE" echo "- Node.js 20 or higher" >> "$NOTES_FILE" - echo "- macOS Apple Silicon (M1/M2/M3/M4) or Linux x64" >> "$NOTES_FILE" + echo "- macOS Apple Silicon (M1/M2/M3/M4), Linux x64, or Linux ARM64" >> "$NOTES_FILE" echo "" >> "$NOTES_FILE" echo "## Usage" >> "$NOTES_FILE" echo "" >> "$NOTES_FILE" @@ -345,6 +348,7 @@ jobs: echo "This release includes binaries for:" >> "$NOTES_FILE" echo '- `roo-cli-darwin-arm64.tar.gz` - macOS Apple Silicon (M1/M2/M3)' >> "$NOTES_FILE" echo '- `roo-cli-linux-x64.tar.gz` - Linux x64' >> "$NOTES_FILE" + echo '- `roo-cli-linux-arm64.tar.gz` - Linux ARM64' >> "$NOTES_FILE" echo "" >> "$NOTES_FILE" echo "## Checksums" >> "$NOTES_FILE" echo "" >> "$NOTES_FILE" From 99a2e3b1ed2efda5e10fd1f7f8b8790ebb54d033 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Sun, 8 Feb 2026 21:04:59 -0800 Subject: [PATCH 23/31] chore(cli): prepare release v0.0.52 (#11324) * chore(cli): prepare release v0.0.52 * Update CHANGELOG for build cleanup and Linux support Removed unused dependency from build configuration and added Linux support. --- apps/cli/CHANGELOG.md | 6 ++++++ apps/cli/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 87fcc57add8..98231e84808 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to the `@roo-code/cli` package will be documented in this fi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.52] - 2026-02-09 + +### Added + +- **Linux Support**: Added support for `linux-arm64`. + ## [0.0.51] - 2026-02-06 ### Changed diff --git a/apps/cli/package.json b/apps/cli/package.json index b9589d985be..029e6772011 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@roo-code/cli", - "version": "0.0.51", + "version": "0.0.52", "description": "Roo Code CLI - Run the Roo Code agent from the command line", "private": true, "type": "module", From 63223dc5dba722ed610af8be9a84d2692b78ba03 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 9 Feb 2026 09:48:12 +0000 Subject: [PATCH 24/31] fix: remove dead getOrganizationMetadata and unused socket.io-client dep --- packages/cloud/package.json | 1 - packages/cloud/src/WebAuthService.ts | 25 ------------------------- pnpm-lock.yaml | 3 --- 3 files changed, 29 deletions(-) diff --git a/packages/cloud/package.json b/packages/cloud/package.json index 2d4456d2739..92ecaa17647 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -15,7 +15,6 @@ "ioredis": "^5.6.1", "jwt-decode": "^4.0.0", "p-wait-for": "^5.0.2", - "socket.io-client": "^4.8.1", "zod": "^3.25.76" }, "devDependencies": { diff --git a/packages/cloud/src/WebAuthService.ts b/packages/cloud/src/WebAuthService.ts index 8f51bad236c..501bf95bb55 100644 --- a/packages/cloud/src/WebAuthService.ts +++ b/packages/cloud/src/WebAuthService.ts @@ -718,31 +718,6 @@ export class WebAuthService extends EventEmitter implements A throw new Error(errorMessage) } - private async getOrganizationMetadata( - organizationId: string, - ): Promise<{ public_metadata?: Record } | null> { - try { - const response = await fetch(`${getClerkBaseUrl()}/v1/organizations/${organizationId}`, { - headers: { - Authorization: `Bearer ${this.credentials!.clientToken}`, - "User-Agent": this.userAgent(), - }, - signal: AbortSignal.timeout(10000), - }) - - if (!response.ok) { - this.log(`[auth] Failed to fetch organization metadata: ${response.status} ${response.statusText}`) - return null - } - - const data = await response.json() - return data.response || data - } catch (error) { - this.log("[auth] Error fetching organization metadata:", error) - return null - } - } - private async clerkLogout(credentials: AuthCredentials): Promise { const formData = new URLSearchParams() formData.append("_is_native", "1") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a47ae416ae..11ca6e085e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -489,9 +489,6 @@ importers: p-wait-for: specifier: ^5.0.2 version: 5.0.2 - socket.io-client: - specifier: ^4.8.1 - version: 4.8.1 zod: specifier: 3.25.76 version: 3.25.76 From fc5793437f97bf2ca71c771452d292779c24366c Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Mon, 9 Feb 2026 09:56:47 +0000 Subject: [PATCH 25/31] Readmes --- README.md | 11 +++++------ locales/ca/README.md | 5 ++--- locales/de/README.md | 5 ++--- locales/es/README.md | 5 ++--- locales/hi/README.md | 5 ++--- locales/id/README.md | 5 ++--- locales/it/README.md | 5 ++--- locales/ja/README.md | 5 ++--- locales/ko/README.md | 5 ++--- locales/nl/README.md | 3 +-- locales/pl/README.md | 5 ++--- locales/pt-BR/README.md | 5 ++--- locales/ru/README.md | 5 ++--- locales/tr/README.md | 11 +++++------ locales/vi/README.md | 11 +++++------ 15 files changed, 38 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 75f37762f93..e7621cf12f5 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ - [简体中文](locales/zh-CN/README.md) - [繁體中文](locales/zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code adapts to how you work: - Ask Mode: fast answers, explanations, and docs - Debug Mode: trace issues, add logs, isolate root causes - Custom Modes: build specialized modes for your team or workflow -- Roomote Control: Roomote Control lets you remotely control tasks running in your local VS Code instance. Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -66,10 +65,10 @@ Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) •
-| | | | -| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | -|
Custom Modes |
Checkpoints |
Context Management | +| | | | +| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
Installing Roo Code |
Configuring Profiles |
Codebase Indexing | +|
Custom Modes |
Checkpoints |
Context Management |

diff --git a/locales/ca/README.md b/locales/ca/README.md index 0c09d8c6611..5e5ac76afa9 100644 --- a/locales/ca/README.md +++ b/locales/ca/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code s'adapta a la teva manera de treballar, no a l'inrevés: - Mode Pregunta: respostes ràpides, explicacions i documents - Mode Depuració: rastrejar problemes, afegir registres, aïllar les causes arrel - Modes personalitzats: crea modes especialitzats per al teu equip o flux de treball -- Roomote Control: Roomote Control et permet controlar a distància tasques que s'executen a la teva instància local de VS Code. Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personalitzats](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-mode | | | | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Instal·lant Roo Code |
Configurant perfils |
Indexació de la base de codi | -|
Modes personalitzats |
Punts de control |
Gestió de Context | +|
Modes personalitzats |
Punts de control |
Gestió de Context |

diff --git a/locales/de/README.md b/locales/de/README.md index 526d601e70b..d3874c63c55 100644 --- a/locales/de/README.md +++ b/locales/de/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code passt sich an deine Arbeitsweise an, nicht umgekehrt: - Fragen-Modus: schnelle Antworten, Erklärungen und Dokumentationen - Debug-Modus: Probleme aufspüren, Protokolle hinzufügen, Ursachen isolieren - Benutzerdefinierte Modi: erstelle spezialisierte Modi für dein Team oder deinen Workflow -- Roomote Control: Mit Roomote Control kannst du Aufgaben in deiner lokalen VS Code-Instanz aus der Ferne steuern. Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes) • [Benutzerdefinierte Modi](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Roo Code installieren |
Profile konfigurieren |
Codebasis-Indizierung | -|
Benutzerdefinierte Modi |
Checkpoints |
Kontextverwaltung | +|
Benutzerdefinierte Modi |
Checkpoints |
Kontextverwaltung |

diff --git a/locales/es/README.md b/locales/es/README.md index 9e378b7fd7c..f66c61bb268 100644 --- a/locales/es/README.md +++ b/locales/es/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code se adapta a tu forma de trabajar, no al revés: - Modo Pregunta: respuestas rápidas, explicaciones y documentos - Modo Depuración: rastrear problemas, agregar registros, aislar causas raíz - Modos Personalizados: crea modos especializados para tu equipo o flujo de trabajo -- Roomote Control: Roomote Control te permite controlar de forma remota tareas que se ejecutan en tu instancia local de VS Code. Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos Personalizados](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [M | | | | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Instalando Roo Code |
Configurando perfiles |
Indexación de la base de código | -|
Modos personalizados |
Checkpoints |
Gestión de Contexto | +|
Modos personalizados |
Checkpoints |
Gestión de Contexto |

diff --git a/locales/hi/README.md b/locales/hi/README.md index 8d12b689944..08179c6831e 100644 --- a/locales/hi/README.md +++ b/locales/hi/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ - पूछें मोड: त्वरित उत्तर, स्पष्टीकरण और डॉक्स - डीबग मोड: समस्याओं का पता लगाएं, लॉग जोड़ें, मूल कारणों को अलग करें - कस्टम मोड: अपनी टीम या वर्कफ़्लो के लिए विशेष मोड बनाएं -- Roomote Control: Roomote Control से तुम अपनी लोकल VS Code इंस्टेंस में चल रही टास्क को रिमोट से कंट्रोल कर सकते हो। और जानो: [मोड्स का इस्तेमाल](https://docs.roocode.com/basic-usage/using-modes) • [कस्टम मोड्स](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
रू कोड इंस्टॉल करना |
प्रोफाइल कॉन्फ़िगर करना |
कोडबेस इंडेक्सिंग | -|
कस्टम मोड |
चेकपॉइंट्स |
संदर्भ प्रबंधन | +|
कस्टम मोड |
चेकपॉइंट्स |
संदर्भ प्रबंधन |

diff --git a/locales/id/README.md b/locales/id/README.md index 657b1ab750f..4a46a0d5d7f 100644 --- a/locales/id/README.md +++ b/locales/id/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code beradaptasi dengan cara Anda bekerja, bukan sebaliknya: - Mode Tanya: jawaban cepat, penjelasan, dan dokumen - Mode Debug: melacak masalah, menambahkan log, mengisolasi akar penyebab - Mode Kustom: buat mode khusus untuk tim atau alur kerja Anda -- Roomote Control: Roomote Control memungkinkan kamu mengontrol dari jarak jauh tugas yang berjalan di VS Code lokalmu. Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/using-modes) • [Mode Kustom](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/u | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Menginstal Roo Code |
Mengonfigurasi Profil |
Pengindeksan Basis Kode | -|
Mode Kustom |
Pos Pemeriksaan |
Manajemen Konteks | +|
Mode Kustom |
Pos Pemeriksaan |
Manajemen Konteks |

diff --git a/locales/it/README.md b/locales/it/README.md index 9bd5ce9e81d..9d4bf887c88 100644 --- a/locales/it/README.md +++ b/locales/it/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code si adatta al tuo modo di lavorare, non il contrario: - Modalità Chiedi: risposte rapide, spiegazioni e documenti - Modalità Debug: traccia problemi, aggiungi log, isola le cause principali - Modalità Personalizzate: crea modalità specializzate per il tuo team o flusso di lavoro -- Roomote Control: Roomote Control ti permette di controllare da remoto le attività in esecuzione sulla tua istanza locale di VS Code. Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using-modes) • [Modalità personalizzate](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using- | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Installazione di Roo Code |
Configurazione dei profili |
Indicizzazione della codebase | -|
Modalità personalizzate |
Checkpoint |
Gestione del Contesto | +|
Modalità personalizzate |
Checkpoint |
Gestione del Contesto |

diff --git a/locales/ja/README.md b/locales/ja/README.md index 3b7a7a6e6ef..bca736fbce0 100644 --- a/locales/ja/README.md +++ b/locales/ja/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Codeは、あなたの働き方に合わせるように適応します。 - 質問モード:迅速な回答、説明、ドキュメント - デバッグモード:問題の追跡、ログの追加、根本原因の特定 - カスタムモード:チームやワークフローに特化したモードの構築 -- Roomote Control: Roomote Control はローカルの VS Code で実行中のタスクをリモート操作できます。 詳しくは: [モードの使い方](https://docs.roocode.com/basic-usage/using-modes) • [カスタムモード](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ Roo Codeは、あなたの働き方に合わせるように適応します。 | | | | | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Roo Codeのインストール |
プロファイルの設定 |
コードベースのインデックス作成 | -|
カスタムモード |
チェックポイント |
コンテキスト管理 | +|
カスタムモード |
チェックポイント |
コンテキスト管理 |

diff --git a/locales/ko/README.md b/locales/ko/README.md index a53a1b965f0..98491cd0bd8 100644 --- a/locales/ko/README.md +++ b/locales/ko/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다. - 질문 모드: 빠른 답변, 설명 및 문서 - 디버그 모드: 문제 추적, 로그 추가, 근본 원인 격리 - 사용자 지정 모드: 팀이나 워크플로우를 위한 특수 모드 구축 -- Roomote Control: Roomote Control은 로컬 VS Code 인스턴스에서 실행 중인 작업을 원격으로 제어할 수 있어. 자세히: [모드 사용](https://docs.roocode.com/basic-usage/using-modes) • [사용자 지정 모드](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다. | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Roo Code 설치하기 |
프로필 구성하기 |
코드베이스 인덱싱 | -|
사용자 지정 모드 |
체크포인트 |
컨텍스트 관리 | +|
사용자 지정 모드 |
체크포인트 |
컨텍스트 관리 |

diff --git a/locales/nl/README.md b/locales/nl/README.md index fa5454b9c54..0dac08b525f 100644 --- a/locales/nl/README.md +++ b/locales/nl/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code past zich aan jouw werkwijze aan, niet andersom: - Vraag Modus: snelle antwoorden, uitleg en documenten - Debug Modus: spoor problemen op, voeg logs toe, isoleer de oorzaak - Aangepaste Modi: bouw gespecialiseerde modi voor je team of workflow -- Roomote Control: Roomote Control laat je taken op je lokale VS Code-instantie op afstand besturen. Meer info: [Modi gebruiken](https://docs.roocode.com/basic-usage/using-modes) • [Aangepaste modi](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) diff --git a/locales/pl/README.md b/locales/pl/README.md index b8553a08c74..99bbe92732f 100644 --- a/locales/pl/README.md +++ b/locales/pl/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code dostosowuje się do Twojego sposobu pracy, a nie odwrotnie: - Tryb Zapytaj: szybkie odpowiedzi, wyjaśnienia i dokumenty - Tryb Debugowanie: śledzenie problemów, dodawanie logów, izolowanie przyczyn źródłowych - Tryby niestandardowe: buduj specjalistyczne tryby dla swojego zespołu lub przepływu pracy -- Roomote Control: Roomote Control pozwala zdalnie sterować zadaniami uruchomionymi na twojej lokalnej instancji VS Code. Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-modes) • [Tryby niestandardowe](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-mode | | | | | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Instalacja Roo Code |
Konfiguracja profili |
Indeksowanie bazy kodu | -|
Tryby niestandardowe |
Punkty kontrolne |
Zarządzanie Kontekstem | +|
Tryby niestandardowe |
Punkty kontrolne |
Zarządzanie Kontekstem |

diff --git a/locales/pt-BR/README.md b/locales/pt-BR/README.md index 3b128b0fd39..20e8a589201 100644 --- a/locales/pt-BR/README.md +++ b/locales/pt-BR/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ O Roo Code se adapta à sua maneira de trabalhar, e não o contrário: - Modo Pergunta: respostas rápidas, explicações e documentos - Modo Depuração: rastreie problemas, adicione logs, isole as causas raiz - Modos Personalizados: crie modos especializados para sua equipe ou fluxo de trabalho -- Roomote Control: O Roomote Control permite controlar remotamente tarefas em execução na sua instância local do VS Code. Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos personalizados](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [ | | | | | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Instalando o Roo Code |
Configurando perfis |
Indexação da base de código | -|
Modos personalizados |
Checkpoints |
Gerenciamento de Contexto | +|
Modos personalizados |
Checkpoints |
Gerenciamento de Contexto |

diff --git a/locales/ru/README.md b/locales/ru/README.md index 9abf4ae5110..6c7de2568f3 100644 --- a/locales/ru/README.md +++ b/locales/ru/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code адаптируется к вашему стилю работы, а н - Режим Вопрос: быстрые ответы, объяснения и документация - Режим Отладка: отслеживание проблем, добавление логов, изоляция первопричин - Пользовательские режимы: создавайте специализированные режимы для вашей команды или рабочего процесса -- Roomote Control: Roomote Control позволяет удаленно управлять задачами, запущенными в вашей локальной инстансе VS Code. Подробнее: [Использование режимов](https://docs.roocode.com/basic-usage/using-modes) • [Пользовательские режимы](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -69,7 +68,7 @@ Roo Code адаптируется к вашему стилю работы, а н | | | | | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Установка Roo Code |
Настройка профилей |
Индексация кодовой базы | -|
Пользовательские режимы |
Контрольные точки |
Управление Контекстом | +|
Пользовательские режимы |
Контрольные точки |
Управление Контекстом |

diff --git a/locales/tr/README.md b/locales/tr/README.md index ac5a7884070..1fc0708c0dd 100644 --- a/locales/tr/README.md +++ b/locales/tr/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code, sizin çalışma şeklinize uyum sağlar, tam tersi değil: - Sor Modu: hızlı cevaplar, açıklamalar ve belgeler - Hata Ayıklama Modu: sorunları izleyin, günlükler ekleyin, kök nedenleri izole edin - Özel Modlar: ekibiniz veya iş akışınız için özel modlar oluşturun -- Roomote Control: Roomote Control, yerel VS Code örneğinde çalışan işleri uzaktan kontrol etmeni sağlar. Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes) • [Özel modlar](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -66,10 +65,10 @@ Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes

-| | | | -| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
Roo Code Kurulumu |
Profilleri Yapılandırma |
Kod Tabanı İndeksleme | -|
Özel Modlar |
Kontrol Noktaları |
Bağlam Yönetimi | +| | | | +| :---------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
Roo Code Kurulumu |
Profilleri Yapılandırma |
Kod Tabanı İndeksleme | +|
Özel Modlar |
Kontrol Noktaları |
Bağlam Yönetimi |

diff --git a/locales/vi/README.md b/locales/vi/README.md index bedaa3c26ac..ded54a4f42c 100644 --- a/locales/vi/README.md +++ b/locales/vi/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code thích ứng với cách bạn làm việc, chứ không phải ngượ - Chế độ Hỏi: câu trả lời nhanh, giải thích và tài liệu - Chế độ Gỡ lỗi: theo dõi sự cố, thêm nhật ký, cô lập nguyên nhân gốc rễ - Chế độ Tùy chỉnh: xây dựng các chế độ chuyên biệt cho nhóm hoặc quy trình làm việc của bạn -- Roomote Control: Roomote Control cho phép bạn điều khiển từ xa các tác vụ đang chạy trên VS Code cục bộ của bạn. Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-modes) • [Chế độ tùy chỉnh](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) @@ -66,10 +65,10 @@ Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-

-| | | | -| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
Cài đặt Roo Code |
Định cấu hình Hồ sơ |
Lập chỉ mục cơ sở mã | -|
Chế độ tùy chỉnh |
Điểm kiểm tra |
Quản lý Ngữ cảnh | +| | | | +| :--------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
Cài đặt Roo Code |
Định cấu hình Hồ sơ |
Lập chỉ mục cơ sở mã | +|
Chế độ tùy chỉnh |
Điểm kiểm tra |
Quản lý Ngữ cảnh |

From eb78c11f11647c74afe4ee81779a07fc611129f6 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Mon, 9 Feb 2026 09:57:42 +0000 Subject: [PATCH 26/31] Readmes --- README.md | 4 ++-- locales/ca/README.md | 4 ++-- locales/de/README.md | 4 ++-- locales/es/README.md | 4 ++-- locales/fr/README.md | 6 +++--- locales/hi/README.md | 4 ++-- locales/id/README.md | 4 ++-- locales/it/README.md | 4 ++-- locales/ja/README.md | 4 ++-- locales/ko/README.md | 4 ++-- locales/nl/README.md | 4 ++-- locales/pl/README.md | 4 ++-- locales/pt-BR/README.md | 4 ++-- locales/ru/README.md | 4 ++-- locales/tr/README.md | 4 ++-- locales/vi/README.md | 4 ++-- locales/zh-CN/README.md | 10 +++++----- locales/zh-TW/README.md | 4 ++-- 18 files changed, 40 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index e7621cf12f5..6f024db235e 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ - [简体中文](locales/zh-CN/README.md) - [繁體中文](locales/zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code adapts to how you work: - Debug Mode: trace issues, add logs, isolate root causes - Custom Modes: build specialized modes for your team or workflow -Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Learn more: [Using Modes](https://docs.roocode.com/basic-usage/using-modes) • [Custom Modes](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorial & Feature Videos diff --git a/locales/ca/README.md b/locales/ca/README.md index 5e5ac76afa9..be4aae4cc0b 100644 --- a/locales/ca/README.md +++ b/locales/ca/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code s'adapta a la teva manera de treballar, no a l'inrevés: - Mode Depuració: rastrejar problemes, afegir registres, aïllar les causes arrel - Modes personalitzats: crea modes especialitzats per al teu equip o flux de treball -Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personalitzats](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Més informació: [Ús de Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personalitzats](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorials i vídeos de funcionalitats diff --git a/locales/de/README.md b/locales/de/README.md index d3874c63c55..634996646d2 100644 --- a/locales/de/README.md +++ b/locales/de/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code passt sich an deine Arbeitsweise an, nicht umgekehrt: - Debug-Modus: Probleme aufspüren, Protokolle hinzufügen, Ursachen isolieren - Benutzerdefinierte Modi: erstelle spezialisierte Modi für dein Team oder deinen Workflow -Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes) • [Benutzerdefinierte Modi](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Mehr erfahren: [Modi verwenden](https://docs.roocode.com/basic-usage/using-modes) • [Benutzerdefinierte Modi](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorial- & Feature-Videos diff --git a/locales/es/README.md b/locales/es/README.md index f66c61bb268..67641511493 100644 --- a/locales/es/README.md +++ b/locales/es/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code se adapta a tu forma de trabajar, no al revés: - Modo Depuración: rastrear problemas, agregar registros, aislar causas raíz - Modos Personalizados: crea modos especializados para tu equipo o flujo de trabajo -Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos Personalizados](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Más info: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos Personalizados](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutoriales y vídeos de funcionalidades diff --git a/locales/fr/README.md b/locales/fr/README.md index 5197e76f0e9..b93bcb34b7d 100644 --- a/locales/fr/README.md +++ b/locales/fr/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -60,7 +60,7 @@ Roo Code s'adapte à votre façon de travailler, pas l'inverse : - Modes Personnalisés : créez des modes spécialisés pour votre équipe ou votre flux de travail - Roomote Control : Roomote Control te permet de piloter à distance les tâches exécutées dans ton instance VS Code locale. -En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personnalisés](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personnalisés](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutoriels & Vidéos de fonctionnalités @@ -69,7 +69,7 @@ En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using | | | | | :-----------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------: | |
Installer Roo Code |
Configurer les profils |
Indexation de la base de code | -|
Modes personnalisés |
Checkpoints |
Gestion du Contexte | +|
Modes personnalisés |
Checkpoints |
Gestion du Contexte |

diff --git a/locales/hi/README.md b/locales/hi/README.md index 08179c6831e..27cb624213c 100644 --- a/locales/hi/README.md +++ b/locales/hi/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ - डीबग मोड: समस्याओं का पता लगाएं, लॉग जोड़ें, मूल कारणों को अलग करें - कस्टम मोड: अपनी टीम या वर्कफ़्लो के लिए विशेष मोड बनाएं -और जानो: [मोड्स का इस्तेमाल](https://docs.roocode.com/basic-usage/using-modes) • [कस्टम मोड्स](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +और जानो: [मोड्स का इस्तेमाल](https://docs.roocode.com/basic-usage/using-modes) • [कस्टम मोड्स](https://docs.roocode.com/advanced-usage/custom-modes) ## ट्यूटोरियल और फ़ीचर वीडियो diff --git a/locales/id/README.md b/locales/id/README.md index 4a46a0d5d7f..0d208c18a39 100644 --- a/locales/id/README.md +++ b/locales/id/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code beradaptasi dengan cara Anda bekerja, bukan sebaliknya: - Mode Debug: melacak masalah, menambahkan log, mengisolasi akar penyebab - Mode Kustom: buat mode khusus untuk tim atau alur kerja Anda -Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/using-modes) • [Mode Kustom](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Pelajari lebih lanjut: [Menggunakan Mode](https://docs.roocode.com/basic-usage/using-modes) • [Mode Kustom](https://docs.roocode.com/advanced-usage/custom-modes) ## Video Tutorial & Fitur diff --git a/locales/it/README.md b/locales/it/README.md index 9d4bf887c88..69a7393967e 100644 --- a/locales/it/README.md +++ b/locales/it/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code si adatta al tuo modo di lavorare, non il contrario: - Modalità Debug: traccia problemi, aggiungi log, isola le cause principali - Modalità Personalizzate: crea modalità specializzate per il tuo team o flusso di lavoro -Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using-modes) • [Modalità personalizzate](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Scopri di più: [Usare le Modalità](https://docs.roocode.com/basic-usage/using-modes) • [Modalità personalizzate](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorial e video sulle funzionalità diff --git a/locales/ja/README.md b/locales/ja/README.md index bca736fbce0..dd5332b9619 100644 --- a/locales/ja/README.md +++ b/locales/ja/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Codeは、あなたの働き方に合わせるように適応します。 - デバッグモード:問題の追跡、ログの追加、根本原因の特定 - カスタムモード:チームやワークフローに特化したモードの構築 -詳しくは: [モードの使い方](https://docs.roocode.com/basic-usage/using-modes) • [カスタムモード](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +詳しくは: [モードの使い方](https://docs.roocode.com/basic-usage/using-modes) • [カスタムモード](https://docs.roocode.com/advanced-usage/custom-modes) ## チュートリアルと機能のビデオ diff --git a/locales/ko/README.md b/locales/ko/README.md index 98491cd0bd8..1070244b783 100644 --- a/locales/ko/README.md +++ b/locales/ko/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code는 당신의 작업 방식에 맞춰 적응합니다. - 디버그 모드: 문제 추적, 로그 추가, 근본 원인 격리 - 사용자 지정 모드: 팀이나 워크플로우를 위한 특수 모드 구축 -자세히: [모드 사용](https://docs.roocode.com/basic-usage/using-modes) • [사용자 지정 모드](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +자세히: [모드 사용](https://docs.roocode.com/basic-usage/using-modes) • [사용자 지정 모드](https://docs.roocode.com/advanced-usage/custom-modes) ## 튜토리얼 및 기능 비디오 diff --git a/locales/nl/README.md b/locales/nl/README.md index 0dac08b525f..52e7138f8c7 100644 --- a/locales/nl/README.md +++ b/locales/nl/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code past zich aan jouw werkwijze aan, niet andersom: - Debug Modus: spoor problemen op, voeg logs toe, isoleer de oorzaak - Aangepaste Modi: bouw gespecialiseerde modi voor je team of workflow -Meer info: [Modi gebruiken](https://docs.roocode.com/basic-usage/using-modes) • [Aangepaste modi](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Meer info: [Modi gebruiken](https://docs.roocode.com/basic-usage/using-modes) • [Aangepaste modi](https://docs.roocode.com/advanced-usage/custom-modes) ## Tutorial & Feature Videos diff --git a/locales/pl/README.md b/locales/pl/README.md index 99bbe92732f..8f79597598f 100644 --- a/locales/pl/README.md +++ b/locales/pl/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code dostosowuje się do Twojego sposobu pracy, a nie odwrotnie: - Tryb Debugowanie: śledzenie problemów, dodawanie logów, izolowanie przyczyn źródłowych - Tryby niestandardowe: buduj specjalistyczne tryby dla swojego zespołu lub przepływu pracy -Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-modes) • [Tryby niestandardowe](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Więcej: [Korzystanie z trybów](https://docs.roocode.com/basic-usage/using-modes) • [Tryby niestandardowe](https://docs.roocode.com/advanced-usage/custom-modes) ## Filmy instruktażowe i prezentujące funkcje diff --git a/locales/pt-BR/README.md b/locales/pt-BR/README.md index 20e8a589201..50269e00ed8 100644 --- a/locales/pt-BR/README.md +++ b/locales/pt-BR/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ O Roo Code se adapta à sua maneira de trabalhar, e não o contrário: - Modo Depuração: rastreie problemas, adicione logs, isole as causas raiz - Modos Personalizados: crie modos especializados para sua equipe ou fluxo de trabalho -Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos personalizados](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Saiba mais: [Usar Modos](https://docs.roocode.com/basic-usage/using-modes) • [Modos personalizados](https://docs.roocode.com/advanced-usage/custom-modes) ## Vídeos de tutorial e recursos diff --git a/locales/ru/README.md b/locales/ru/README.md index 6c7de2568f3..a323ff417c2 100644 --- a/locales/ru/README.md +++ b/locales/ru/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code адаптируется к вашему стилю работы, а н - Режим Отладка: отслеживание проблем, добавление логов, изоляция первопричин - Пользовательские режимы: создавайте специализированные режимы для вашей команды или рабочего процесса -Подробнее: [Использование режимов](https://docs.roocode.com/basic-usage/using-modes) • [Пользовательские режимы](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Подробнее: [Использование режимов](https://docs.roocode.com/basic-usage/using-modes) • [Пользовательские режимы](https://docs.roocode.com/advanced-usage/custom-modes) ## Обучающие видео и видео о функциях diff --git a/locales/tr/README.md b/locales/tr/README.md index 1fc0708c0dd..2847a23c068 100644 --- a/locales/tr/README.md +++ b/locales/tr/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code, sizin çalışma şeklinize uyum sağlar, tam tersi değil: - Hata Ayıklama Modu: sorunları izleyin, günlükler ekleyin, kök nedenleri izole edin - Özel Modlar: ekibiniz veya iş akışınız için özel modlar oluşturun -Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes) • [Özel modlar](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Daha fazla: [Modları kullanma](https://docs.roocode.com/basic-usage/using-modes) • [Özel modlar](https://docs.roocode.com/advanced-usage/custom-modes) ## Eğitim ve Özellik Videoları diff --git a/locales/vi/README.md b/locales/vi/README.md index ded54a4f42c..aa9b4ff6746 100644 --- a/locales/vi/README.md +++ b/locales/vi/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -59,7 +59,7 @@ Roo Code thích ứng với cách bạn làm việc, chứ không phải ngượ - Chế độ Gỡ lỗi: theo dõi sự cố, thêm nhật ký, cô lập nguyên nhân gốc rễ - Chế độ Tùy chỉnh: xây dựng các chế độ chuyên biệt cho nhóm hoặc quy trình làm việc của bạn -Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-modes) • [Chế độ tùy chỉnh](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +Xem thêm: [Sử dụng Chế độ](https://docs.roocode.com/basic-usage/using-modes) • [Chế độ tùy chỉnh](https://docs.roocode.com/advanced-usage/custom-modes) ## Video hướng dẫn & tính năng diff --git a/locales/zh-CN/README.md b/locales/zh-CN/README.md index a21e147f963..818df49caef 100644 --- a/locales/zh-CN/README.md +++ b/locales/zh-CN/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -60,15 +60,15 @@ Roo Code 适应您的工作方式,而不是相反: - 自定义模式:为您的团队或工作流程构建专门的模式 - Roomote Control:Roomote Control 允许你远程控制在本地 VS Code 实例中运行的任务。 -了解更多:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自定义模式](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +了解更多:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自定义模式](https://docs.roocode.com/advanced-usage/custom-modes) ## 教程和功能视频

-| | | | -| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | -|
安装 Roo Code |
配置个人资料 |
代码库索引 | +| | | | +| :-----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :----------------------------------------------------------------------------------------------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------------------------------------------------------------------: | +|
安装 Roo Code |
配置个人资料 |
代码库索引 | |
自定义模式 |
检查点 |
上下文管理 |
diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md index fe985d7ec89..58f55b2b131 100644 --- a/locales/zh-TW/README.md +++ b/locales/zh-TW/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -60,7 +60,7 @@ Roo Code 會配合您的工作方式,而非要您配合它: - 自訂模式:為您的團隊或工作流程建置專門的模式 - Roomote Control:Roomote Control 讓您能遠端控制在本機 VS Code 執行個體中運行的工作。 -更多資訊:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自訂模式](https://docs.roocode.com/advanced-usage/custom-modes) • [Roomote Control](https://docs.roocode.com/roo-code-cloud/roomote-control) +更多資訊:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自訂模式](https://docs.roocode.com/advanced-usage/custom-modes) ## 教學和功能影片 From c6cdf67acde885514560b20c61402a4f70842058 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Mon, 9 Feb 2026 10:04:29 +0000 Subject: [PATCH 27/31] Types --- packages/types/src/cloud.ts | 200 ------------------------------------ 1 file changed, 200 deletions(-) diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index b0119729daa..c991cdb1e6e 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -424,206 +424,6 @@ export const extensionInstanceSchema = z.object({ export type ExtensionInstance = z.infer -/** - * ExtensionBridgeEvent - */ - -export enum ExtensionBridgeEventName { - TaskCreated = RooCodeEventName.TaskCreated, - TaskStarted = RooCodeEventName.TaskStarted, - TaskCompleted = RooCodeEventName.TaskCompleted, - TaskAborted = RooCodeEventName.TaskAborted, - TaskFocused = RooCodeEventName.TaskFocused, - TaskUnfocused = RooCodeEventName.TaskUnfocused, - TaskActive = RooCodeEventName.TaskActive, - TaskInteractive = RooCodeEventName.TaskInteractive, - TaskResumable = RooCodeEventName.TaskResumable, - TaskIdle = RooCodeEventName.TaskIdle, - - TaskPaused = RooCodeEventName.TaskPaused, - TaskUnpaused = RooCodeEventName.TaskUnpaused, - TaskSpawned = RooCodeEventName.TaskSpawned, - TaskDelegated = RooCodeEventName.TaskDelegated, - TaskDelegationCompleted = RooCodeEventName.TaskDelegationCompleted, - TaskDelegationResumed = RooCodeEventName.TaskDelegationResumed, - - TaskUserMessage = RooCodeEventName.TaskUserMessage, - - TaskTokenUsageUpdated = RooCodeEventName.TaskTokenUsageUpdated, - - ModeChanged = RooCodeEventName.ModeChanged, - ProviderProfileChanged = RooCodeEventName.ProviderProfileChanged, - - InstanceRegistered = "instance_registered", - InstanceUnregistered = "instance_unregistered", - HeartbeatUpdated = "heartbeat_updated", -} - -export const extensionBridgeEventSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskCreated), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskStarted), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskCompleted), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskAborted), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskFocused), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskUnfocused), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskActive), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskInteractive), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskResumable), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskIdle), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskPaused), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskUnpaused), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskSpawned), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskDelegated), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskDelegationCompleted), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskDelegationResumed), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskUserMessage), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.TaskTokenUsageUpdated), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.ModeChanged), - instance: extensionInstanceSchema, - mode: z.string(), - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.ProviderProfileChanged), - instance: extensionInstanceSchema, - providerProfile: z.object({ name: z.string(), provider: z.string().optional() }), - timestamp: z.number(), - }), - - z.object({ - type: z.literal(ExtensionBridgeEventName.InstanceRegistered), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.InstanceUnregistered), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeEventName.HeartbeatUpdated), - instance: extensionInstanceSchema, - timestamp: z.number(), - }), -]) - -export type ExtensionBridgeEvent = z.infer - -/** - * ExtensionBridgeCommand - */ - -export enum ExtensionBridgeCommandName { - StartTask = "start_task", - StopTask = "stop_task", - ResumeTask = "resume_task", -} - -export const extensionBridgeCommandSchema = z.discriminatedUnion("type", [ - z.object({ - type: z.literal(ExtensionBridgeCommandName.StartTask), - instanceId: z.string(), - payload: z.object({ - text: z.string(), - images: z.array(z.string()).optional(), - mode: z.string().optional(), - providerProfile: z.string().optional(), - }), - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeCommandName.StopTask), - instanceId: z.string(), - payload: z.object({ taskId: z.string() }), - timestamp: z.number(), - }), - z.object({ - type: z.literal(ExtensionBridgeCommandName.ResumeTask), - instanceId: z.string(), - payload: z.object({ taskId: z.string() }), - timestamp: z.number(), - }), -]) - -export type ExtensionBridgeCommand = z.infer - /** * TaskBridgeEvent */ From 6905e33b56a4b24844c01aad197e7397033652a1 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 9 Feb 2026 10:21:55 +0000 Subject: [PATCH 28/31] fix: remove leftover Roomote Control references from locale READMEs and stale BridgeOrchestrator mock --- locales/fr/README.md | 3 +-- locales/zh-CN/README.md | 3 +-- locales/zh-TW/README.md | 3 +-- src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts | 3 --- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/locales/fr/README.md b/locales/fr/README.md index b93bcb34b7d..36e693f0414 100644 --- a/locales/fr/README.md +++ b/locales/fr/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code s'adapte à votre façon de travailler, pas l'inverse : - Mode Demande : réponses rapides, explications et documents - Mode Débogage : tracer les problèmes, ajouter des journaux, isoler les causes profondes - Modes Personnalisés : créez des modes spécialisés pour votre équipe ou votre flux de travail -- Roomote Control : Roomote Control te permet de piloter à distance les tâches exécutées dans ton instance VS Code locale. En savoir plus : [Utiliser les Modes](https://docs.roocode.com/basic-usage/using-modes) • [Modes personnalisés](https://docs.roocode.com/advanced-usage/custom-modes) diff --git a/locales/zh-CN/README.md b/locales/zh-CN/README.md index 818df49caef..ef26871f316 100644 --- a/locales/zh-CN/README.md +++ b/locales/zh-CN/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code 适应您的工作方式,而不是相反: - 提问模式:快速回答、解释和文档 - 调试模式:跟踪问题、添加日志、隔离根本原因 - 自定义模式:为您的团队或工作流程构建专门的模式 -- Roomote Control:Roomote Control 允许你远程控制在本地 VS Code 实例中运行的任务。 了解更多:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自定义模式](https://docs.roocode.com/advanced-usage/custom-modes) diff --git a/locales/zh-TW/README.md b/locales/zh-TW/README.md index 58f55b2b131..db94c425c9d 100644 --- a/locales/zh-TW/README.md +++ b/locales/zh-TW/README.md @@ -35,7 +35,7 @@ - [简体中文](../zh-CN/README.md) - [繁體中文](../zh-TW/README.md) - ... - + --- @@ -58,7 +58,6 @@ Roo Code 會配合您的工作方式,而非要您配合它: - 詢問模式:快速回答、解釋和文件 - 偵錯模式:追蹤問題、新增日誌、鎖定根本原因 - 自訂模式:為您的團隊或工作流程建置專門的模式 -- Roomote Control:Roomote Control 讓您能遠端控制在本機 VS Code 執行個體中運行的工作。 更多資訊:[使用模式](https://docs.roocode.com/basic-usage/using-modes) • [自訂模式](https://docs.roocode.com/advanced-usage/custom-modes) diff --git a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts index 9b5e3b16ee6..40cf9b29812 100644 --- a/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.lockApiConfig.spec.ts @@ -110,9 +110,6 @@ vi.mock("@roo-code/cloud", () => ({ } }, }, - BridgeOrchestrator: { - isEnabled: vi.fn().mockReturnValue(false), - }, getRooCodeApiUrl: vi.fn().mockReturnValue("https://app.roocode.com"), })) From e20cdd680747c1129b4038afc04cc31a037e1930 Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Mon, 9 Feb 2026 10:49:33 +0000 Subject: [PATCH 29/31] Removes cloudtaskbutton --- .../src/components/chat/CloudTaskButton.tsx | 126 ---------- .../src/components/chat/TaskActions.tsx | 2 - .../chat/__tests__/CloudTaskButton.spec.tsx | 232 ------------------ 3 files changed, 360 deletions(-) delete mode 100644 webview-ui/src/components/chat/CloudTaskButton.tsx delete mode 100644 webview-ui/src/components/chat/__tests__/CloudTaskButton.spec.tsx diff --git a/webview-ui/src/components/chat/CloudTaskButton.tsx b/webview-ui/src/components/chat/CloudTaskButton.tsx deleted file mode 100644 index effe57c6d74..00000000000 --- a/webview-ui/src/components/chat/CloudTaskButton.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { useState, useEffect, useCallback } from "react" -import { useTranslation } from "react-i18next" -import { Copy, Check, CloudUploadIcon } from "lucide-react" -import QRCode from "qrcode" - -import type { HistoryItem } from "@roo-code/types" - -import { useExtensionState } from "@/context/ExtensionStateContext" -import { useCopyToClipboard } from "@/utils/clipboard" -import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input } from "@/components/ui" -import { vscode } from "@/utils/vscode" -import { LucideIconButton } from "./LucideIconButton" - -interface CloudTaskButtonProps { - item?: HistoryItem - disabled?: boolean -} - -export const CloudTaskButton = ({ item, disabled = false }: CloudTaskButtonProps) => { - const [dialogOpen, setDialogOpen] = useState(false) - const { t } = useTranslation() - const { cloudUserInfo, cloudApiUrl } = useExtensionState() - const { copyWithFeedback, showCopyFeedback } = useCopyToClipboard() - const [canvasElement, setCanvasElement] = useState(null) - - // Generate the cloud URL for the task - const cloudTaskUrl = item?.id ? `${cloudApiUrl}/task/${item.id}` : "" - - const generateQRCode = useCallback( - (canvas: HTMLCanvasElement, context: string) => { - if (!cloudTaskUrl) { - // This will run again later when ready - return - } - - QRCode.toCanvas( - canvas, - cloudTaskUrl, - { - width: 140, - margin: 0, - color: { - dark: "#000000", - light: "#FFFFFF", - }, - }, - (error: Error | null | undefined) => { - if (error) { - console.error(`Error generating QR code (${context}):`, error) - } - }, - ) - }, - [cloudTaskUrl], - ) - - // Callback ref to capture canvas element when it mounts - const canvasRef = useCallback( - (node: HTMLCanvasElement | null) => { - if (node) { - setCanvasElement(node) - - // Try to generate QR code immediately when canvas is available - if (dialogOpen) { - generateQRCode(node, "on mount") - } - } else { - setCanvasElement(null) - } - }, - [dialogOpen, generateQRCode], - ) - - // Also generate QR code when dialog opens after canvas is available - useEffect(() => { - if (dialogOpen && canvasElement) { - generateQRCode(canvasElement, "in useEffect") - } - }, [dialogOpen, canvasElement, generateQRCode]) - - if (!cloudUserInfo || !item?.id) { - return null - } - - return ( - <> - setDialogOpen(true)}> - - - - - {t("chat:task.openInCloud")} - - -
-

{t("chat:task.openInCloudIntro")}

-
-
vscode.postMessage({ type: "openExternal", url: cloudTaskUrl })} - title={t("chat:task.openInCloud")}> - -
-
- -
- - -
-
-
-
- - ) -} diff --git a/webview-ui/src/components/chat/TaskActions.tsx b/webview-ui/src/components/chat/TaskActions.tsx index 74575ddc28f..c7401425f63 100644 --- a/webview-ui/src/components/chat/TaskActions.tsx +++ b/webview-ui/src/components/chat/TaskActions.tsx @@ -9,7 +9,6 @@ import { useExtensionState } from "@/context/ExtensionStateContext" import { DeleteTaskDialog } from "../history/DeleteTaskDialog" import { ShareButton } from "./ShareButton" -import { CloudTaskButton } from "./CloudTaskButton" import { CopyIcon, DownloadIcon, Trash2Icon, FileJsonIcon, MessageSquareCodeIcon } from "lucide-react" import { LucideIconButton } from "./LucideIconButton" @@ -64,7 +63,6 @@ export const TaskActions = ({ item, buttonsDisabled }: TaskActionsProps) => { )} - {debug && item?.id && ( <> ({ - default: { - toCanvas: vi.fn((_canvas, _text, _options, callback) => { - // Simulate successful QR code generation - if (callback) { - callback(null) - } - }), - }, -})) - -// Mock react-i18next -vi.mock("react-i18next") - -// Mock the cloud config -vi.mock("@roo-code/cloud/src/config", () => ({ - getRooCodeApiUrl: vi.fn(() => "https://app.roocode.com"), -})) - -// Mock the extension state context -vi.mock("@/context/ExtensionStateContext", () => ({ - ExtensionStateContextProvider: ({ children }: { children: React.ReactNode }) => children, - useExtensionState: vi.fn(), -})) - -// Mock clipboard utility -vi.mock("@/utils/clipboard", () => ({ - useCopyToClipboard: () => ({ - copyWithFeedback: vi.fn(), - showCopyFeedback: false, - }), -})) - -const mockUseTranslation = vi.mocked(useTranslation) -const { useExtensionState } = await import("@/context/ExtensionStateContext") -const mockUseExtensionState = vi.mocked(useExtensionState) - -describe("CloudTaskButton", () => { - const mockT = vi.fn((key: string) => key) - const mockItem = { - id: "test-task-id", - number: 1, - ts: Date.now(), - task: "Test Task", - tokensIn: 100, - tokensOut: 50, - totalCost: 0.01, - } - - beforeEach(() => { - vi.clearAllMocks() - - mockUseTranslation.mockReturnValue({ - t: mockT, - i18n: {} as any, - ready: true, - } as any) - - // Default extension state with bridge enabled - mockUseExtensionState.mockReturnValue({ - cloudUserInfo: { - id: "test-user", - email: "test@example.com", - }, - cloudApiUrl: "https://app.roocode.com", - } as any) - }) - - test("renders cloud task button when extension bridge is enabled", () => { - render() - - const button = screen.getByTestId("cloud-task-button") - expect(button).toBeInTheDocument() - expect(button).toHaveAttribute("aria-label", "chat:task.openInCloud") - }) - - test("does not render when extension bridge is disabled", () => { - mockUseExtensionState.mockReturnValue({ - cloudUserInfo: { - id: "test-user", - email: "test@example.com", - }, - cloudApiUrl: "https://app.roocode.com", - } as any) - - render() - - expect(screen.queryByTestId("cloud-task-button")).not.toBeInTheDocument() - }) - - test("does not render when cloudUserInfo is null", () => { - mockUseExtensionState.mockReturnValue({ - cloudUserInfo: null, - cloudApiUrl: "https://app.roocode.com", - } as any) - - render() - - expect(screen.queryByTestId("cloud-task-button")).not.toBeInTheDocument() - }) - - test("does not render when item has no id", () => { - const itemWithoutId = { ...mockItem, id: undefined } - render() - - expect(screen.queryByTestId("cloud-task-button")).not.toBeInTheDocument() - }) - - test("opens dialog when button is clicked", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByText("chat:task.openInCloud")).toBeInTheDocument() - }) - }) - - test("displays correct cloud URL in dialog", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - const input = screen.getByDisplayValue("https://app.roocode.com/task/test-task-id") - expect(input).toBeInTheDocument() - expect(input).toBeDisabled() - }) - }) - - test("displays intro text in dialog", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByText("chat:task.openInCloudIntro")).toBeInTheDocument() - }) - }) - - // Note: QR code generation is tested implicitly through the canvas rendering test below - - test("QR code canvas is rendered", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - // Canvas element doesn't have a specific aria label, find it directly - const canvas = document.querySelector("canvas") - expect(canvas).toBeInTheDocument() - expect(canvas?.tagName).toBe("CANVAS") - }) - }) - - // Note: Error handling for QR code generation is non-critical as per PR feedback - - test("button is disabled when disabled prop is true", () => { - render() - - const button = screen.getByTestId("cloud-task-button") - expect(button).toBeDisabled() - }) - - test("button is enabled when disabled prop is false", () => { - render() - - const button = screen.getByTestId("cloud-task-button") - expect(button).not.toBeDisabled() - }) - - test("dialog can be closed", async () => { - render() - - // Open dialog - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - expect(screen.getByText("chat:task.openInCloud")).toBeInTheDocument() - }) - - // Close dialog by clicking the X button (assuming it exists in Dialog component) - const closeButton = screen.getByRole("button", { name: /close/i }) - fireEvent.click(closeButton) - - await waitFor(() => { - expect(screen.queryByText("chat:task.openInCloud")).not.toBeInTheDocument() - }) - }) - - test("copy button exists in dialog", async () => { - render() - - const button = screen.getByTestId("cloud-task-button") - fireEvent.click(button) - - await waitFor(() => { - // Look for the copy button (it should have a Copy icon) - const copyButtons = screen.getAllByRole("button") - const copyButton = copyButtons.find( - (btn) => btn.querySelector('[class*="lucide"]') || btn.textContent?.includes("Copy"), - ) - expect(copyButton).toBeInTheDocument() - }) - }) - - test("uses correct URL from getRooCodeApiUrl", async () => { - // Mock getRooCodeApiUrl to return a custom URL - vi.doMock("@roo-code/cloud/src/config", () => ({ - getRooCodeApiUrl: vi.fn(() => "https://custom.roocode.com"), - })) - - // Clear module cache and re-import to get the mocked version - vi.resetModules() - - // Since we can't easily test the dynamic import, let's skip this specific test - // The functionality is already covered by the main component using getRooCodeApiUrl - expect(true).toBe(true) - }) -}) From 08f07839a79ee799350e825a3c63eb24a55656ce Mon Sep 17 00:00:00 2001 From: Roo Code Date: Mon, 9 Feb 2026 11:01:47 +0000 Subject: [PATCH 30/31] fix: remove orphaned qrcode packages and dead openInCloud translation keys --- webview-ui/package.json | 2 -- webview-ui/src/i18n/locales/ca/chat.json | 2 -- webview-ui/src/i18n/locales/de/chat.json | 2 -- webview-ui/src/i18n/locales/en/chat.json | 2 -- webview-ui/src/i18n/locales/es/chat.json | 2 -- webview-ui/src/i18n/locales/fr/chat.json | 2 -- webview-ui/src/i18n/locales/hi/chat.json | 2 -- webview-ui/src/i18n/locales/id/chat.json | 2 -- webview-ui/src/i18n/locales/it/chat.json | 2 -- webview-ui/src/i18n/locales/ja/chat.json | 2 -- webview-ui/src/i18n/locales/ko/chat.json | 2 -- webview-ui/src/i18n/locales/nl/chat.json | 2 -- webview-ui/src/i18n/locales/pl/chat.json | 2 -- webview-ui/src/i18n/locales/pt-BR/chat.json | 2 -- webview-ui/src/i18n/locales/ru/chat.json | 2 -- webview-ui/src/i18n/locales/tr/chat.json | 2 -- webview-ui/src/i18n/locales/vi/chat.json | 2 -- webview-ui/src/i18n/locales/zh-CN/chat.json | 2 -- webview-ui/src/i18n/locales/zh-TW/chat.json | 2 -- 19 files changed, 38 deletions(-) diff --git a/webview-ui/package.json b/webview-ui/package.json index d72c6a1a2c6..7722f4119f0 100644 --- a/webview-ui/package.json +++ b/webview-ui/package.json @@ -33,7 +33,6 @@ "@roo-code/types": "workspace:^", "@tailwindcss/vite": "^4.0.0", "@tanstack/react-query": "^5.68.0", - "@types/qrcode": "^1.5.5", "@vscode/codicons": "^0.0.36", "@vscode/webview-ui-toolkit": "^1.4.0", "axios": "^1.12.0", @@ -55,7 +54,6 @@ "mermaid": "^11.4.1", "posthog-js": "^1.227.2", "pretty-bytes": "^7.0.0", - "qrcode": "^1.5.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-compiler-runtime": "^1.0.0", diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 070adc265db..b22714edc22 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Compartició deshabilitada per l'organització", "shareSuccessOrganization": "Enllaç d'organització copiat al porta-retalls", "shareSuccessPublic": "Enllaç públic copiat al porta-retalls", - "openInCloud": "Obrir tasca a Roo Code Cloud", - "openInCloudIntro": "Continua monitoritzant o interactuant amb Roo des de qualsevol lloc. Escaneja, fes clic o copia per obrir.", "openApiHistory": "Obrir historial d'API", "openUiHistory": "Obrir historial d'UI", "backToParentTask": "Tasca principal" diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index bc550520d7a..414b1a43cac 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Freigabe von der Organisation deaktiviert", "shareSuccessOrganization": "Organisationslink in die Zwischenablage kopiert", "shareSuccessPublic": "Öffentlicher Link in die Zwischenablage kopiert", - "openInCloud": "Aufgabe in Roo Code Cloud öffnen", - "openInCloudIntro": "Überwache oder interagiere mit Roo von überall aus. Scanne, klicke oder kopiere zum Öffnen.", "openApiHistory": "API-Verlauf öffnen", "openUiHistory": "UI-Verlauf öffnen", "backToParentTask": "Übergeordnete Aufgabe" diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index bd97e041f20..37d94641ba4 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Sharing disabled by organization", "shareSuccessOrganization": "Organization link copied to clipboard", "shareSuccessPublic": "Public link copied to clipboard", - "openInCloud": "Open task in Roo Code Cloud", - "openInCloudIntro": "Keep monitoring or interacting with Roo from anywhere. Scan, click or copy to open.", "openApiHistory": "Open API History", "openUiHistory": "Open UI History", "backToParentTask": "Parent task" diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 0687bc7037b..e72f43909e4 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Compartir deshabilitado por la organización", "shareSuccessOrganization": "Enlace de organización copiado al portapapeles", "shareSuccessPublic": "Enlace público copiado al portapapeles", - "openInCloud": "Abrir tarea en Roo Code Cloud", - "openInCloudIntro": "Continúa monitoreando o interactuando con Roo desde cualquier lugar. Escanea, haz clic o copia para abrir.", "openApiHistory": "Abrir historial de API", "openUiHistory": "Abrir historial de UI", "backToParentTask": "Tarea principal" diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index f4887b7e6c7..f9d782d7ac8 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Partage désactivé par l'organisation", "shareSuccessOrganization": "Lien d'organisation copié dans le presse-papiers", "shareSuccessPublic": "Lien public copié dans le presse-papiers", - "openInCloud": "Ouvrir la tâche dans Roo Code Cloud", - "openInCloudIntro": "Continue à surveiller ou interagir avec Roo depuis n'importe où. Scanne, clique ou copie pour ouvrir.", "openApiHistory": "Ouvrir l'historique de l'API", "openUiHistory": "Ouvrir l'historique de l'UI", "backToParentTask": "Tâche parente" diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 580822b82f5..7874ea39e11 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "संगठन द्वारा साझाकरण अक्षम किया गया", "shareSuccessOrganization": "संगठन लिंक क्लिपबोर्ड में कॉपी किया गया", "shareSuccessPublic": "सार्वजनिक लिंक क्लिपबोर्ड में कॉपी किया गया", - "openInCloud": "Roo Code Cloud में कार्य खोलें", - "openInCloudIntro": "कहीं से भी Roo की निगरानी या इंटरैक्ट करना जारी रखें। खोलने के लिए स्कैन करें, क्लिक करें या कॉपी करें।", "openApiHistory": "API इतिहास खोलें", "openUiHistory": "UI इतिहास खोलें", "backToParentTask": "मूल कार्य" diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 600708e4d9c..121366a432f 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Berbagi dinonaktifkan oleh organisasi", "shareSuccessOrganization": "Tautan organisasi disalin ke clipboard", "shareSuccessPublic": "Tautan publik disalin ke clipboard", - "openInCloud": "Buka tugas di Roo Code Cloud", - "openInCloudIntro": "Terus pantau atau berinteraksi dengan Roo dari mana saja. Pindai, klik atau salin untuk membuka.", "openApiHistory": "Buka Riwayat API", "openUiHistory": "Buka Riwayat UI", "backToParentTask": "Tugas Induk" diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index ce8e45bf459..8d626f66088 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Condivisione disabilitata dall'organizzazione", "shareSuccessOrganization": "Link organizzazione copiato negli appunti", "shareSuccessPublic": "Link pubblico copiato negli appunti", - "openInCloud": "Apri attività in Roo Code Cloud", - "openInCloudIntro": "Continua a monitorare o interagire con Roo da qualsiasi luogo. Scansiona, clicca o copia per aprire.", "openApiHistory": "Apri cronologia API", "openUiHistory": "Apri cronologia UI", "backToParentTask": "Attività principale" diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index f3c3d86f7a1..7f69efb8968 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "組織により共有が無効化されています", "shareSuccessOrganization": "組織リンクをクリップボードにコピーしました", "shareSuccessPublic": "公開リンクをクリップボードにコピーしました", - "openInCloud": "Roo Code Cloudでタスクを開く", - "openInCloudIntro": "どこからでもRooの監視や操作を続けられます。スキャン、クリック、またはコピーして開いてください。", "openApiHistory": "API履歴を開く", "openUiHistory": "UI履歴を開く", "backToParentTask": "親タスク" diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 817768c2753..f39527f4e4e 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "조직에서 공유가 비활성화됨", "shareSuccessOrganization": "조직 링크가 클립보드에 복사되었습니다", "shareSuccessPublic": "공개 링크가 클립보드에 복사되었습니다", - "openInCloud": "Roo Code Cloud에서 작업 열기", - "openInCloudIntro": "어디서나 Roo를 계속 모니터링하거나 상호작용할 수 있습니다. 스캔, 클릭 또는 복사하여 열기.", "openApiHistory": "API 기록 열기", "openUiHistory": "UI 기록 열기", "backToParentTask": "상위 작업" diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 0a728aa38e8..45f54b9e6f8 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Delen uitgeschakeld door organisatie", "shareSuccessOrganization": "Organisatielink gekopieerd naar klembord", "shareSuccessPublic": "Openbare link gekopieerd naar klembord", - "openInCloud": "Taak openen in Roo Code Cloud", - "openInCloudIntro": "Blijf Roo vanaf elke locatie monitoren of ermee interacteren. Scan, klik of kopieer om te openen.", "openApiHistory": "API-geschiedenis openen", "openUiHistory": "UI-geschiedenis openen", "backToParentTask": "Bovenliggende taak" diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 09a1c994927..02625d496e0 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Udostępnianie wyłączone przez organizację", "shareSuccessOrganization": "Link organizacji skopiowany do schowka", "shareSuccessPublic": "Link publiczny skopiowany do schowka", - "openInCloud": "Otwórz zadanie w Roo Code Cloud", - "openInCloudIntro": "Kontynuuj monitorowanie lub interakcję z Roo z dowolnego miejsca. Zeskanuj, kliknij lub skopiuj, aby otworzyć.", "openApiHistory": "Otwórz historię API", "openUiHistory": "Otwórz historię UI", "backToParentTask": "Zadanie nadrzędne" diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index cc4fbbd742e..0a50ec8ad22 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Compartilhamento desabilitado pela organização", "shareSuccessOrganization": "Link da organização copiado para a área de transferência", "shareSuccessPublic": "Link público copiado para a área de transferência", - "openInCloud": "Abrir tarefa no Roo Code Cloud", - "openInCloudIntro": "Continue monitorando ou interagindo com Roo de qualquer lugar. Escaneie, clique ou copie para abrir.", "openApiHistory": "Abrir histórico da API", "openUiHistory": "Abrir histórico da UI", "backToParentTask": "Tarefa pai" diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index c5e4a3f0e2a..8c78165d007 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Обмен отключен организацией", "shareSuccessOrganization": "Ссылка организации скопирована в буфер обмена", "shareSuccessPublic": "Публичная ссылка скопирована в буфер обмена", - "openInCloud": "Открыть задачу в Roo Code Cloud", - "openInCloudIntro": "Продолжай отслеживать или взаимодействовать с Roo откуда угодно. Отсканируй, нажми или скопируй для открытия.", "openApiHistory": "Открыть историю API", "openUiHistory": "Открыть историю UI", "backToParentTask": "Родительская задача" diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index b913a5afb2b..cbe67b3fea3 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Paylaşım kuruluş tarafından devre dışı bırakıldı", "shareSuccessOrganization": "Organizasyon bağlantısı panoya kopyalandı", "shareSuccessPublic": "Genel bağlantı panoya kopyalandı", - "openInCloud": "Görevi Roo Code Cloud'da aç", - "openInCloudIntro": "Roo'yu her yerden izlemeye veya etkileşime devam et. Açmak için tara, tıkla veya kopyala.", "openApiHistory": "API Geçmişini Aç", "openUiHistory": "UI Geçmişini Aç", "backToParentTask": "Üst görev" diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 9aecd5c3857..577a55bac38 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "Chia sẻ bị tổ chức vô hiệu hóa", "shareSuccessOrganization": "Liên kết tổ chức đã được sao chép vào clipboard", "shareSuccessPublic": "Liên kết công khai đã được sao chép vào clipboard", - "openInCloud": "Mở tác vụ trong Roo Code Cloud", - "openInCloudIntro": "Tiếp tục theo dõi hoặc tương tác với Roo từ bất cứ đâu. Quét, nhấp hoặc sao chép để mở.", "openApiHistory": "Mở lịch sử API", "openUiHistory": "Mở lịch sử UI", "backToParentTask": "Nhiệm vụ cha" diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 853279506fc..eea14afe262 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "组织已禁用分享功能", "shareSuccessOrganization": "组织链接已复制到剪贴板", "shareSuccessPublic": "公开链接已复制到剪贴板", - "openInCloud": "在 Roo Code Cloud 中打开任务", - "openInCloudIntro": "从任何地方继续监控或与 Roo 交互。扫描、点击或复制以打开。", "openApiHistory": "打开 API 历史", "openUiHistory": "打开 UI 历史", "backToParentTask": "父任务" diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 84c54900b68..6218cd82163 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -25,8 +25,6 @@ "sharingDisabledByOrganization": "組織已停用分享功能", "shareSuccessOrganization": "組織連結已複製到剪貼簿", "shareSuccessPublic": "公開連結已複製到剪貼簿", - "openInCloud": "在 Roo Code Cloud 中開啟工作", - "openInCloudIntro": "從任何地方繼續監控或與 Roo 互動。掃描、點選或複製即可開啟。", "openApiHistory": "開啟 API 歷史紀錄", "openUiHistory": "開啟 UI 歷史紀錄", "backToParentTask": "上層工作" From f0e5ec7137458c133e72a1837eb55e36851b2adb Mon Sep 17 00:00:00 2001 From: Bruno Bergher Date: Mon, 9 Feb 2026 11:08:35 +0000 Subject: [PATCH 31/31] pnpmlock --- pnpm-lock.yaml | 113 ------------------------------------------------- 1 file changed, 113 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 92d313aeee9..fae725e2ee8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1197,9 +1197,6 @@ importers: '@tanstack/react-query': specifier: ^5.68.0 version: 5.76.1(react@18.3.1) - '@types/qrcode': - specifier: ^1.5.5 - version: 1.5.5 '@vscode/codicons': specifier: ^0.0.36 version: 0.0.36 @@ -1263,9 +1260,6 @@ importers: pretty-bytes: specifier: ^7.0.0 version: 7.0.0 - qrcode: - specifier: ^1.5.4 - version: 1.5.4 react: specifier: ^18.3.1 version: 18.3.1 @@ -4586,9 +4580,6 @@ packages: '@types/ps-tree@1.1.6': resolution: {integrity: sha512-PtrlVaOaI44/3pl3cvnlK+GxOM3re2526TJvPvh7W+keHIXdV4TE0ylpPBAcvFQCbGitaTXwL9u+RF7qtVeazQ==} - '@types/qrcode@1.5.5': - resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} - '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -5252,10 +5243,6 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - camelcase@5.3.1: - resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} - engines: {node: '>=6'} - camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -5402,9 +5389,6 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} - cliui@6.0.0: - resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} - cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -5865,10 +5849,6 @@ packages: supports-color: optional: true - decamelize@1.2.0: - resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} - engines: {node: '>=0.10.0'} - decamelize@4.0.0: resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} engines: {node: '>=10'} @@ -5997,9 +5977,6 @@ packages: resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} engines: {node: '>=0.3.1'} - dijkstrajs@1.0.3: - resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} - dingbat-to-unicode@1.0.1: resolution: {integrity: sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==} @@ -8846,10 +8823,6 @@ packages: pkg-types@2.2.0: resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==} - pngjs@5.0.0: - resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} - engines: {node: '>=10.13.0'} - points-on-curve@0.2.0: resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} @@ -9073,11 +9046,6 @@ packages: resolution: {integrity: sha512-CnzhOgrZj8DvkDqI+Yx+9or33i3Y9uUYbKyYpP4C13jWwXx/keQ38RMTMmxuLCWQlxjZrOH0Foq7P2fGP7adDQ==} engines: {node: '>=18'} - qrcode@1.5.4: - resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} - engines: {node: '>=10.13.0'} - hasBin: true - qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -9381,9 +9349,6 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} - require-main-filename@2.0.0: - resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} - resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -9582,9 +9547,6 @@ packages: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} - set-blocking@2.0.0: - resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} - set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -10774,9 +10736,6 @@ packages: resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} engines: {node: '>= 0.4'} - which-module@2.0.1: - resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} - which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -10826,10 +10785,6 @@ packages: workerpool@9.2.0: resolution: {integrity: sha512-PKZqBOCo6CYkVOwAxWxQaSF2Fvb5Iv2fCeTP7buyWI2GiynWr46NcXSgK/idoV6e60dgCBfgYc+Un3HMvmqP8w==} - wrap-ansi@6.2.0: - resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} - engines: {node: '>=8'} - wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -10908,9 +10863,6 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} - y18n@4.0.3: - resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} - y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -10930,10 +10882,6 @@ packages: engines: {node: '>= 14.6'} hasBin: true - yargs-parser@18.1.3: - resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} - engines: {node: '>=6'} - yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -10946,10 +10894,6 @@ packages: resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} engines: {node: '>=10'} - yargs@15.4.1: - resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} - engines: {node: '>=8'} - yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -14714,10 +14658,6 @@ snapshots: '@types/ps-tree@1.1.6': {} - '@types/qrcode@1.5.5': - dependencies: - '@types/node': 24.2.1 - '@types/react-dom@18.3.7(@types/react@18.3.23)': dependencies: '@types/react': 18.3.23 @@ -15532,8 +15472,6 @@ snapshots: camelcase-css@2.0.1: {} - camelcase@5.3.1: {} - camelcase@6.3.0: {} camelize@1.0.1: {} @@ -15693,12 +15631,6 @@ snapshots: client-only@0.0.1: {} - cliui@6.0.0: - dependencies: - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi: 6.2.0 - cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -16163,8 +16095,6 @@ snapshots: dependencies: ms: 2.1.3 - decamelize@1.2.0: {} - decamelize@4.0.0: {} decimal.js-light@2.5.1: {} @@ -16262,8 +16192,6 @@ snapshots: diff@5.2.0: {} - dijkstrajs@1.0.3: {} - dingbat-to-unicode@1.0.1: {} dir-glob@3.0.1: @@ -19634,8 +19562,6 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 - pngjs@5.0.0: {} - points-on-curve@0.2.0: {} points-on-path@0.2.1: @@ -19906,12 +19832,6 @@ snapshots: - supports-color - utf-8-validate - qrcode@1.5.4: - dependencies: - dijkstrajs: 1.0.3 - pngjs: 5.0.0 - yargs: 15.4.1 - qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -20320,8 +20240,6 @@ snapshots: require-directory@2.1.1: {} - require-main-filename@2.0.0: {} - resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -20569,8 +20487,6 @@ snapshots: transitivePeerDependencies: - supports-color - set-blocking@2.0.0: {} - set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -22063,8 +21979,6 @@ snapshots: is-weakmap: 2.0.2 is-weakset: 2.0.4 - which-module@2.0.1: {} - which-pm-runs@1.1.0: {} which-typed-array@1.1.19: @@ -22112,12 +22026,6 @@ snapshots: workerpool@9.2.0: {} - wrap-ansi@6.2.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -22161,8 +22069,6 @@ snapshots: xtend@4.0.2: {} - y18n@4.0.3: {} - y18n@5.0.8: {} yallist@3.1.1: {} @@ -22173,11 +22079,6 @@ snapshots: yaml@2.8.0: {} - yargs-parser@18.1.3: - dependencies: - camelcase: 5.3.1 - decamelize: 1.2.0 - yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} @@ -22189,20 +22090,6 @@ snapshots: flat: 5.0.2 is-plain-obj: 2.1.0 - yargs@15.4.1: - dependencies: - cliui: 6.0.0 - decamelize: 1.2.0 - find-up: 4.1.0 - get-caller-file: 2.0.5 - require-directory: 2.1.1 - require-main-filename: 2.0.0 - set-blocking: 2.0.0 - string-width: 4.2.3 - which-module: 2.0.1 - y18n: 4.0.3 - yargs-parser: 18.1.3 - yargs@16.2.0: dependencies: cliui: 7.0.4