diff --git a/.changeset/seven-apricots-watch.md b/.changeset/seven-apricots-watch.md new file mode 100644 index 00000000000..ec51cccdea7 --- /dev/null +++ b/.changeset/seven-apricots-watch.md @@ -0,0 +1,5 @@ +--- +"roo-cline": patch +--- + +ContextProxy fix - constructor should not be async diff --git a/.github/workflows/code-qa.yml b/.github/workflows/code-qa.yml index 2e269cbd793..2bbad069bb1 100644 --- a/.github/workflows/code-qa.yml +++ b/.github/workflows/code-qa.yml @@ -110,9 +110,9 @@ jobs: cache: 'npm' - name: Install dependencies run: npm run install:all - - name: Create env.integration file + - name: Create .env.local file working-directory: e2e - run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.integration + run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.local - name: Run integration tests working-directory: e2e run: xvfb-run -a npm run ci diff --git a/.gitignore b/.gitignore index e5cc9a6117b..02fdf8f88e5 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,9 @@ docs/_site/ # Dotenv .env -.env.integration +.env.* +!.env.*.sample + #Local lint config .eslintrc.local.json diff --git a/e2e/.env.integration.example b/e2e/.env.local.sample similarity index 100% rename from e2e/.env.integration.example rename to e2e/.env.local.sample diff --git a/e2e/VSCODE_INTEGRATION_TESTS.md b/e2e/VSCODE_INTEGRATION_TESTS.md index a36b2403811..452c00c2268 100644 --- a/e2e/VSCODE_INTEGRATION_TESTS.md +++ b/e2e/VSCODE_INTEGRATION_TESTS.md @@ -30,7 +30,7 @@ The test runner (`runTest.ts`) is responsible for: ### Environment Setup -1. Create a `.env.integration` file in the root directory with required environment variables: +1. Create a `.env.local` file in the root directory with required environment variables: ``` OPENROUTER_API_KEY=sk-or-v1-... @@ -67,7 +67,7 @@ declare global { ## Running Tests -1. Ensure you have the required environment variables set in `.env.integration` +1. Ensure you have the required environment variables set in `.env.local` 2. Run the integration tests: @@ -117,8 +117,10 @@ const interval = 1000 2. **State Management**: Reset extension state before/after tests: ```typescript -await globalThis.provider.updateGlobalState("mode", "Ask") -await globalThis.provider.updateGlobalState("alwaysAllowModeSwitch", true) +await globalThis.api.setConfiguration({ + mode: "Ask", + alwaysAllowModeSwitch: true, +}) ``` 3. **Assertions**: Use clear assertions with meaningful messages: @@ -141,8 +143,12 @@ try { ```typescript let startTime = Date.now() + while (Date.now() - startTime < timeout) { - if (condition) break + if (condition) { + break + } + await new Promise((resolve) => setTimeout(resolve, interval)) } ``` diff --git a/e2e/package.json b/e2e/package.json index 630a13f0e7e..d4932f8a076 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -3,12 +3,13 @@ "version": "0.1.0", "private": true, "scripts": { - "build": "cd .. && npm run build", - "compile": "tsc -p tsconfig.json", + "build": "cd .. && npm run compile && npm run build:webview", + "compile": "rm -rf out && tsc -p tsconfig.json", "lint": "eslint src --ext ts", "check-types": "tsc --noEmit", - "test": "npm run compile && npx dotenvx run -f .env.integration -- node ./out/runTest.js", - "ci": "npm run build && npm run test" + "test": "npm run compile && npx dotenvx run -f .env.local -- node ./out/runTest.js", + "ci": "npm run build && npm run test", + "clean": "rimraf out" }, "dependencies": {}, "devDependencies": { diff --git a/e2e/src/suite/index.ts b/e2e/src/suite/index.ts index 19e00aa40c2..aea3d882320 100644 --- a/e2e/src/suite/index.ts +++ b/e2e/src/suite/index.ts @@ -1,80 +1,46 @@ import * as path from "path" import Mocha from "mocha" import { glob } from "glob" -import { RooCodeAPI, ClineProvider } from "../../../src/exports/roo-code" import * as vscode from "vscode" +import { RooCodeAPI } from "../../../src/exports/roo-code" + +import { waitUntilReady } from "./utils" + declare global { - var api: RooCodeAPI - var provider: ClineProvider var extension: vscode.Extension | undefined - var panel: vscode.WebviewPanel | undefined + var api: RooCodeAPI } -export async function run(): Promise { - const mocha = new Mocha({ - ui: "tdd", - timeout: 600000, // 10 minutes to compensate for time communicating with LLM while running in GHA. - }) - +export async function run() { + const mocha = new Mocha({ ui: "tdd", timeout: 300_000 }) const testsRoot = path.resolve(__dirname, "..") try { // Find all test files. const files = await glob("**/**.test.js", { cwd: testsRoot }) - - // Add files to the test suite. files.forEach((f: string) => mocha.addFile(path.resolve(testsRoot, f))) - // Set up global extension, api, provider, and panel. - globalThis.extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline") + const extension = vscode.extensions.getExtension("RooVeterinaryInc.roo-cline") - if (!globalThis.extension) { + if (!extension) { throw new Error("Extension not found") } - globalThis.api = globalThis.extension.isActive - ? globalThis.extension.exports - : await globalThis.extension.activate() - - globalThis.provider = globalThis.api.sidebarProvider + const api = extension.isActive ? extension.exports : await extension.activate() - await globalThis.provider.updateGlobalState("apiProvider", "openrouter") - await globalThis.provider.updateGlobalState("openRouterModelId", "anthropic/claude-3.5-sonnet") - - await globalThis.provider.storeSecret( - "openRouterApiKey", - process.env.OPENROUTER_API_KEY || "sk-or-v1-fake-api-key", - ) - - globalThis.panel = vscode.window.createWebviewPanel( - "roo-cline.SidebarProvider", - "Roo Code", - vscode.ViewColumn.One, - { - enableScripts: true, - enableCommandUris: true, - retainContextWhenHidden: true, - localResourceRoots: [globalThis.extension?.extensionUri], - }, - ) - - await globalThis.provider.resolveWebviewView(globalThis.panel) - - let startTime = Date.now() - const timeout = 60000 - const interval = 1000 + await api.setConfiguration({ + apiProvider: "openrouter", + openRouterApiKey: process.env.OPENROUTER_API_KEY!, + openRouterModelId: "anthropic/claude-3.5-sonnet", + }) - while (Date.now() - startTime < timeout) { - if (globalThis.provider.viewLaunched) { - break - } + await waitUntilReady(api) - await new Promise((resolve) => setTimeout(resolve, interval)) - } + globalThis.api = api + globalThis.extension = extension - // Run the mocha test. - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { try { mocha.run((failures: number) => { if (failures > 0) { diff --git a/e2e/src/suite/modes.test.ts b/e2e/src/suite/modes.test.ts index b94e71d1106..5083d289f6a 100644 --- a/e2e/src/suite/modes.test.ts +++ b/e2e/src/suite/modes.test.ts @@ -1,102 +1,60 @@ import * as assert from "assert" +import { waitForMessage } from "./utils" + suite("Roo Code Modes", () => { test("Should handle switching modes correctly", async function () { - const timeout = 30000 - const interval = 1000 + const timeout = 300_000 + const api = globalThis.api const testPrompt = - "For each mode (Code, Architect, Ask) respond with the mode name and what it specializes in after switching to that mode, do not start with the current mode, be sure to say 'I AM DONE' after the task is complete" - - if (!globalThis.extension) { - assert.fail("Extension not found") - } - - let startTime = Date.now() - - // Ensure the webview is launched. - while (Date.now() - startTime < timeout) { - if (globalThis.provider.viewLaunched) { - break - } - - await new Promise((resolve) => setTimeout(resolve, interval)) - } - - await globalThis.provider.updateGlobalState("mode", "Ask") - await globalThis.provider.updateGlobalState("alwaysAllowModeSwitch", true) - await globalThis.provider.updateGlobalState("autoApprovalEnabled", true) - - // Start a new task. - await globalThis.api.startNewTask(testPrompt) + "For each mode (Code, Architect, Ask) respond with the mode name and what it specializes in after switching to that mode, do not start with the current mode, be sure to say 'I AM DONE' after the task is complete." - // Wait for task to appear in history with tokens. - startTime = Date.now() + await api.setConfiguration({ mode: "Code", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }) + await api.startNewTask(testPrompt) - while (Date.now() - startTime < timeout) { - const messages = globalThis.provider.messages + await waitForMessage(api, { include: "I AM DONE", exclude: "be sure to say", timeout }) - if ( - messages.some( - ({ type, text }) => - type === "say" && text?.includes("I AM DONE") && !text?.includes("be sure to say"), - ) - ) { - break - } - - await new Promise((resolve) => setTimeout(resolve, interval)) - } - - if (globalThis.provider.messages.length === 0) { + if (api.getMessages().length === 0) { assert.fail("No messages received") } // Log the messages to the console. - globalThis.provider.messages.forEach(({ type, text }) => { + api.getMessages().forEach(({ type, text }) => { if (type === "say") { console.log(text) } }) // Start Grading Portion of test to grade the response from 1 to 10. - await globalThis.provider.updateGlobalState("mode", "Ask") - let output = globalThis.provider.messages.map(({ type, text }) => (type === "say" ? text : "")).join("\n") - - await globalThis.api.startNewTask( - `Given this prompt: ${testPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ${output} \n Be sure to say 'I AM DONE GRADING' after the task is complete`, - ) - - startTime = Date.now() + await api.setConfiguration({ mode: "Ask" }) - while (Date.now() - startTime < timeout) { - const messages = globalThis.provider.messages + let output = api + .getMessages() + .map(({ type, text }) => (type === "say" ? text : "")) + .join("\n") - if ( - messages.some( - ({ type, text }) => - type === "say" && text?.includes("I AM DONE GRADING") && !text?.includes("be sure to say"), - ) - ) { - break - } + await api.startNewTask( + `Given this prompt: ${testPrompt} grade the response from 1 to 10 in the format of "Grade: (1-10)": ${output}\nBe sure to say 'I AM DONE GRADING' after the task is complete.`, + ) - await new Promise((resolve) => setTimeout(resolve, interval)) - } + await waitForMessage(api, { include: "I AM DONE GRADING", exclude: "be sure to say", timeout }) - if (globalThis.provider.messages.length === 0) { + if (api.getMessages().length === 0) { assert.fail("No messages received") } - globalThis.provider.messages.forEach(({ type, text }) => { + api.getMessages().forEach(({ type, text }) => { if (type === "say" && text?.includes("Grade:")) { console.log(text) } }) - const gradeMessage = globalThis.provider.messages.find( - ({ type, text }) => type === "say" && !text?.includes("Grade: (1-10)") && text?.includes("Grade:"), - )?.text + const gradeMessage = api + .getMessages() + .find( + ({ type, text }) => type === "say" && !text?.includes("Grade: (1-10)") && text?.includes("Grade:"), + )?.text const gradeMatch = gradeMessage?.match(/Grade: (\d+)/) const gradeNum = gradeMatch ? parseInt(gradeMatch[1]) : undefined diff --git a/e2e/src/suite/subtasks.test.ts b/e2e/src/suite/subtasks.test.ts new file mode 100644 index 00000000000..4a3a65375a9 --- /dev/null +++ b/e2e/src/suite/subtasks.test.ts @@ -0,0 +1,58 @@ +import * as assert from "assert" + +import { sleep, waitForToolUse, waitForMessage } from "./utils" + +suite("Roo Code Subtasks", () => { + test.skip("Should handle subtask cancellation and resumption correctly", async function () { + const api = globalThis.api + + await api.setConfiguration({ + mode: "Code", + alwaysAllowModeSwitch: true, + alwaysAllowSubtasks: true, + autoApprovalEnabled: true, + }) + + // Start a parent task that will create a subtask. + await api.startNewTask( + "You are the parent task. " + + "Create a subtask by using the new_task tool with the message 'You are the subtask'. " + + "After creating the subtask, wait for it to complete and then respond with 'Parent task resumed'.", + ) + + await waitForToolUse(api, "new_task") + + // Cancel the current task (which should be the subtask). + await api.cancelTask() + + // Check if the parent task is still waiting (not resumed). We need to + // wait a bit to ensure any task resumption would have happened. + await sleep(5_000) + + // The parent task should not have resumed yet, so we shouldn't see + // "Parent task resumed". + assert.ok( + !api.getMessages().some(({ type, text }) => type === "say" && text?.includes("Parent task resumed")), + "Parent task should not have resumed after subtask cancellation.", + ) + + // Start a new task with the same message as the subtask. + await api.startNewTask("You are the subtask") + + // Wait for the subtask to complete. + await waitForMessage(api, { include: "Task complete" }) + + // Verify that the parent task is still not resumed. We need to wait a + // bit to ensure any task resumption would have happened. + await sleep(5_000) + + // The parent task should still not have resumed. + assert.ok( + !api.getMessages().some(({ type, text }) => type === "say" && text?.includes("Parent task resumed")), + "Parent task should not have resumed after subtask completion.", + ) + + // Clean up - cancel all tasks. + await api.cancelTask() + }) +}) diff --git a/e2e/src/suite/task.test.ts b/e2e/src/suite/task.test.ts index e6bab19c41b..679a82f550e 100644 --- a/e2e/src/suite/task.test.ts +++ b/e2e/src/suite/task.test.ts @@ -1,161 +1,10 @@ -import * as assert from "assert" -import * as vscode from "vscode" +import { waitForMessage } from "./utils" suite("Roo Code Task", () => { test("Should handle prompt and response correctly", async function () { - const timeout = 30000 - const interval = 1000 - - if (!globalThis.extension) { - assert.fail("Extension not found") - } - - // Ensure the webview is launched. - let startTime = Date.now() - - while (Date.now() - startTime < timeout) { - if (globalThis.provider.viewLaunched) { - break - } - - await new Promise((resolve) => setTimeout(resolve, interval)) - } - - await globalThis.provider.updateGlobalState("mode", "Code") - await globalThis.provider.updateGlobalState("alwaysAllowModeSwitch", true) - await globalThis.provider.updateGlobalState("autoApprovalEnabled", true) - - await globalThis.api.startNewTask("Hello world, what is your name? Respond with 'My name is ...'") - - // Wait for task to appear in history with tokens. - startTime = Date.now() - - while (Date.now() - startTime < timeout) { - const messages = globalThis.provider.messages - - if (messages.some(({ type, text }) => type === "say" && text?.includes("My name is Roo"))) { - break - } - - await new Promise((resolve) => setTimeout(resolve, interval)) - } - - if (globalThis.provider.messages.length === 0) { - assert.fail("No messages received") - } - - assert.ok( - globalThis.provider.messages.some(({ type, text }) => type === "say" && text?.includes("My name is Roo")), - "Did not receive expected response containing 'My name is Roo'", - ) - }) - - test("Should handle subtask cancellation and resumption correctly", async function () { - this.timeout(60000) // Increase timeout for this test - const interval = 1000 - - if (!globalThis.extension) { - assert.fail("Extension not found") - } - - // Ensure the webview is launched - await ensureWebviewLaunched(30000, interval) - - // Set up required global state - await globalThis.provider.updateGlobalState("mode", "Code") - await globalThis.provider.updateGlobalState("alwaysAllowModeSwitch", true) - await globalThis.provider.updateGlobalState("alwaysAllowSubtasks", true) - await globalThis.provider.updateGlobalState("autoApprovalEnabled", true) - - // 1. Start a parent task that will create a subtask - await globalThis.api.startNewTask( - "You are the parent task. Create a subtask by using the new_task tool with the message 'You are the subtask'. " + - "After creating the subtask, wait for it to complete and then respond with 'Parent task resumed'.", - ) - - // Wait for the parent task to use the new_task tool - await waitForToolUse("new_task", 30000, interval) - - // Wait for the subtask to be created and start responding - await waitForMessage("You are the subtask", 10000, interval) - - // 3. Cancel the current task (which should be the subtask) - await globalThis.provider.cancelTask() - - // 4. Check if the parent task is still waiting (not resumed) - // We need to wait a bit to ensure any task resumption would have happened - await new Promise((resolve) => setTimeout(resolve, 5000)) - - // The parent task should not have resumed yet, so we shouldn't see "Parent task resumed" - assert.ok( - !globalThis.provider.messages.some( - ({ type, text }) => type === "say" && text?.includes("Parent task resumed"), - ), - "Parent task should not have resumed after subtask cancellation", - ) - - // 5. Start a new task with the same message as the subtask - await globalThis.api.startNewTask("You are the subtask") - - // Wait for the subtask to complete - await waitForMessage("Task complete", 20000, interval) - - // 6. Verify that the parent task is still not resumed - // We need to wait a bit to ensure any task resumption would have happened - await new Promise((resolve) => setTimeout(resolve, 5000)) - - // The parent task should still not have resumed - assert.ok( - !globalThis.provider.messages.some( - ({ type, text }) => type === "say" && text?.includes("Parent task resumed"), - ), - "Parent task should not have resumed after subtask completion", - ) - - // Clean up - cancel all tasks - await globalThis.provider.cancelTask() + const api = globalThis.api + await api.setConfiguration({ mode: "Ask", alwaysAllowModeSwitch: true, autoApprovalEnabled: true }) + await api.startNewTask("Hello world, what is your name? Respond with 'My name is ...'") + await waitForMessage(api, { include: "My name is Roo" }) }) }) - -// Helper functions -async function ensureWebviewLaunched(timeout: number, interval: number): Promise { - const startTime = Date.now() - while (Date.now() - startTime < timeout) { - if (globalThis.provider.viewLaunched) { - return - } - await new Promise((resolve) => setTimeout(resolve, interval)) - } - throw new Error("Webview failed to launch within timeout") -} - -async function waitForToolUse(toolName: string, timeout: number, interval: number): Promise { - const startTime = Date.now() - while (Date.now() - startTime < timeout) { - const messages = globalThis.provider.messages - if ( - messages.some( - (message) => - message.type === "say" && message.say === "tool" && message.text && message.text.includes(toolName), - ) - ) { - return - } - await new Promise((resolve) => setTimeout(resolve, interval)) - } - throw new Error(`Tool ${toolName} was not used within timeout`) -} - -async function waitForMessage(messageContent: string, timeout: number, interval: number): Promise { - const startTime = Date.now() - while (Date.now() - startTime < timeout) { - const messages = globalThis.provider.messages - if ( - messages.some((message) => message.type === "say" && message.text && message.text.includes(messageContent)) - ) { - return - } - await new Promise((resolve) => setTimeout(resolve, interval)) - } - throw new Error(`Message containing "${messageContent}" not found within timeout`) -} diff --git a/e2e/src/suite/utils.ts b/e2e/src/suite/utils.ts new file mode 100644 index 00000000000..c0927fc6503 --- /dev/null +++ b/e2e/src/suite/utils.ts @@ -0,0 +1,76 @@ +import * as vscode from "vscode" + +import { RooCodeAPI } from "../../../src/exports/roo-code" + +type WaitForOptions = { + timeout?: number + interval?: number +} + +export const waitFor = ( + condition: (() => Promise) | (() => boolean), + { timeout = 30_000, interval = 250 }: WaitForOptions = {}, +) => { + let timeoutId: NodeJS.Timeout | undefined = undefined + + return Promise.race([ + new Promise((resolve) => { + const check = async () => { + const result = condition() + const isSatisfied = result instanceof Promise ? await result : result + + if (isSatisfied) { + if (timeoutId) { + clearTimeout(timeoutId) + timeoutId = undefined + } + + resolve() + } else { + setTimeout(check, interval) + } + } + + check() + }), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Timeout after ${Math.floor(timeout / 1000)}s`)) + }, timeout) + }), + ]) +} + +export const waitUntilReady = async (api: RooCodeAPI, { timeout = 10_000, interval = 250 }: WaitForOptions = {}) => { + await vscode.commands.executeCommand("roo-cline.SidebarProvider.focus") + await waitFor(api.isReady, { timeout, interval }) +} + +export const waitForToolUse = async (api: RooCodeAPI, toolName: string, options: WaitForOptions = {}) => + waitFor( + () => + api + .getMessages() + .some(({ type, say, text }) => type === "say" && say === "tool" && text && text.includes(toolName)), + options, + ) + +export const waitForMessage = async ( + api: RooCodeAPI, + options: WaitForOptions & { include: string; exclude?: string }, +) => + waitFor( + () => + api + .getMessages() + .some( + ({ type, text }) => + type === "say" && + text && + text.includes(options.include) && + (!options.exclude || !text.includes(options.exclude)), + ), + options, + ) + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/knip.json b/knip.json index a9f0b93e0d2..55b60e82104 100644 --- a/knip.json +++ b/knip.json @@ -13,12 +13,10 @@ "dist/**", "out/**", "bin/**", + "e2e/**", "src/activate/**", "src/exports/**", - "src/extension.ts", - "e2e/.vscode-test.mjs", - "e2e/src/runTest.ts", - "e2e/src/suite/index.ts" + "src/extension.ts" ], "workspaces": { "webview-ui": { diff --git a/src/activate/createRooCodeAPI.ts b/src/activate/createRooCodeAPI.ts index 18c696a8fde..aa404999aa8 100644 --- a/src/activate/createRooCodeAPI.ts +++ b/src/activate/createRooCodeAPI.ts @@ -3,24 +3,18 @@ import * as vscode from "vscode" import { ClineProvider } from "../core/webview/ClineProvider" import { RooCodeAPI } from "../exports/roo-code" +import { ConfigurationValues } from "../shared/globalState" -export function createRooCodeAPI(outputChannel: vscode.OutputChannel, sidebarProvider: ClineProvider): RooCodeAPI { +export function createRooCodeAPI(outputChannel: vscode.OutputChannel, provider: ClineProvider): RooCodeAPI { return { - setCustomInstructions: async (value: string) => { - await sidebarProvider.updateCustomInstructions(value) - outputChannel.appendLine("Custom instructions set") - }, - - getCustomInstructions: async () => { - return (await sidebarProvider.getGlobalState("customInstructions")) as string | undefined - }, - startNewTask: async (task?: string, images?: string[]) => { outputChannel.appendLine("Starting new task") - await sidebarProvider.removeClineFromStack() - await sidebarProvider.postStateToWebview() - await sidebarProvider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) - await sidebarProvider.postMessageToWebview({ + + await provider.removeClineFromStack() + await provider.postStateToWebview() + await provider.postMessageToWebview({ type: "action", action: "chatButtonClicked" }) + + await provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text: task, @@ -32,12 +26,17 @@ export function createRooCodeAPI(outputChannel: vscode.OutputChannel, sidebarPro ) }, + cancelTask: async () => { + outputChannel.appendLine("Cancelling current task") + await provider.cancelTask() + }, + sendMessage: async (message?: string, images?: string[]) => { outputChannel.appendLine( `Sending message: ${message ? `"${message}"` : "undefined"} with ${images?.length || 0} image(s)`, ) - await sidebarProvider.postMessageToWebview({ + await provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text: message, @@ -47,14 +46,20 @@ export function createRooCodeAPI(outputChannel: vscode.OutputChannel, sidebarPro pressPrimaryButton: async () => { outputChannel.appendLine("Pressing primary button") - await sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" }) + await provider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" }) }, pressSecondaryButton: async () => { outputChannel.appendLine("Pressing secondary button") - await sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" }) + await provider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" }) }, - sidebarProvider: sidebarProvider, + setConfiguration: async (values: Partial) => { + await provider.setValues(values) + }, + + isReady: () => provider.viewLaunched, + + getMessages: () => provider.messages, } } diff --git a/src/api/providers/openrouter.ts b/src/api/providers/openrouter.ts index 01e1393aa56..33feba5db71 100644 --- a/src/api/providers/openrouter.ts +++ b/src/api/providers/openrouter.ts @@ -1,6 +1,6 @@ import { Anthropic } from "@anthropic-ai/sdk" import { BetaThinkingConfigParam } from "@anthropic-ai/sdk/resources/beta" -import axios from "axios" +import axios, { AxiosRequestConfig } from "axios" import OpenAI from "openai" import delay from "delay" @@ -132,62 +132,55 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH const delta = chunk.choices[0]?.delta if ("reasoning" in delta && delta.reasoning) { - yield { - type: "reasoning", - text: delta.reasoning, - } as ApiStreamChunk + yield { type: "reasoning", text: delta.reasoning } as ApiStreamChunk } if (delta?.content) { fullResponseText += delta.content - yield { - type: "text", - text: delta.content, - } as ApiStreamChunk + yield { type: "text", text: delta.content } as ApiStreamChunk } + } - // if (chunk.usage) { - // yield { - // type: "usage", - // inputTokens: chunk.usage.prompt_tokens || 0, - // outputTokens: chunk.usage.completion_tokens || 0, - // } - // } + const endpoint = `${this.client.baseURL}/generation?id=${genId}` + + const config: AxiosRequestConfig = { + headers: { Authorization: `Bearer ${this.options.openRouterApiKey}` }, + timeout: 3_000, } - // Retry fetching generation details. let attempt = 0 + let lastError: Error | undefined + const startTime = Date.now() while (attempt++ < 10) { - await delay(200) // FIXME: necessary delay to ensure generation endpoint is ready + await delay(attempt * 100) // Give OpenRouter some time to produce the generation metadata. try { - const response = await axios.get(`${this.client.baseURL}/generation?id=${genId}`, { - headers: { - Authorization: `Bearer ${this.options.openRouterApiKey}`, - }, - timeout: 5_000, // this request hangs sometimes - }) - + const response = await axios.get(endpoint, config) const generation = response.data?.data yield { type: "usage", - // cacheWriteTokens: 0, - // cacheReadTokens: 0, - // openrouter generation endpoint fails often inputTokens: generation?.native_tokens_prompt || 0, outputTokens: generation?.native_tokens_completion || 0, totalCost: generation?.total_cost || 0, fullResponseText, } as OpenRouterApiStreamUsageChunk - return - } catch (error) { - // ignore if fails - console.error("Error fetching OpenRouter generation details:", error) + break + } catch (error: unknown) { + if (error instanceof Error) { + lastError = error + } } } + + if (lastError) { + console.error( + `Failed to fetch OpenRouter generation details after ${Date.now() - startTime}ms (${genId})`, + lastError, + ) + } } override getModel() { diff --git a/src/core/__tests__/contextProxy.test.ts b/src/core/__tests__/contextProxy.test.ts index e44f3e45b3c..ea0cf8b578c 100644 --- a/src/core/__tests__/contextProxy.test.ts +++ b/src/core/__tests__/contextProxy.test.ts @@ -1,15 +1,11 @@ +// npx jest src/core/__tests__/contextProxy.test.ts + import * as vscode from "vscode" import { ContextProxy } from "../contextProxy" -import { logger } from "../../utils/logging" -import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../../shared/globalState" -// Mock shared/globalState -jest.mock("../../shared/globalState", () => ({ - GLOBAL_STATE_KEYS: ["apiProvider", "apiModelId", "mode"], - SECRET_KEYS: ["apiKey", "openAiApiKey"], -})) +import { logger } from "../../utils/logging" +import { GLOBAL_STATE_KEYS, SECRET_KEYS, ConfigurationKey, GlobalStateKey } from "../../shared/globalState" -// Mock VSCode API jest.mock("vscode", () => ({ Uri: { file: jest.fn((path) => ({ path })), @@ -27,7 +23,7 @@ describe("ContextProxy", () => { let mockGlobalState: any let mockSecrets: any - beforeEach(() => { + beforeEach(async () => { // Reset mocks jest.clearAllMocks() @@ -58,6 +54,7 @@ describe("ContextProxy", () => { // Create proxy instance proxy = new ContextProxy(mockContext) + await proxy.initialize() }) describe("read-only pass-through properties", () => { @@ -90,10 +87,10 @@ describe("ContextProxy", () => { describe("getGlobalState", () => { it("should return value from cache when it exists", async () => { // Manually set a value in the cache - await proxy.updateGlobalState("test-key", "cached-value") + await proxy.updateGlobalState("apiProvider", "cached-value") // Should return the cached value - const result = proxy.getGlobalState("test-key") + const result = proxy.getGlobalState("apiProvider") expect(result).toBe("cached-value") // Original context should be called once during updateGlobalState @@ -102,20 +99,20 @@ describe("ContextProxy", () => { it("should handle default values correctly", async () => { // No value in cache - const result = proxy.getGlobalState("unknown-key", "default-value") + const result = proxy.getGlobalState("apiProvider", "default-value") expect(result).toBe("default-value") }) }) describe("updateGlobalState", () => { it("should update state directly in original context", async () => { - await proxy.updateGlobalState("test-key", "new-value") + await proxy.updateGlobalState("apiProvider", "new-value") // Should have called original context - expect(mockGlobalState.update).toHaveBeenCalledWith("test-key", "new-value") + expect(mockGlobalState.update).toHaveBeenCalledWith("apiProvider", "new-value") // Should have stored the value in cache - const storedValue = await proxy.getGlobalState("test-key") + const storedValue = await proxy.getGlobalState("apiProvider") expect(storedValue).toBe("new-value") }) }) @@ -123,34 +120,34 @@ describe("ContextProxy", () => { describe("getSecret", () => { it("should return value from cache when it exists", async () => { // Manually set a value in the cache - await proxy.storeSecret("api-key", "cached-secret") + await proxy.storeSecret("apiKey", "cached-secret") // Should return the cached value - const result = proxy.getSecret("api-key") + const result = proxy.getSecret("apiKey") expect(result).toBe("cached-secret") }) }) describe("storeSecret", () => { it("should store secret directly in original context", async () => { - await proxy.storeSecret("api-key", "new-secret") + await proxy.storeSecret("apiKey", "new-secret") // Should have called original context - expect(mockSecrets.store).toHaveBeenCalledWith("api-key", "new-secret") + expect(mockSecrets.store).toHaveBeenCalledWith("apiKey", "new-secret") // Should have stored the value in cache - const storedValue = await proxy.getSecret("api-key") + const storedValue = await proxy.getSecret("apiKey") expect(storedValue).toBe("new-secret") }) it("should handle undefined value for secret deletion", async () => { - await proxy.storeSecret("api-key", undefined) + await proxy.storeSecret("apiKey", undefined) // Should have called delete on original context - expect(mockSecrets.delete).toHaveBeenCalledWith("api-key") + expect(mockSecrets.delete).toHaveBeenCalledWith("apiKey") // Should have stored undefined in cache - const storedValue = await proxy.getSecret("api-key") + const storedValue = await proxy.getSecret("apiKey") expect(storedValue).toBeUndefined() }) }) @@ -194,7 +191,7 @@ describe("ContextProxy", () => { const updateGlobalStateSpy = jest.spyOn(proxy, "updateGlobalState") // Test with an unknown key - await proxy.setValue("unknownKey", "some-value") + await proxy.setValue("unknownKey" as ConfigurationKey, "some-value") // Should have logged a warning expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Unknown key: unknownKey")) @@ -203,7 +200,7 @@ describe("ContextProxy", () => { expect(updateGlobalStateSpy).toHaveBeenCalledWith("unknownKey", "some-value") // Should have stored the value in state cache - const storedValue = proxy.getGlobalState("unknownKey") + const storedValue = proxy.getGlobalState("unknownKey" as GlobalStateKey) expect(storedValue).toBe("some-value") }) }) @@ -241,18 +238,15 @@ describe("ContextProxy", () => { await proxy.setValues({ apiModelId: "gpt-4", // global state openAiApiKey: "test-api-key", // secret - unknownKey: "some-value", // unknown }) // Should have called appropriate methods expect(storeSecretSpy).toHaveBeenCalledWith("openAiApiKey", "test-api-key") expect(updateGlobalStateSpy).toHaveBeenCalledWith("apiModelId", "gpt-4") - expect(updateGlobalStateSpy).toHaveBeenCalledWith("unknownKey", "some-value") // Should have stored values in appropriate caches expect(proxy.getSecret("openAiApiKey")).toBe("test-api-key") expect(proxy.getGlobalState("apiModelId")).toBe("gpt-4") - expect(proxy.getGlobalState("unknownKey")).toBe("some-value") }) }) @@ -262,13 +256,11 @@ describe("ContextProxy", () => { await proxy.setValues({ apiModelId: "gpt-4", // global state openAiApiKey: "test-api-key", // secret - unknownKey: "some-value", // unknown }) // Verify initial state expect(proxy.getGlobalState("apiModelId")).toBe("gpt-4") expect(proxy.getSecret("openAiApiKey")).toBe("test-api-key") - expect(proxy.getGlobalState("unknownKey")).toBe("some-value") // Reset all state await proxy.resetAllState() @@ -277,7 +269,6 @@ describe("ContextProxy", () => { // Since our mock globalState.get returns undefined by default, // the cache should now contain undefined values expect(proxy.getGlobalState("apiModelId")).toBeUndefined() - expect(proxy.getGlobalState("unknownKey")).toBeUndefined() }) it("should update all global state keys to undefined", async () => { @@ -317,15 +308,13 @@ describe("ContextProxy", () => { it("should reinitialize caches after reset", async () => { // Spy on initialization methods - const initStateCache = jest.spyOn(proxy as any, "initializeStateCache") - const initSecretCache = jest.spyOn(proxy as any, "initializeSecretCache") + const initializeSpy = jest.spyOn(proxy as any, "initialize") // Reset all state await proxy.resetAllState() // Should reinitialize caches - expect(initStateCache).toHaveBeenCalledTimes(1) - expect(initSecretCache).toHaveBeenCalledTimes(1) + expect(initializeSpy).toHaveBeenCalledTimes(1) }) }) }) diff --git a/src/core/contextProxy.ts b/src/core/contextProxy.ts index 52197d99f3a..b1cfc71a5a1 100644 --- a/src/core/contextProxy.ts +++ b/src/core/contextProxy.ts @@ -1,97 +1,105 @@ import * as vscode from "vscode" + import { logger } from "../utils/logging" -import { GLOBAL_STATE_KEYS, SECRET_KEYS } from "../shared/globalState" +import { + GLOBAL_STATE_KEYS, + SECRET_KEYS, + GlobalStateKey, + SecretKey, + ConfigurationKey, + ConfigurationValues, + isSecretKey, + isGlobalStateKey, +} from "../shared/globalState" export class ContextProxy { private readonly originalContext: vscode.ExtensionContext - private stateCache: Map - private secretCache: Map + + private stateCache: Map + private secretCache: Map + private _isInitialized = false constructor(context: vscode.ExtensionContext) { - // Initialize properties first this.originalContext = context this.stateCache = new Map() this.secretCache = new Map() + this._isInitialized = false + } - // Initialize state cache with all defined global state keys - this.initializeStateCache() - - // Initialize secret cache with all defined secret keys - this.initializeSecretCache() - - logger.debug("ContextProxy created") + public get isInitialized() { + return this._isInitialized } - // Helper method to initialize state cache - private initializeStateCache(): void { + public async initialize() { for (const key of GLOBAL_STATE_KEYS) { try { - const value = this.originalContext.globalState.get(key) - this.stateCache.set(key, value) + this.stateCache.set(key, this.originalContext.globalState.get(key)) } catch (error) { logger.error(`Error loading global ${key}: ${error instanceof Error ? error.message : String(error)}`) } } - } - // Helper method to initialize secret cache - private initializeSecretCache(): void { - for (const key of SECRET_KEYS) { - // Get actual value and update cache when promise resolves - ;(this.originalContext.secrets.get(key) as Promise) - .then((value) => { - this.secretCache.set(key, value) - }) - .catch((error: Error) => { - logger.error(`Error loading secret ${key}: ${error.message}`) - }) - } + const promises = SECRET_KEYS.map(async (key) => { + try { + this.secretCache.set(key, await this.originalContext.secrets.get(key)) + } catch (error) { + logger.error(`Error loading secret ${key}: ${error instanceof Error ? error.message : String(error)}`) + } + }) + + await Promise.all(promises) + + this._isInitialized = true } - get extensionUri(): vscode.Uri { + get extensionUri() { return this.originalContext.extensionUri } - get extensionPath(): string { + + get extensionPath() { return this.originalContext.extensionPath } - get globalStorageUri(): vscode.Uri { + + get globalStorageUri() { return this.originalContext.globalStorageUri } - get logUri(): vscode.Uri { + + get logUri() { return this.originalContext.logUri } - get extension(): vscode.Extension | undefined { + + get extension() { return this.originalContext.extension } - get extensionMode(): vscode.ExtensionMode { + + get extensionMode() { return this.originalContext.extensionMode } - getGlobalState(key: string): T | undefined - getGlobalState(key: string, defaultValue: T): T - getGlobalState(key: string, defaultValue?: T): T | undefined { + getGlobalState(key: GlobalStateKey): T | undefined + getGlobalState(key: GlobalStateKey, defaultValue: T): T + getGlobalState(key: GlobalStateKey, defaultValue?: T): T | undefined { const value = this.stateCache.get(key) as T | undefined return value !== undefined ? value : (defaultValue as T | undefined) } - updateGlobalState(key: string, value: T): Thenable { + updateGlobalState(key: GlobalStateKey, value: T) { this.stateCache.set(key, value) return this.originalContext.globalState.update(key, value) } - getSecret(key: string): string | undefined { + getSecret(key: SecretKey) { return this.secretCache.get(key) } - storeSecret(key: string, value?: string): Thenable { - // Update cache + storeSecret(key: SecretKey, value?: string) { + // Update cache. this.secretCache.set(key, value) - // Write directly to context - if (value === undefined) { - return this.originalContext.secrets.delete(key) - } else { - return this.originalContext.secrets.store(key, value) - } + + // Write directly to context. + return value === undefined + ? this.originalContext.secrets.delete(key) + : this.originalContext.secrets.store(key, value) } /** * Set a value in either secrets or global state based on key type. @@ -101,17 +109,15 @@ export class ContextProxy { * @param value The value to set * @returns A promise that resolves when the operation completes */ - setValue(key: string, value: any): Thenable { - if (SECRET_KEYS.includes(key as any)) { + setValue(key: ConfigurationKey, value: any) { + if (isSecretKey(key)) { return this.storeSecret(key, value) - } - - if (GLOBAL_STATE_KEYS.includes(key as any)) { + } else if (isGlobalStateKey(key)) { + return this.updateGlobalState(key, value) + } else { + logger.warn(`Unknown key: ${key}. Storing as global state.`) return this.updateGlobalState(key, value) } - - logger.warn(`Unknown key: ${key}. Storing as global state.`) - return this.updateGlobalState(key, value) } /** @@ -120,14 +126,14 @@ export class ContextProxy { * @param values An object containing key-value pairs to set * @returns A promise that resolves when all operations complete */ - async setValues(values: Record): Promise { + async setValues(values: Partial) { const promises: Thenable[] = [] for (const [key, value] of Object.entries(values)) { - promises.push(this.setValue(key, value)) + promises.push(this.setValue(key as ConfigurationKey, value)) } - return Promise.all(promises) + await Promise.all(promises) } /** @@ -135,23 +141,22 @@ export class ContextProxy { * This clears all data from both the in-memory caches and the VSCode storage. * @returns A promise that resolves when all reset operations are complete */ - async resetAllState(): Promise { + async resetAllState() { // Clear in-memory caches this.stateCache.clear() this.secretCache.clear() - // Reset all global state values to undefined + // Reset all global state values to undefined. const stateResetPromises = GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined), ) - // Delete all secrets + // Delete all secrets. const secretResetPromises = SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)) - // Wait for all reset operations to complete + // Wait for all reset operations to complete. await Promise.all([...stateResetPromises, ...secretResetPromises]) - this.initializeStateCache() - this.initializeSecretCache() + this.initialize() } } diff --git a/src/core/prompts/sections/modes.ts b/src/core/prompts/sections/modes.ts index 788c7d7ebe3..78b94ec9e76 100644 --- a/src/core/prompts/sections/modes.ts +++ b/src/core/prompts/sections/modes.ts @@ -12,7 +12,7 @@ export async function getModesSection(context: vscode.ExtensionContext): Promise const allModes = await getAllModesWithPrompts(context) // Get enableCustomModeCreation setting from extension state - const shouldEnableCustomModeCreation = await context.globalState.get("enableCustomModeCreation") ?? true + const shouldEnableCustomModeCreation = (await context.globalState.get("enableCustomModeCreation")) ?? true let modesContent = `==== diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index b41b973f11f..7a61e1698d2 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -6,7 +6,6 @@ import os from "os" import pWaitFor from "p-wait-for" import * as path from "path" import * as vscode from "vscode" -import simpleGit from "simple-git" import { setPanel } from "../../activate/registerCommands" import { ApiConfiguration, ApiProvider, ModelInfo, API_CONFIG_KEYS } from "../../shared/api" @@ -14,7 +13,13 @@ import { CheckpointStorage } from "../../shared/checkpoints" import { findLast } from "../../shared/array" import { CustomSupportPrompts, supportPrompt } from "../../shared/support-prompt" import { GlobalFileNames } from "../../shared/globalFileNames" -import { SecretKey, GlobalStateKey, SECRET_KEYS, GLOBAL_STATE_KEYS } from "../../shared/globalState" +import { + SecretKey, + GlobalStateKey, + SECRET_KEYS, + GLOBAL_STATE_KEYS, + ConfigurationValues, +} from "../../shared/globalState" import { HistoryItem } from "../../shared/HistoryItem" import { ApiConfigMeta, ExtensionMessage } from "../../shared/ExtensionMessage" import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage" @@ -387,6 +392,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) { this.outputChannel.appendLine("Resolving webview view") + + if (!this.contextProxy.isInitialized) { + await this.contextProxy.initialize() + } + this.view = webviewView // Set panel reference according to webview type @@ -2013,18 +2023,19 @@ export class ClineProvider implements vscode.WebviewViewProvider { } private async updateApiConfiguration(apiConfiguration: ApiConfiguration) { - // Update mode's default config + // Update mode's default config. const { mode } = await this.getState() + if (mode) { const currentApiConfigName = await this.getGlobalState("currentApiConfigName") const listApiConfig = await this.configManager.listConfig() const config = listApiConfig?.find((c) => c.name === currentApiConfigName) + if (config?.id) { await this.configManager.setModeConfig(mode, config.id) } } - // Use the new setValues method to handle routing values to secrets or global state await this.contextProxy.setValues(apiConfiguration) if (this.getCurrentCline()) { @@ -2620,11 +2631,11 @@ export class ClineProvider implements vscode.WebviewViewProvider { // global - async updateGlobalState(key: GlobalStateKey, value: any) { + public async updateGlobalState(key: GlobalStateKey, value: any) { await this.contextProxy.updateGlobalState(key, value) } - async getGlobalState(key: GlobalStateKey) { + public async getGlobalState(key: GlobalStateKey) { return await this.contextProxy.getGlobalState(key) } @@ -2638,6 +2649,12 @@ export class ClineProvider implements vscode.WebviewViewProvider { return await this.contextProxy.getSecret(key) } + // global + secret + + public async setValues(values: Partial) { + await this.contextProxy.setValues(values) + } + // dev async resetState() { diff --git a/src/core/webview/__tests__/ClineProvider.test.ts b/src/core/webview/__tests__/ClineProvider.test.ts index be77b13c7a7..177373ef1eb 100644 --- a/src/core/webview/__tests__/ClineProvider.test.ts +++ b/src/core/webview/__tests__/ClineProvider.test.ts @@ -19,6 +19,8 @@ jest.mock("../../contextProxy", () => { return { ContextProxy: jest.fn().mockImplementation((context) => ({ originalContext: context, + isInitialized: true, + initialize: jest.fn(), extensionUri: context.extensionUri, extensionPath: context.extensionPath, globalStorageUri: context.globalStorageUri, diff --git a/src/exports/README.md b/src/exports/README.md index 0554580836c..36b7c23d555 100644 --- a/src/exports/README.md +++ b/src/exports/README.md @@ -19,13 +19,6 @@ if (!api) { throw new Error("API is not available") } -// Set custom instructions. -await api.setCustomInstructions("Talk like a pirate") - -// Get custom instructions. -const instructions = await api.getCustomInstructions() -console.log("Current custom instructions:", instructions) - // Start a new task with an initial message. await api.startNewTask("Hello, Roo Code API! Let's make a new project...") diff --git a/src/exports/roo-code.d.ts b/src/exports/roo-code.d.ts index e310fef6a7a..04f6bdd7423 100644 --- a/src/exports/roo-code.d.ts +++ b/src/exports/roo-code.d.ts @@ -1,16 +1,4 @@ export interface RooCodeAPI { - /** - * Sets the custom instructions in the global storage. - * @param value The custom instructions to be saved. - */ - setCustomInstructions(value: string): Promise - - /** - * Retrieves the custom instructions from the global storage. - * @returns The saved custom instructions, or undefined if not set. - */ - getCustomInstructions(): Promise - /** * Starts a new task with an optional initial message and images. * @param task Optional initial task message. @@ -18,6 +6,11 @@ export interface RooCodeAPI { */ startNewTask(task?: string, images?: string[]): Promise + /** + * Cancels the current task. + */ + cancelTask(): Promise + /** * Sends a message to the current task. * @param message Optional message to send. @@ -36,9 +29,20 @@ export interface RooCodeAPI { pressSecondaryButton(): Promise /** - * The sidebar provider instance. + * Sets the configuration for the current task. + * @param values An object containing key-value pairs to set. + */ + setConfiguration(values: Partial): Promise + + /** + * Returns true if the API is ready to use. + */ + isReady(): boolean + + /** + * Returns the messages from the current task. */ - sidebarProvider: ClineProvider + getMessages(): ClineMessage[] } export type ClineAsk = @@ -95,84 +99,106 @@ export interface ClineMessage { progressStatus?: ToolProgressStatus } -export interface ClineProvider { - readonly context: vscode.ExtensionContext - readonly viewLaunched: boolean - readonly messages: ClineMessage[] - - /** - * Resolves the webview view for the provider - * @param webviewView The webview view or panel to resolve - */ - resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel): Promise - - /** - * Initializes Cline with a task - */ - initClineWithTask(task?: string, images?: string[]): Promise - - /** - * Initializes Cline with a history item - */ - initClineWithHistoryItem(historyItem: HistoryItem): Promise - - /** - * Posts a message to the webview - */ - postMessageToWebview(message: ExtensionMessage): Promise - - /** - * Handles mode switching - */ - handleModeSwitch(newMode: Mode): Promise - - /** - * Updates custom instructions - */ - updateCustomInstructions(instructions?: string): Promise - - /** - * Cancels the current task - */ - cancelTask(): Promise - - /** - * Gets the current state - */ - getState(): Promise - - /** - * Updates a value in the global state - * @param key The key to update - * @param value The value to set - */ - updateGlobalState(key: GlobalStateKey, value: any): Promise - - /** - * Gets a value from the global state - * @param key The key to get - */ - getGlobalState(key: GlobalStateKey): Promise - - /** - * Stores a secret value in secure storage - * @param key The key to store the secret under - * @param value The secret value to store, or undefined to remove the secret - */ - storeSecret(key: SecretKey, value?: string): Promise - - /** - * Resets the state - */ - resetState(): Promise - - /** - * Logs a message - */ - log(message: string): void - - /** - * Disposes of the provider - */ - dispose(): Promise -} +export type SecretKey = + | "apiKey" + | "glamaApiKey" + | "openRouterApiKey" + | "awsAccessKey" + | "awsSecretKey" + | "awsSessionToken" + | "openAiApiKey" + | "geminiApiKey" + | "openAiNativeApiKey" + | "deepSeekApiKey" + | "mistralApiKey" + | "unboundApiKey" + | "requestyApiKey" + +export type GlobalStateKey = + | "apiProvider" + | "apiModelId" + | "glamaModelId" + | "glamaModelInfo" + | "awsRegion" + | "awsUseCrossRegionInference" + | "awsProfile" + | "awsUseProfile" + | "awsCustomArn" + | "vertexKeyFile" + | "vertexJsonCredentials" + | "vertexProjectId" + | "vertexRegion" + | "lastShownAnnouncementId" + | "customInstructions" + | "alwaysAllowReadOnly" + | "alwaysAllowWrite" + | "alwaysAllowExecute" + | "alwaysAllowBrowser" + | "alwaysAllowMcp" + | "alwaysAllowModeSwitch" + | "alwaysAllowSubtasks" + | "taskHistory" + | "openAiBaseUrl" + | "openAiModelId" + | "openAiCustomModelInfo" + | "openAiUseAzure" + | "ollamaModelId" + | "ollamaBaseUrl" + | "lmStudioModelId" + | "lmStudioBaseUrl" + | "anthropicBaseUrl" + | "modelMaxThinkingTokens" + | "azureApiVersion" + | "openAiStreamingEnabled" + | "openRouterModelId" + | "openRouterModelInfo" + | "openRouterBaseUrl" + | "openRouterUseMiddleOutTransform" + | "allowedCommands" + | "soundEnabled" + | "soundVolume" + | "diffEnabled" + | "enableCheckpoints" + | "checkpointStorage" + | "browserViewportSize" + | "screenshotQuality" + | "remoteBrowserHost" + | "fuzzyMatchThreshold" + | "preferredLanguage" // Language setting for Cline's communication + | "writeDelayMs" + | "terminalOutputLineLimit" + | "mcpEnabled" + | "enableMcpServerCreation" + | "alwaysApproveResubmit" + | "requestDelaySeconds" + | "rateLimitSeconds" + | "currentApiConfigName" + | "listApiConfigMeta" + | "vsCodeLmModelSelector" + | "mode" + | "modeApiConfigs" + | "customModePrompts" + | "customSupportPrompts" + | "enhancementApiConfigId" + | "experiments" // Map of experiment IDs to their enabled state + | "autoApprovalEnabled" + | "enableCustomModeCreation" // Enable the ability for Roo to create custom modes + | "customModes" // Array of custom modes + | "unboundModelId" + | "requestyModelId" + | "requestyModelInfo" + | "unboundModelInfo" + | "modelTemperature" + | "modelMaxTokens" + | "mistralCodestralUrl" + | "maxOpenTabsContext" + | "browserToolEnabled" + | "lmStudioSpeculativeDecodingEnabled" + | "lmStudioDraftModelId" + | "telemetrySetting" + | "showRooIgnoredFiles" + | "remoteBrowserEnabled" + +export type ConfigurationKey = GlobalStateKey | SecretKey + +export type ConfigurationValues = Record diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index ba63c16eb67..36225625066 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -7,7 +7,7 @@ import { CustomSupportPrompts } from "./support-prompt" import { ExperimentId } from "./experiments" import { CheckpointStorage } from "./checkpoints" import { TelemetrySetting } from "./TelemetrySetting" -import { ClineMessage, ClineAsk, ClineSay } from "../exports/roo-code" +import type { ClineMessage, ClineAsk, ClineSay } from "../exports/roo-code" export interface LanguageModelChatSelector { vendor?: string diff --git a/src/shared/globalState.ts b/src/shared/globalState.ts index d03f5f20a45..a3dbb2b710b 100644 --- a/src/shared/globalState.ts +++ b/src/shared/globalState.ts @@ -1,4 +1,17 @@ -// Define the array first with 'as const' to create a readonly tuple type +import type { SecretKey, GlobalStateKey, ConfigurationKey, ConfigurationValues } from "../exports/roo-code" + +export type { SecretKey, GlobalStateKey, ConfigurationKey, ConfigurationValues } + +/** + * For convenience we'd like the `RooCodeAPI` to define `SecretKey` and `GlobalStateKey`, + * but since it is a type definition file we can't export constants without some + * annoyances. In order to achieve proper type safety without using constants as + * in the type definition we use this clever Check<>Exhaustiveness pattern. + * If you extend the `SecretKey` or `GlobalStateKey` types, you will need to + * update the `SECRET_KEYS` and `GLOBAL_STATE_KEYS` arrays to include the new + * keys or a type error will be thrown. + */ + export const SECRET_KEYS = [ "apiKey", "glamaApiKey", @@ -15,10 +28,10 @@ export const SECRET_KEYS = [ "requestyApiKey", ] as const -// Derive the type from the array - creates a union of string literals -export type SecretKey = (typeof SECRET_KEYS)[number] +type CheckSecretKeysExhaustiveness = Exclude extends never ? true : false + +const _checkSecretKeysExhaustiveness: CheckSecretKeysExhaustiveness = true -// Define the array first with 'as const' to create a readonly tuple type export const GLOBAL_STATE_KEYS = [ "apiProvider", "apiModelId", @@ -69,7 +82,7 @@ export const GLOBAL_STATE_KEYS = [ "screenshotQuality", "remoteBrowserHost", "fuzzyMatchThreshold", - "preferredLanguage", // Language setting for Cline's communication + "preferredLanguage", // Language setting for Cline's communication. "writeDelayMs", "terminalOutputLineLimit", "mcpEnabled", @@ -85,10 +98,10 @@ export const GLOBAL_STATE_KEYS = [ "customModePrompts", "customSupportPrompts", "enhancementApiConfigId", - "experiments", // Map of experiment IDs to their enabled state + "experiments", // Map of experiment IDs to their enabled state. "autoApprovalEnabled", - "enableCustomModeCreation", // Enable the ability for Roo to create custom modes - "customModes", // Array of custom modes + "enableCustomModeCreation", // Enable the ability for Roo to create custom modes. + "customModes", // Array of custom modes. "unboundModelId", "requestyModelId", "requestyModelInfo", @@ -105,5 +118,12 @@ export const GLOBAL_STATE_KEYS = [ "remoteBrowserEnabled", ] as const -// Derive the type from the array - creates a union of string literals -export type GlobalStateKey = (typeof GLOBAL_STATE_KEYS)[number] +type CheckGlobalStateKeysExhaustiveness = + Exclude extends never ? true : false + +const _checkGlobalStateKeysExhaustiveness: CheckGlobalStateKeysExhaustiveness = true + +export const isSecretKey = (key: string): key is SecretKey => SECRET_KEYS.includes(key as SecretKey) + +export const isGlobalStateKey = (key: string): key is GlobalStateKey => + GLOBAL_STATE_KEYS.includes(key as GlobalStateKey)