mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Merge remote-tracking branch 'origin/main' into feature/add_sse_mcp
This commit is contained in:
commit
29aa464ce3
41 changed files with 2373 additions and 377 deletions
5
.changeset/tidy-queens-pay.md
Normal file
5
.changeset/tidy-queens-pay.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"roo-cline": patch
|
||||
---
|
||||
|
||||
Fix usage tracking for SiliconFlow etc
|
||||
|
|
@ -19,5 +19,5 @@
|
|||
"no-throw-literal": "warn",
|
||||
"semi": "off"
|
||||
},
|
||||
"ignorePatterns": ["out", "dist", "**/*.d.ts"]
|
||||
"ignorePatterns": ["out", "dist", "**/*.d.ts", "!roo-code.d.ts"]
|
||||
}
|
||||
|
|
|
|||
2
.github/workflows/changeset-release.yml
vendored
2
.github/workflows/changeset-release.yml
vendored
|
|
@ -37,7 +37,7 @@ jobs:
|
|||
cache: 'npm'
|
||||
|
||||
- name: Install Dependencies
|
||||
run: npm run install:ci
|
||||
run: npm run install:all
|
||||
|
||||
# Check if there are any new changesets to process
|
||||
- name: Check for changesets
|
||||
|
|
|
|||
10
.github/workflows/code-qa.yml
vendored
10
.github/workflows/code-qa.yml
vendored
|
|
@ -20,7 +20,7 @@ jobs:
|
|||
node-version: '18'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm run install:ci
|
||||
run: npm run install:all
|
||||
- name: Compile
|
||||
run: npm run compile
|
||||
- name: Check types
|
||||
|
|
@ -39,7 +39,7 @@ jobs:
|
|||
node-version: '18'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm run install:ci
|
||||
run: npm run install:all
|
||||
- name: Run knip checks
|
||||
run: npm run knip
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ jobs:
|
|||
node-version: '18'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm run install:ci
|
||||
run: npm run install:all
|
||||
- name: Run unit tests
|
||||
run: npx jest --silent
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ jobs:
|
|||
node-version: '18'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm run install:ci
|
||||
run: npm run install:all
|
||||
- name: Run unit tests
|
||||
working-directory: webview-ui
|
||||
run: npx jest --silent
|
||||
|
|
@ -109,7 +109,7 @@ jobs:
|
|||
node-version: '18'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm run install:ci
|
||||
run: npm run install:all
|
||||
- name: Create env.integration file
|
||||
working-directory: e2e
|
||||
run: echo "OPENROUTER_API_KEY=${{ secrets.OPENROUTER_API_KEY }}" > .env.integration
|
||||
|
|
|
|||
10
.github/workflows/marketplace-publish.yml
vendored
10
.github/workflows/marketplace-publish.yml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
publish-extension:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Required for pushing tags
|
||||
contents: write # Required for pushing tags.
|
||||
if: >
|
||||
( github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.base.ref == 'main' &&
|
||||
|
|
@ -33,13 +33,9 @@ jobs:
|
|||
- name: Install Dependencies
|
||||
run: |
|
||||
npm install -g vsce ovsx
|
||||
npm run install:ci
|
||||
|
||||
npm run install:all
|
||||
- name: Create .env file
|
||||
run: |
|
||||
echo "# PostHog API Keys for telemetry" > .env
|
||||
echo "POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }}" >> .env
|
||||
|
||||
run: echo "POSTHOG_API_KEY=${{ secrets.POSTHOG_API_KEY }}" >> .env
|
||||
- name: Package Extension
|
||||
run: |
|
||||
current_package_version=$(node -p "require('./package.json').version")
|
||||
|
|
|
|||
53
README.md
53
README.md
|
|
@ -115,37 +115,40 @@ Make Roo Code work your way with:
|
|||
## Local Setup & Development
|
||||
|
||||
1. **Clone** the repo:
|
||||
```bash
|
||||
git clone https://github.com/RooVetGit/Roo-Code.git
|
||||
```
|
||||
|
||||
```sh
|
||||
git clone https://github.com/RooVetGit/Roo-Code.git
|
||||
```
|
||||
|
||||
2. **Install dependencies**:
|
||||
```bash
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
if that fails, try:
|
||||
```bash
|
||||
npm run install:ci
|
||||
```
|
||||
```sh
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
3. **Build** the extension:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
- A `.vsix` file will appear in the `bin/` directory.
|
||||
4. **Install** the `.vsix` manually if desired:
|
||||
```bash
|
||||
code --install-extension bin/roo-code-4.0.0.vsix
|
||||
```
|
||||
5. **Start the webview (Vite/React app with HMR)**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
6. **Debug**:
|
||||
- Press `F5` (or **Run** → **Start Debugging**) in VSCode to open a new session with Roo Code loaded.
|
||||
3. **Start the webview (Vite/React app with HMR)**:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. **Debug**:
|
||||
Press `F5` (or **Run** → **Start Debugging**) in VSCode to open a new session with Roo Code loaded.
|
||||
|
||||
Changes to the webview will appear immediately. Changes to the core extension will require a restart of the extension host.
|
||||
|
||||
Alternatively you can build a .vsix and install it directly in VSCode:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
A `.vsix` file will appear in the `bin/` directory which can be installed with:
|
||||
|
||||
```sh
|
||||
code --install-extension bin/roo-cline-<version>.vsix
|
||||
```
|
||||
|
||||
We use [changesets](https://github.com/changesets/changesets) for versioning and publishing. Check our `CHANGELOG.md` for release notes.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -58,9 +58,9 @@ The following global objects are available in tests:
|
|||
|
||||
```typescript
|
||||
declare global {
|
||||
var api: ClineAPI
|
||||
var api: RooCodeAPI
|
||||
var provider: ClineProvider
|
||||
var extension: vscode.Extension<ClineAPI>
|
||||
var extension: vscode.Extension<RooCodeAPI>
|
||||
var panel: vscode.WebviewPanel
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import * as path from "path"
|
||||
import Mocha from "mocha"
|
||||
import { glob } from "glob"
|
||||
import { ClineAPI, ClineProvider } from "../../../src/exports/cline"
|
||||
import { RooCodeAPI, ClineProvider } from "../../../src/exports/roo-code"
|
||||
import * as vscode from "vscode"
|
||||
|
||||
declare global {
|
||||
var api: ClineAPI
|
||||
var api: RooCodeAPI
|
||||
var provider: ClineProvider
|
||||
var extension: vscode.Extension<ClineAPI> | undefined
|
||||
var extension: vscode.Extension<RooCodeAPI> | undefined
|
||||
var panel: vscode.WebviewPanel | undefined
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,6 @@
|
|||
"useUnknownInCatchVariables": false,
|
||||
"outDir": "out"
|
||||
},
|
||||
"include": ["src", "../src/exports/cline.d.ts"],
|
||||
"include": ["src", "../src/exports/roo-code.d.ts"],
|
||||
"exclude": [".vscode-test", "**/node_modules/**", "out"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -230,8 +230,8 @@
|
|||
"build": "npm run build:webview && npm run vsix",
|
||||
"build:webview": "cd webview-ui && npm run build",
|
||||
"compile": "tsc -p . --outDir out && node esbuild.js",
|
||||
"install:all": "npm-run-all -p install-*",
|
||||
"install:ci": "npm install npm-run-all && npm run install:all",
|
||||
"install:all": "npm install npm-run-all && npm run install:_all",
|
||||
"install:_all": "npm-run-all -p install-*",
|
||||
"install-extension": "npm install",
|
||||
"install-webview-ui": "cd webview-ui && npm install",
|
||||
"install-e2e": "cd e2e && npm install",
|
||||
|
|
@ -246,7 +246,9 @@
|
|||
"package": "npm run build:webview && npm run check-types && npm run lint && node esbuild.js --production",
|
||||
"pretest": "npm run compile",
|
||||
"dev": "cd webview-ui && npm run dev",
|
||||
"test": "jest && cd webview-ui && npm run test",
|
||||
"test": "npm-run-all -p test:*",
|
||||
"test:extension": "jest",
|
||||
"test:webview": "cd webview-ui && npm run test",
|
||||
"prepare": "husky",
|
||||
"publish:marketplace": "vsce publish && ovsx publish",
|
||||
"publish": "npm run build && changeset publish && npm install --package-lock-only",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import * as vscode from "vscode"
|
||||
import { ClineProvider } from "../core/webview/ClineProvider"
|
||||
import { ClineAPI } from "./cline"
|
||||
|
||||
export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvider: ClineProvider): ClineAPI {
|
||||
const api: ClineAPI = {
|
||||
import { ClineProvider } from "../core/webview/ClineProvider"
|
||||
|
||||
import { RooCodeAPI } from "../exports/roo-code"
|
||||
|
||||
export function createRooCodeAPI(outputChannel: vscode.OutputChannel, sidebarProvider: ClineProvider): RooCodeAPI {
|
||||
return {
|
||||
setCustomInstructions: async (value: string) => {
|
||||
await sidebarProvider.updateCustomInstructions(value)
|
||||
outputChannel.appendLine("Custom instructions set")
|
||||
|
|
@ -24,6 +26,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
|
|||
text: task,
|
||||
images: images,
|
||||
})
|
||||
|
||||
outputChannel.appendLine(
|
||||
`Task started with message: ${task ? `"${task}"` : "undefined"} and ${images?.length || 0} image(s)`,
|
||||
)
|
||||
|
|
@ -33,6 +36,7 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
|
|||
outputChannel.appendLine(
|
||||
`Sending message: ${message ? `"${message}"` : "undefined"} with ${images?.length || 0} image(s)`,
|
||||
)
|
||||
|
||||
await sidebarProvider.postMessageToWebview({
|
||||
type: "invoke",
|
||||
invoke: "sendMessage",
|
||||
|
|
@ -43,22 +47,14 @@ export function createClineAPI(outputChannel: vscode.OutputChannel, sidebarProvi
|
|||
|
||||
pressPrimaryButton: async () => {
|
||||
outputChannel.appendLine("Pressing primary button")
|
||||
await sidebarProvider.postMessageToWebview({
|
||||
type: "invoke",
|
||||
invoke: "primaryButtonClick",
|
||||
})
|
||||
await sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "primaryButtonClick" })
|
||||
},
|
||||
|
||||
pressSecondaryButton: async () => {
|
||||
outputChannel.appendLine("Pressing secondary button")
|
||||
await sidebarProvider.postMessageToWebview({
|
||||
type: "invoke",
|
||||
invoke: "secondaryButtonClick",
|
||||
})
|
||||
await sidebarProvider.postMessageToWebview({ type: "invoke", invoke: "secondaryButtonClick" })
|
||||
},
|
||||
|
||||
sidebarProvider: sidebarProvider,
|
||||
}
|
||||
|
||||
return api
|
||||
}
|
||||
26
src/activate/humanRelay.ts
Normal file
26
src/activate/humanRelay.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Callback mapping of human relay response.
|
||||
const humanRelayCallbacks = new Map<string, (response: string | undefined) => void>()
|
||||
|
||||
/**
|
||||
* Register a callback function for human relay response.
|
||||
* @param requestId
|
||||
* @param callback
|
||||
*/
|
||||
export const registerHumanRelayCallback = (requestId: string, callback: (response: string | undefined) => void) =>
|
||||
humanRelayCallbacks.set(requestId, callback)
|
||||
|
||||
export const unregisterHumanRelayCallback = (requestId: string) => humanRelayCallbacks.delete(requestId)
|
||||
|
||||
export const handleHumanRelayResponse = (response: { requestId: string; text?: string; cancelled?: boolean }) => {
|
||||
const callback = humanRelayCallbacks.get(response.requestId)
|
||||
|
||||
if (callback) {
|
||||
if (response.cancelled) {
|
||||
callback(undefined)
|
||||
} else {
|
||||
callback(response.text)
|
||||
}
|
||||
|
||||
humanRelayCallbacks.delete(response.requestId)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export { handleUri } from "./handleUri"
|
||||
export { registerCommands } from "./registerCommands"
|
||||
export { registerCodeActions } from "./registerCodeActions"
|
||||
export { createRooCodeAPI } from "./createRooCodeAPI"
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import delay from "delay"
|
|||
|
||||
import { ClineProvider } from "../core/webview/ClineProvider"
|
||||
|
||||
import { registerHumanRelayCallback, unregisterHumanRelayCallback, handleHumanRelayResponse } from "./humanRelay"
|
||||
|
||||
// Store panel references in both modes
|
||||
let sidebarPanel: vscode.WebviewView | undefined = undefined
|
||||
let tabPanel: vscode.WebviewPanel | undefined = undefined
|
||||
|
|
@ -43,22 +45,6 @@ export const registerCommands = (options: RegisterCommandOptions) => {
|
|||
for (const [command, callback] of Object.entries(getCommandsMap(options))) {
|
||||
context.subscriptions.push(vscode.commands.registerCommand(command, callback))
|
||||
}
|
||||
|
||||
// Human Relay Dialog Command
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"roo-cline.showHumanRelayDialog",
|
||||
(params: { requestId: string; promptText: string }) => {
|
||||
if (getPanel()) {
|
||||
getPanel()?.webview.postMessage({
|
||||
type: "showHumanRelayDialog",
|
||||
requestId: params.requestId,
|
||||
promptText: params.promptText,
|
||||
})
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOptions) => {
|
||||
|
|
@ -85,6 +71,20 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
|
|||
"roo-cline.helpButtonClicked": () => {
|
||||
vscode.env.openExternal(vscode.Uri.parse("https://docs.roocode.com"))
|
||||
},
|
||||
"roo-cline.showHumanRelayDialog": (params: { requestId: string; promptText: string }) => {
|
||||
const panel = getPanel()
|
||||
|
||||
if (panel) {
|
||||
panel?.webview.postMessage({
|
||||
type: "showHumanRelayDialog",
|
||||
requestId: params.requestId,
|
||||
promptText: params.promptText,
|
||||
})
|
||||
}
|
||||
},
|
||||
"roo-cline.registerHumanRelayCallback": registerHumanRelayCallback,
|
||||
"roo-cline.unregisterHumanRelayCallback": unregisterHumanRelayCallback,
|
||||
"roo-cline.handleHumanRelayResponse": handleHumanRelayResponse,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
75
src/api/providers/__tests__/bedrock-custom-arn.test.ts
Normal file
75
src/api/providers/__tests__/bedrock-custom-arn.test.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { AwsBedrockHandler } from "../bedrock"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
|
||||
// Mock the AWS SDK
|
||||
jest.mock("@aws-sdk/client-bedrock-runtime", () => {
|
||||
const mockSend = jest.fn().mockImplementation(() => {
|
||||
return Promise.resolve({
|
||||
output: new TextEncoder().encode(JSON.stringify({ content: "Test response" })),
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
BedrockRuntimeClient: jest.fn().mockImplementation(() => ({
|
||||
send: mockSend,
|
||||
config: {
|
||||
region: "us-east-1",
|
||||
},
|
||||
})),
|
||||
ConverseCommand: jest.fn(),
|
||||
ConverseStreamCommand: jest.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe("AwsBedrockHandler with custom ARN", () => {
|
||||
const mockOptions: ApiHandlerOptions = {
|
||||
apiModelId: "custom-arn",
|
||||
awsCustomArn: "arn:aws:bedrock:us-east-1:123456789012:foundation-model/anthropic.claude-3-sonnet-20240229-v1:0",
|
||||
awsRegion: "us-east-1",
|
||||
}
|
||||
|
||||
it("should use the custom ARN as the model ID", async () => {
|
||||
const handler = new AwsBedrockHandler(mockOptions)
|
||||
const model = handler.getModel()
|
||||
|
||||
expect(model.id).toBe(mockOptions.awsCustomArn)
|
||||
expect(model.info).toHaveProperty("maxTokens")
|
||||
expect(model.info).toHaveProperty("contextWindow")
|
||||
expect(model.info).toHaveProperty("supportsPromptCache")
|
||||
})
|
||||
|
||||
it("should extract region from ARN and use it for client configuration", () => {
|
||||
// Test with matching region
|
||||
const handler1 = new AwsBedrockHandler(mockOptions)
|
||||
expect((handler1 as any).client.config.region).toBe("us-east-1")
|
||||
|
||||
// Test with mismatched region
|
||||
const mismatchOptions = {
|
||||
...mockOptions,
|
||||
awsRegion: "us-west-2",
|
||||
}
|
||||
const handler2 = new AwsBedrockHandler(mismatchOptions)
|
||||
// Should use the ARN region, not the provided region
|
||||
expect((handler2 as any).client.config.region).toBe("us-east-1")
|
||||
})
|
||||
|
||||
it("should validate ARN format", async () => {
|
||||
// Invalid ARN format
|
||||
const invalidOptions = {
|
||||
...mockOptions,
|
||||
awsCustomArn: "invalid-arn-format",
|
||||
}
|
||||
|
||||
const handler = new AwsBedrockHandler(invalidOptions)
|
||||
|
||||
// completePrompt should throw an error for invalid ARN
|
||||
await expect(handler.completePrompt("test")).rejects.toThrow("Invalid ARN format")
|
||||
})
|
||||
|
||||
it("should complete a prompt successfully with valid ARN", async () => {
|
||||
const handler = new AwsBedrockHandler(mockOptions)
|
||||
const response = await handler.completePrompt("test prompt")
|
||||
|
||||
expect(response).toBe("Test response")
|
||||
})
|
||||
})
|
||||
|
|
@ -315,5 +315,34 @@ describe("AwsBedrockHandler", () => {
|
|||
expect(modelInfo.info.maxTokens).toBe(5000)
|
||||
expect(modelInfo.info.contextWindow).toBe(128_000)
|
||||
})
|
||||
|
||||
it("should use custom ARN when provided", () => {
|
||||
const customArnHandler = new AwsBedrockHandler({
|
||||
apiModelId: "anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||
awsAccessKey: "test-access-key",
|
||||
awsSecretKey: "test-secret-key",
|
||||
awsRegion: "us-east-1",
|
||||
awsCustomArn: "arn:aws:bedrock:us-east-1::foundation-model/custom-model",
|
||||
})
|
||||
const modelInfo = customArnHandler.getModel()
|
||||
expect(modelInfo.id).toBe("arn:aws:bedrock:us-east-1::foundation-model/custom-model")
|
||||
expect(modelInfo.info.maxTokens).toBe(4096)
|
||||
expect(modelInfo.info.contextWindow).toBe(128_000)
|
||||
expect(modelInfo.info.supportsPromptCache).toBe(false)
|
||||
})
|
||||
|
||||
it("should use default model when custom-arn is selected but no ARN is provided", () => {
|
||||
const customArnHandler = new AwsBedrockHandler({
|
||||
apiModelId: "custom-arn",
|
||||
awsAccessKey: "test-access-key",
|
||||
awsSecretKey: "test-secret-key",
|
||||
awsRegion: "us-east-1",
|
||||
// No awsCustomArn provided
|
||||
})
|
||||
const modelInfo = customArnHandler.getModel()
|
||||
// Should fall back to default model
|
||||
expect(modelInfo.id).not.toBe("custom-arn")
|
||||
expect(modelInfo.info).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
235
src/api/providers/__tests__/openai-usage-tracking.test.ts
Normal file
235
src/api/providers/__tests__/openai-usage-tracking.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { OpenAiHandler } from "../openai"
|
||||
import { ApiHandlerOptions } from "../../../shared/api"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
// Mock OpenAI client with multiple chunks that contain usage data
|
||||
const mockCreate = jest.fn()
|
||||
jest.mock("openai", () => {
|
||||
return {
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: mockCreate.mockImplementation(async (options) => {
|
||||
if (!options.stream) {
|
||||
return {
|
||||
id: "test-completion",
|
||||
choices: [
|
||||
{
|
||||
message: { role: "assistant", content: "Test response", refusal: null },
|
||||
finish_reason: "stop",
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Return a stream with multiple chunks that include usage metrics
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
// First chunk with partial usage
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "Test " },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 12,
|
||||
},
|
||||
}
|
||||
|
||||
// Second chunk with updated usage
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: { content: "response" },
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 4,
|
||||
total_tokens: 14,
|
||||
},
|
||||
}
|
||||
|
||||
// Final chunk with complete usage
|
||||
yield {
|
||||
choices: [
|
||||
{
|
||||
delta: {},
|
||||
index: 0,
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}),
|
||||
},
|
||||
},
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
describe("OpenAiHandler with usage tracking fix", () => {
|
||||
let handler: OpenAiHandler
|
||||
let mockOptions: ApiHandlerOptions
|
||||
|
||||
beforeEach(() => {
|
||||
mockOptions = {
|
||||
openAiApiKey: "test-api-key",
|
||||
openAiModelId: "gpt-4",
|
||||
openAiBaseUrl: "https://api.openai.com/v1",
|
||||
}
|
||||
handler = new OpenAiHandler(mockOptions)
|
||||
mockCreate.mockClear()
|
||||
})
|
||||
|
||||
describe("usage metrics with streaming", () => {
|
||||
const systemPrompt = "You are a helpful assistant."
|
||||
const messages: Anthropic.Messages.MessageParam[] = [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: "Hello!",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
it("should only yield usage metrics once at the end of the stream", async () => {
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Check we have text chunks
|
||||
const textChunks = chunks.filter((chunk) => chunk.type === "text")
|
||||
expect(textChunks).toHaveLength(2)
|
||||
expect(textChunks[0].text).toBe("Test ")
|
||||
expect(textChunks[1].text).toBe("response")
|
||||
|
||||
// Check we only have one usage chunk and it's the last one
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks).toHaveLength(1)
|
||||
expect(usageChunks[0]).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
|
||||
// Check the usage chunk is the last one reported from the API
|
||||
const lastChunk = chunks[chunks.length - 1]
|
||||
expect(lastChunk.type).toBe("usage")
|
||||
expect(lastChunk.inputTokens).toBe(10)
|
||||
expect(lastChunk.outputTokens).toBe(5)
|
||||
})
|
||||
|
||||
it("should handle case where usage is only in the final chunk", async () => {
|
||||
// Override the mock for this specific test
|
||||
mockCreate.mockImplementationOnce(async (options) => {
|
||||
if (!options.stream) {
|
||||
return {
|
||||
id: "test-completion",
|
||||
choices: [{ message: { role: "assistant", content: "Test response" } }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
// First chunk with no usage
|
||||
yield {
|
||||
choices: [{ delta: { content: "Test " }, index: 0 }],
|
||||
usage: null,
|
||||
}
|
||||
|
||||
// Second chunk with no usage
|
||||
yield {
|
||||
choices: [{ delta: { content: "response" }, index: 0 }],
|
||||
usage: null,
|
||||
}
|
||||
|
||||
// Final chunk with usage data
|
||||
yield {
|
||||
choices: [{ delta: {}, index: 0 }],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Check usage metrics
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks).toHaveLength(1)
|
||||
expect(usageChunks[0]).toEqual({
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
})
|
||||
})
|
||||
|
||||
it("should handle case where no usage is provided", async () => {
|
||||
// Override the mock for this specific test
|
||||
mockCreate.mockImplementationOnce(async (options) => {
|
||||
if (!options.stream) {
|
||||
return {
|
||||
id: "test-completion",
|
||||
choices: [{ message: { role: "assistant", content: "Test response" } }],
|
||||
usage: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield {
|
||||
choices: [{ delta: { content: "Test response" }, index: 0 }],
|
||||
usage: null,
|
||||
}
|
||||
yield {
|
||||
choices: [{ delta: {}, index: 0 }],
|
||||
usage: null,
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage(systemPrompt, messages)
|
||||
const chunks: any[] = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Check we don't have any usage chunks
|
||||
const usageChunks = chunks.filter((chunk) => chunk.type === "usage")
|
||||
expect(usageChunks).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -11,6 +11,47 @@ import { ApiHandlerOptions, BedrockModelId, ModelInfo, bedrockDefaultModelId, be
|
|||
import { ApiStream } from "../transform/stream"
|
||||
import { convertToBedrockConverseMessages } from "../transform/bedrock-converse-format"
|
||||
import { BaseProvider } from "./base-provider"
|
||||
import { logger } from "../../utils/logging"
|
||||
|
||||
/**
|
||||
* Validates an AWS Bedrock ARN format and optionally checks if the region in the ARN matches the provided region
|
||||
* @param arn The ARN string to validate
|
||||
* @param region Optional region to check against the ARN's region
|
||||
* @returns An object with validation results: { isValid, arnRegion, errorMessage }
|
||||
*/
|
||||
function validateBedrockArn(arn: string, region?: string) {
|
||||
// Validate ARN format
|
||||
const arnRegex = /^arn:aws:bedrock:([^:]+):(\d+):(foundation-model|provisioned-model|default-prompt-router)\/(.+)$/
|
||||
const match = arn.match(arnRegex)
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
isValid: false,
|
||||
arnRegion: undefined,
|
||||
errorMessage:
|
||||
"Invalid ARN format. ARN should follow the pattern: arn:aws:bedrock:region:account-id:resource-type/resource-name",
|
||||
}
|
||||
}
|
||||
|
||||
// Extract region from ARN
|
||||
const arnRegion = match[1]
|
||||
|
||||
// Check if region in ARN matches provided region (if specified)
|
||||
if (region && arnRegion !== region) {
|
||||
return {
|
||||
isValid: true,
|
||||
arnRegion,
|
||||
errorMessage: `Warning: 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.`,
|
||||
}
|
||||
}
|
||||
|
||||
// ARN is valid and region matches (or no region was provided to check against)
|
||||
return {
|
||||
isValid: true,
|
||||
arnRegion,
|
||||
errorMessage: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const BEDROCK_DEFAULT_TEMPERATURE = 0.3
|
||||
|
||||
|
|
@ -55,8 +96,31 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
super()
|
||||
this.options = options
|
||||
|
||||
// Extract region from custom ARN if provided
|
||||
let region = this.options.awsRegion || "us-east-1"
|
||||
|
||||
// If using custom ARN, extract region from the ARN
|
||||
if (this.options.awsCustomArn) {
|
||||
const validation = validateBedrockArn(this.options.awsCustomArn, region)
|
||||
|
||||
if (validation.isValid && validation.arnRegion) {
|
||||
// If there's a region mismatch warning, log it and use the ARN region
|
||||
if (validation.errorMessage) {
|
||||
logger.info(
|
||||
`Region mismatch: Selected region is ${region}, but ARN region is ${validation.arnRegion}. Using ARN region.`,
|
||||
{
|
||||
ctx: "bedrock",
|
||||
selectedRegion: region,
|
||||
arnRegion: validation.arnRegion,
|
||||
},
|
||||
)
|
||||
region = validation.arnRegion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const clientConfig: BedrockRuntimeClientConfig = {
|
||||
region: this.options.awsRegion || "us-east-1",
|
||||
region: region,
|
||||
}
|
||||
|
||||
if (this.options.awsUseProfile && this.options.awsProfile) {
|
||||
|
|
@ -81,7 +145,41 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
|
||||
// Handle cross-region inference
|
||||
let modelId: string
|
||||
if (this.options.awsUseCrossRegionInference) {
|
||||
|
||||
// For custom ARNs, use the ARN directly without modification
|
||||
if (this.options.awsCustomArn) {
|
||||
modelId = modelConfig.id
|
||||
|
||||
// Validate ARN format and check region match
|
||||
const clientRegion = this.client.config.region as string
|
||||
const validation = validateBedrockArn(modelId, clientRegion)
|
||||
|
||||
if (!validation.isValid) {
|
||||
logger.error("Invalid ARN format", {
|
||||
ctx: "bedrock",
|
||||
modelId,
|
||||
errorMessage: validation.errorMessage,
|
||||
})
|
||||
yield {
|
||||
type: "text",
|
||||
text: `Error: ${validation.errorMessage}`,
|
||||
}
|
||||
yield { type: "usage", inputTokens: 0, outputTokens: 0 }
|
||||
throw new Error("Invalid ARN format")
|
||||
}
|
||||
|
||||
// Extract region from ARN
|
||||
const arnRegion = validation.arnRegion!
|
||||
|
||||
// Log warning if there's a region mismatch
|
||||
if (validation.errorMessage) {
|
||||
logger.warn(validation.errorMessage, {
|
||||
ctx: "bedrock",
|
||||
arnRegion,
|
||||
clientRegion,
|
||||
})
|
||||
}
|
||||
} else if (this.options.awsUseCrossRegionInference) {
|
||||
let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
|
||||
switch (regionPrefix) {
|
||||
case "us-":
|
||||
|
|
@ -107,7 +205,7 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
messages: formattedMessages,
|
||||
system: [{ text: systemPrompt }],
|
||||
inferenceConfig: {
|
||||
maxTokens: modelConfig.info.maxTokens || 5000,
|
||||
maxTokens: modelConfig.info.maxTokens || 4096,
|
||||
temperature: this.options.modelTemperature ?? BEDROCK_DEFAULT_TEMPERATURE,
|
||||
topP: 0.1,
|
||||
...(this.options.awsUsePromptCache
|
||||
|
|
@ -121,6 +219,16 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
}
|
||||
|
||||
try {
|
||||
// Log the payload for debugging custom ARN issues
|
||||
if (this.options.awsCustomArn) {
|
||||
logger.debug("Using custom ARN for Bedrock request", {
|
||||
ctx: "bedrock",
|
||||
customArn: this.options.awsCustomArn,
|
||||
clientRegion: this.client.config.region,
|
||||
payload: JSON.stringify(payload, null, 2),
|
||||
})
|
||||
}
|
||||
|
||||
const command = new ConverseStreamCommand(payload)
|
||||
const response = await this.client.send(command)
|
||||
|
||||
|
|
@ -134,7 +242,11 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
try {
|
||||
streamEvent = typeof chunk === "string" ? JSON.parse(chunk) : (chunk as unknown as StreamEvent)
|
||||
} catch (e) {
|
||||
console.error("Failed to parse stream event:", e)
|
||||
logger.error("Failed to parse stream event", {
|
||||
ctx: "bedrock",
|
||||
error: e instanceof Error ? e : String(e),
|
||||
chunk: typeof chunk === "string" ? chunk : "binary data",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
|
|
@ -177,39 +289,257 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Bedrock Runtime API Error:", error)
|
||||
// Only access stack if error is an Error object
|
||||
logger.error("Bedrock Runtime API Error", {
|
||||
ctx: "bedrock",
|
||||
error: error instanceof Error ? error : String(error),
|
||||
})
|
||||
|
||||
// Enhanced error handling for custom ARN issues
|
||||
if (this.options.awsCustomArn) {
|
||||
logger.error("Error occurred with custom ARN", {
|
||||
ctx: "bedrock",
|
||||
customArn: this.options.awsCustomArn,
|
||||
})
|
||||
|
||||
// Check for common ARN-related errors
|
||||
if (error instanceof Error) {
|
||||
const errorMessage = error.message.toLowerCase()
|
||||
|
||||
// Access denied errors
|
||||
if (
|
||||
errorMessage.includes("access") &&
|
||||
(errorMessage.includes("model") || errorMessage.includes("denied"))
|
||||
) {
|
||||
logger.error("Permissions issue with custom ARN", {
|
||||
ctx: "bedrock",
|
||||
customArn: this.options.awsCustomArn,
|
||||
errorType: "access_denied",
|
||||
clientRegion: this.client.config.region,
|
||||
})
|
||||
yield {
|
||||
type: "text",
|
||||
text: `Error: You don't have access to the model with the specified ARN. Please verify:
|
||||
|
||||
1. The ARN is correct and points to a valid model
|
||||
2. Your AWS credentials have permission to access this model (check IAM policies)
|
||||
3. The region in the ARN (${this.client.config.region}) matches the region where the model is deployed
|
||||
4. If using a provisioned model, ensure it's active and not in a failed state
|
||||
5. If using a custom model, ensure your account has been granted access to it`,
|
||||
}
|
||||
}
|
||||
// Model not found errors
|
||||
else if (errorMessage.includes("not found") || errorMessage.includes("does not exist")) {
|
||||
logger.error("Invalid ARN or non-existent model", {
|
||||
ctx: "bedrock",
|
||||
customArn: this.options.awsCustomArn,
|
||||
errorType: "not_found",
|
||||
})
|
||||
yield {
|
||||
type: "text",
|
||||
text: `Error: 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
|
||||
4. The resource type is one of: foundation-model, provisioned-model, or default-prompt-router`,
|
||||
}
|
||||
}
|
||||
// Throttling errors
|
||||
else if (
|
||||
errorMessage.includes("throttl") ||
|
||||
errorMessage.includes("rate") ||
|
||||
errorMessage.includes("limit")
|
||||
) {
|
||||
logger.error("Throttling or rate limit issue with Bedrock", {
|
||||
ctx: "bedrock",
|
||||
customArn: this.options.awsCustomArn,
|
||||
errorType: "throttling",
|
||||
})
|
||||
yield {
|
||||
type: "text",
|
||||
text: `Error: 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`,
|
||||
}
|
||||
}
|
||||
// Other errors
|
||||
else {
|
||||
logger.error("Unspecified error with custom ARN", {
|
||||
ctx: "bedrock",
|
||||
customArn: this.options.awsCustomArn,
|
||||
errorStack: error.stack,
|
||||
errorMessage: error.message,
|
||||
})
|
||||
yield {
|
||||
type: "text",
|
||||
text: `Error with custom ARN: ${error.message}
|
||||
|
||||
Please check:
|
||||
1. Your AWS credentials are valid and have the necessary permissions
|
||||
2. The ARN format is correct
|
||||
3. The region in the ARN matches the region where you're making the request`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
yield {
|
||||
type: "text",
|
||||
text: `Unknown error occurred with custom ARN. Please check your AWS credentials and ARN format.`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Standard error handling for non-ARN cases
|
||||
if (error instanceof Error) {
|
||||
logger.error("Standard Bedrock error", {
|
||||
ctx: "bedrock",
|
||||
errorStack: error.stack,
|
||||
errorMessage: error.message,
|
||||
})
|
||||
yield {
|
||||
type: "text",
|
||||
text: `Error: ${error.message}`,
|
||||
}
|
||||
} else {
|
||||
logger.error("Unknown Bedrock error", {
|
||||
ctx: "bedrock",
|
||||
error: String(error),
|
||||
})
|
||||
yield {
|
||||
type: "text",
|
||||
text: "An unknown error occurred",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always yield usage info
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
}
|
||||
|
||||
// Re-throw the error
|
||||
if (error instanceof Error) {
|
||||
console.error("Error stack:", error.stack)
|
||||
yield {
|
||||
type: "text",
|
||||
text: `Error: ${error.message}`,
|
||||
}
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
}
|
||||
throw error
|
||||
} else {
|
||||
const unknownError = new Error("An unknown error occurred")
|
||||
yield {
|
||||
type: "text",
|
||||
text: unknownError.message,
|
||||
}
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
}
|
||||
throw unknownError
|
||||
throw new Error("An unknown error occurred")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override getModel(): { id: BedrockModelId | string; info: ModelInfo } {
|
||||
// If custom ARN is provided, use it
|
||||
if (this.options.awsCustomArn) {
|
||||
// Custom ARNs should not be modified with region prefixes
|
||||
// as they already contain the full resource path
|
||||
|
||||
// Check if the ARN contains information about the model type
|
||||
// This helps set appropriate token limits for models behind prompt routers
|
||||
const arnLower = this.options.awsCustomArn.toLowerCase()
|
||||
|
||||
// Determine model info based on ARN content
|
||||
let modelInfo: ModelInfo
|
||||
|
||||
if (arnLower.includes("claude-3-7-sonnet") || arnLower.includes("claude-3.7-sonnet")) {
|
||||
// Claude 3.7 Sonnet has 8192 tokens in Bedrock
|
||||
modelInfo = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: true,
|
||||
}
|
||||
} else if (arnLower.includes("claude-3-5-sonnet") || arnLower.includes("claude-3.5-sonnet")) {
|
||||
// Claude 3.5 Sonnet has 8192 tokens in Bedrock
|
||||
modelInfo = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: true,
|
||||
supportsComputerUse: true,
|
||||
}
|
||||
} else if (arnLower.includes("claude-3-opus") || arnLower.includes("claude-3.0-opus")) {
|
||||
// Claude 3 Opus has 4096 tokens in Bedrock
|
||||
modelInfo = {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: true,
|
||||
}
|
||||
} else if (arnLower.includes("claude-3-haiku") || arnLower.includes("claude-3.0-haiku")) {
|
||||
// Claude 3 Haiku has 4096 tokens in Bedrock
|
||||
modelInfo = {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 200_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: true,
|
||||
}
|
||||
} else if (arnLower.includes("claude-3-5-haiku") || arnLower.includes("claude-3.5-haiku")) {
|
||||
// Claude 3.5 Haiku has 8192 tokens in Bedrock
|
||||
modelInfo = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 200_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: false,
|
||||
}
|
||||
} else if (arnLower.includes("claude")) {
|
||||
// Generic Claude model with conservative token limit
|
||||
modelInfo = {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: true,
|
||||
}
|
||||
} else if (arnLower.includes("llama3") || arnLower.includes("llama-3")) {
|
||||
// Llama 3 models typically have 8192 tokens in Bedrock
|
||||
modelInfo = {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: arnLower.includes("90b") || arnLower.includes("11b"),
|
||||
}
|
||||
} else if (arnLower.includes("nova-pro")) {
|
||||
// Amazon Nova Pro
|
||||
modelInfo = {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 300_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: true,
|
||||
}
|
||||
} else {
|
||||
// Default for unknown models or prompt routers
|
||||
modelInfo = {
|
||||
maxTokens: 4096,
|
||||
contextWindow: 128_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: true,
|
||||
}
|
||||
}
|
||||
|
||||
// If modelMaxTokens is explicitly set in options, override the default
|
||||
if (this.options.modelMaxTokens && this.options.modelMaxTokens > 0) {
|
||||
modelInfo.maxTokens = this.options.modelMaxTokens
|
||||
}
|
||||
|
||||
return {
|
||||
id: this.options.awsCustomArn,
|
||||
info: modelInfo,
|
||||
}
|
||||
}
|
||||
|
||||
const modelId = this.options.apiModelId
|
||||
if (modelId) {
|
||||
// Special case for custom ARN option
|
||||
if (modelId === "custom-arn") {
|
||||
// This should not happen as we should have awsCustomArn set
|
||||
// but just in case, return a default model
|
||||
return {
|
||||
id: bedrockDefaultModelId,
|
||||
info: bedrockModels[bedrockDefaultModelId],
|
||||
}
|
||||
}
|
||||
|
||||
// For tests, allow any model ID
|
||||
if (process.env.NODE_ENV === "test") {
|
||||
return {
|
||||
|
|
@ -239,7 +569,43 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
|
||||
// Handle cross-region inference
|
||||
let modelId: string
|
||||
if (this.options.awsUseCrossRegionInference) {
|
||||
|
||||
// For custom ARNs, use the ARN directly without modification
|
||||
if (this.options.awsCustomArn) {
|
||||
modelId = modelConfig.id
|
||||
logger.debug("Using custom ARN in completePrompt", {
|
||||
ctx: "bedrock",
|
||||
customArn: this.options.awsCustomArn,
|
||||
})
|
||||
|
||||
// Validate ARN format and check region match
|
||||
const clientRegion = this.client.config.region as string
|
||||
const validation = validateBedrockArn(modelId, clientRegion)
|
||||
|
||||
if (!validation.isValid) {
|
||||
logger.error("Invalid ARN format in completePrompt", {
|
||||
ctx: "bedrock",
|
||||
modelId,
|
||||
errorMessage: validation.errorMessage,
|
||||
})
|
||||
throw new Error(
|
||||
validation.errorMessage ||
|
||||
"Invalid ARN format. ARN should follow the pattern: arn:aws:bedrock:region:account-id:resource-type/resource-name",
|
||||
)
|
||||
}
|
||||
|
||||
// Extract region from ARN
|
||||
const arnRegion = validation.arnRegion!
|
||||
|
||||
// Log warning if there's a region mismatch
|
||||
if (validation.errorMessage) {
|
||||
logger.warn(validation.errorMessage, {
|
||||
ctx: "bedrock",
|
||||
arnRegion,
|
||||
clientRegion,
|
||||
})
|
||||
}
|
||||
} else if (this.options.awsUseCrossRegionInference) {
|
||||
let regionPrefix = (this.options.awsRegion || "").slice(0, 3)
|
||||
switch (regionPrefix) {
|
||||
case "us-":
|
||||
|
|
@ -265,12 +631,21 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
},
|
||||
]),
|
||||
inferenceConfig: {
|
||||
maxTokens: modelConfig.info.maxTokens || 5000,
|
||||
maxTokens: modelConfig.info.maxTokens || 4096,
|
||||
temperature: this.options.modelTemperature ?? BEDROCK_DEFAULT_TEMPERATURE,
|
||||
topP: 0.1,
|
||||
},
|
||||
}
|
||||
|
||||
// Log the payload for debugging custom ARN issues
|
||||
if (this.options.awsCustomArn) {
|
||||
logger.debug("Bedrock completePrompt request details", {
|
||||
ctx: "bedrock",
|
||||
clientRegion: this.client.config.region,
|
||||
payload: JSON.stringify(payload, null, 2),
|
||||
})
|
||||
}
|
||||
|
||||
const command = new ConverseCommand(payload)
|
||||
const response = await this.client.send(command)
|
||||
|
||||
|
|
@ -282,11 +657,67 @@ export class AwsBedrockHandler extends BaseProvider implements SingleCompletionH
|
|||
return output.content
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error("Failed to parse Bedrock response:", parseError)
|
||||
logger.error("Failed to parse Bedrock response", {
|
||||
ctx: "bedrock",
|
||||
error: parseError instanceof Error ? parseError : String(parseError),
|
||||
})
|
||||
}
|
||||
}
|
||||
return ""
|
||||
} catch (error) {
|
||||
// Enhanced error handling for custom ARN issues
|
||||
if (this.options.awsCustomArn) {
|
||||
logger.error("Error occurred with custom ARN in completePrompt", {
|
||||
ctx: "bedrock",
|
||||
customArn: this.options.awsCustomArn,
|
||||
error: error instanceof Error ? error : String(error),
|
||||
})
|
||||
|
||||
if (error instanceof Error) {
|
||||
const errorMessage = error.message.toLowerCase()
|
||||
|
||||
// Access denied errors
|
||||
if (
|
||||
errorMessage.includes("access") &&
|
||||
(errorMessage.includes("model") || errorMessage.includes("denied"))
|
||||
) {
|
||||
throw new Error(
|
||||
`Bedrock custom ARN error: You don't have access to the model with the specified ARN. Please verify:
|
||||
1. The ARN is correct and points to a valid model
|
||||
2. Your AWS credentials have permission to access this model (check IAM policies)
|
||||
3. The region in the ARN matches the region where the model is deployed
|
||||
4. If using a provisioned model, ensure it's active and not in a failed state`,
|
||||
)
|
||||
}
|
||||
// Model not found errors
|
||||
else if (errorMessage.includes("not found") || errorMessage.includes("does not exist")) {
|
||||
throw new Error(
|
||||
`Bedrock custom ARN error: 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
|
||||
4. The resource type is one of: foundation-model, provisioned-model, or default-prompt-router`,
|
||||
)
|
||||
}
|
||||
// Throttling errors
|
||||
else if (
|
||||
errorMessage.includes("throttl") ||
|
||||
errorMessage.includes("rate") ||
|
||||
errorMessage.includes("limit")
|
||||
) {
|
||||
throw new Error(
|
||||
`Bedrock custom ARN error: 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`,
|
||||
)
|
||||
} else {
|
||||
throw new Error(`Bedrock custom ARN error: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Standard error handling
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Bedrock completion error: ${error.message}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
|
||||
const stream = await this.client.chat.completions.create(requestOptions)
|
||||
|
||||
let lastUsage
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta ?? {}
|
||||
|
||||
|
|
@ -116,9 +118,13 @@ export class OpenAiHandler extends BaseProvider implements SingleCompletionHandl
|
|||
}
|
||||
}
|
||||
if (chunk.usage) {
|
||||
yield this.processUsageMetrics(chunk.usage, modelInfo)
|
||||
lastUsage = chunk.usage
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUsage) {
|
||||
yield this.processUsageMetrics(lastUsage, modelInfo)
|
||||
}
|
||||
} else {
|
||||
// o1 for instance doesnt support streaming, non-1 temp, or system prompt
|
||||
const systemMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import {
|
|||
ClineSay,
|
||||
ClineSayBrowserAction,
|
||||
ClineSayTool,
|
||||
ToolProgressStatus,
|
||||
} from "../shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "../shared/getApiMetrics"
|
||||
import { HistoryItem } from "../shared/HistoryItem"
|
||||
|
|
@ -408,6 +409,7 @@ export class Cline {
|
|||
type: ClineAsk,
|
||||
text?: string,
|
||||
partial?: boolean,
|
||||
progressStatus?: ToolProgressStatus,
|
||||
): Promise<{ response: ClineAskResponse; text?: string; images?: string[] }> {
|
||||
// If this Cline instance was aborted by the provider, then the only thing keeping us alive is a promise still running in the background, in which case we don't want to send its result to the webview as it is attached to a new instance of Cline now. So we can safely ignore the result of any active promises, and this class will be deallocated. (Although we set Cline = undefined in provider, that simply removes the reference to this instance, but the instance is still alive until this promise resolves or rejects.)
|
||||
if (this.abort) {
|
||||
|
|
@ -423,6 +425,7 @@ export class Cline {
|
|||
// existing partial message, so update it
|
||||
lastMessage.text = text
|
||||
lastMessage.partial = partial
|
||||
lastMessage.progressStatus = progressStatus
|
||||
// todo be more efficient about saving and posting only new data or one whole message at a time so ignore partial for saves, and only post parts of partial message instead of whole array in new listener
|
||||
// await this.saveClineMessages()
|
||||
// await this.providerRef.deref()?.postStateToWebview()
|
||||
|
|
@ -460,6 +463,8 @@ export class Cline {
|
|||
// lastMessage.ts = askTs
|
||||
lastMessage.text = text
|
||||
lastMessage.partial = false
|
||||
lastMessage.progressStatus = progressStatus
|
||||
|
||||
await this.saveClineMessages()
|
||||
// await this.providerRef.deref()?.postStateToWebview()
|
||||
await this.providerRef
|
||||
|
|
@ -511,6 +516,7 @@ export class Cline {
|
|||
images?: string[],
|
||||
partial?: boolean,
|
||||
checkpoint?: Record<string, unknown>,
|
||||
progressStatus?: ToolProgressStatus,
|
||||
): Promise<undefined> {
|
||||
if (this.abort) {
|
||||
throw new Error(`Task: ${this.taskNumber} Roo Code instance aborted (#2)`)
|
||||
|
|
@ -526,6 +532,7 @@ export class Cline {
|
|||
lastMessage.text = text
|
||||
lastMessage.images = images
|
||||
lastMessage.partial = partial
|
||||
lastMessage.progressStatus = progressStatus
|
||||
await this.providerRef
|
||||
.deref()
|
||||
?.postMessageToWebview({ type: "partialMessage", partialMessage: lastMessage })
|
||||
|
|
@ -545,6 +552,7 @@ export class Cline {
|
|||
lastMessage.text = text
|
||||
lastMessage.images = images
|
||||
lastMessage.partial = false
|
||||
lastMessage.progressStatus = progressStatus
|
||||
|
||||
// instead of streaming partialMessage events, we do a save and post like normal to persist to disk
|
||||
await this.saveClineMessages()
|
||||
|
|
@ -1394,8 +1402,12 @@ export class Cline {
|
|||
isCheckpointPossible = true
|
||||
}
|
||||
|
||||
const askApproval = async (type: ClineAsk, partialMessage?: string) => {
|
||||
const { response, text, images } = await this.ask(type, partialMessage, false)
|
||||
const askApproval = async (
|
||||
type: ClineAsk,
|
||||
partialMessage?: string,
|
||||
progressStatus?: ToolProgressStatus,
|
||||
) => {
|
||||
const { response, text, images } = await this.ask(type, partialMessage, false, progressStatus)
|
||||
if (response !== "yesButtonClicked") {
|
||||
// Handle both messageResponse and noButtonClicked with text
|
||||
if (text) {
|
||||
|
|
@ -1703,8 +1715,16 @@ export class Cline {
|
|||
try {
|
||||
if (block.partial) {
|
||||
// update gui message
|
||||
let toolProgressStatus
|
||||
if (this.diffStrategy && this.diffStrategy.getProgressStatus) {
|
||||
toolProgressStatus = this.diffStrategy.getProgressStatus(block)
|
||||
}
|
||||
|
||||
const partialMessage = JSON.stringify(sharedMessageProps)
|
||||
await this.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||
|
||||
await this.ask("tool", partialMessage, block.partial, toolProgressStatus).catch(
|
||||
() => {},
|
||||
)
|
||||
break
|
||||
} else {
|
||||
if (!relPath) {
|
||||
|
|
@ -1799,7 +1819,12 @@ export class Cline {
|
|||
diff: diffContent,
|
||||
} satisfies ClineSayTool)
|
||||
|
||||
const didApprove = await askApproval("tool", completeMessage)
|
||||
let toolProgressStatus
|
||||
if (this.diffStrategy && this.diffStrategy.getProgressStatus) {
|
||||
toolProgressStatus = this.diffStrategy.getProgressStatus(block, diffResult)
|
||||
}
|
||||
|
||||
const didApprove = await askApproval("tool", completeMessage, toolProgressStatus)
|
||||
if (!didApprove) {
|
||||
await this.diffViewProvider.revertChanges() // This likely handles closing the diff view
|
||||
break
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { DiffStrategy, DiffResult } from "../types"
|
||||
import { addLineNumbers, everyLineHasLineNumbers, stripLineNumbers } from "../../../integrations/misc/extract-text"
|
||||
import { distance } from "fastest-levenshtein"
|
||||
import { ToolProgressStatus } from "../../../shared/ExtensionMessage"
|
||||
import { ToolUse } from "../../assistant-message"
|
||||
|
||||
const BUFFER_LINES = 40 // Number of extra context lines to show before and after matches
|
||||
|
||||
|
|
@ -362,4 +364,27 @@ Only use a single line of '=======' between search and replacement content, beca
|
|||
failParts: diffResults,
|
||||
}
|
||||
}
|
||||
|
||||
getProgressStatus(toolUse: ToolUse, result?: DiffResult): ToolProgressStatus {
|
||||
const diffContent = toolUse.params.diff
|
||||
if (diffContent) {
|
||||
const icon = "diff-multiple"
|
||||
const searchBlockCount = (diffContent.match(/SEARCH/g) || []).length
|
||||
if (toolUse.partial) {
|
||||
if (diffContent.length < 1000 || (diffContent.length / 50) % 10 === 0) {
|
||||
return { icon, text: `${searchBlockCount}` }
|
||||
}
|
||||
} else if (result) {
|
||||
if (result.failParts?.length) {
|
||||
return {
|
||||
icon,
|
||||
text: `${searchBlockCount - result.failParts.length}/${searchBlockCount}`,
|
||||
}
|
||||
} else {
|
||||
return { icon, text: `${searchBlockCount}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
* Interface for implementing different diff strategies
|
||||
*/
|
||||
|
||||
import { ToolProgressStatus } from "../../shared/ExtensionMessage"
|
||||
import { ToolUse } from "../assistant-message"
|
||||
|
||||
export type DiffResult =
|
||||
| { success: true; content: string; failParts?: DiffResult[] }
|
||||
| ({
|
||||
|
|
@ -34,4 +37,6 @@ export interface DiffStrategy {
|
|||
* @returns A DiffResult object containing either the successful result or error details
|
||||
*/
|
||||
applyDiff(originalContent: string, diffContent: string, startLine?: number, endLine?: number): Promise<DiffResult>
|
||||
|
||||
getProgressStatus?(toolUse: ToolUse, result?: any): ToolProgressStatus
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker"
|
|||
import { McpHub } from "../../services/mcp/McpHub"
|
||||
import { McpServerManager } from "../../services/mcp/McpServerManager"
|
||||
import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService"
|
||||
import { BrowserSession } from "../../services/browser/BrowserSession"
|
||||
import { discoverChromeInstances } from "../../services/browser/browserDiscovery"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { playSound, setSoundEnabled, setSoundVolume } from "../../utils/sound"
|
||||
import { singleCompletionHandler } from "../../utils/single-completion-handler"
|
||||
|
|
@ -1262,6 +1264,105 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
await this.updateGlobalState("browserViewportSize", browserViewportSize)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "remoteBrowserHost":
|
||||
await this.updateGlobalState("remoteBrowserHost", message.text)
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "remoteBrowserEnabled":
|
||||
// Store the preference in global state
|
||||
// remoteBrowserEnabled now means "enable remote browser connection"
|
||||
await this.updateGlobalState("remoteBrowserEnabled", message.bool ?? false)
|
||||
// If disabling remote browser connection, clear the remoteBrowserHost
|
||||
if (!message.bool) {
|
||||
await this.updateGlobalState("remoteBrowserHost", undefined)
|
||||
}
|
||||
await this.postStateToWebview()
|
||||
break
|
||||
case "testBrowserConnection":
|
||||
try {
|
||||
const browserSession = new BrowserSession(this.context)
|
||||
// If no text is provided, try auto-discovery
|
||||
if (!message.text) {
|
||||
try {
|
||||
const discoveredHost = await discoverChromeInstances()
|
||||
if (discoveredHost) {
|
||||
// Test the connection to the discovered host
|
||||
const result = await browserSession.testConnection(discoveredHost)
|
||||
// Send the result back to the webview
|
||||
await this.postMessageToWebview({
|
||||
type: "browserConnectionResult",
|
||||
success: result.success,
|
||||
text: `Auto-discovered and tested connection to Chrome at ${discoveredHost}: ${result.message}`,
|
||||
values: { endpoint: result.endpoint },
|
||||
})
|
||||
} else {
|
||||
await this.postMessageToWebview({
|
||||
type: "browserConnectionResult",
|
||||
success: false,
|
||||
text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
await this.postMessageToWebview({
|
||||
type: "browserConnectionResult",
|
||||
success: false,
|
||||
text: `Error during auto-discovery: ${error instanceof Error ? error.message : String(error)}`,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Test the provided URL
|
||||
const result = await browserSession.testConnection(message.text)
|
||||
|
||||
// Send the result back to the webview
|
||||
await this.postMessageToWebview({
|
||||
type: "browserConnectionResult",
|
||||
success: result.success,
|
||||
text: result.message,
|
||||
values: { endpoint: result.endpoint },
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
await this.postMessageToWebview({
|
||||
type: "browserConnectionResult",
|
||||
success: false,
|
||||
text: `Error testing connection: ${error instanceof Error ? error.message : String(error)}`,
|
||||
})
|
||||
}
|
||||
break
|
||||
case "discoverBrowser":
|
||||
try {
|
||||
const discoveredHost = await discoverChromeInstances()
|
||||
|
||||
if (discoveredHost) {
|
||||
// Don't update the remoteBrowserHost state when auto-discovering
|
||||
// This way we don't override the user's preference
|
||||
|
||||
// Test the connection to get the endpoint
|
||||
const browserSession = new BrowserSession(this.context)
|
||||
const result = await browserSession.testConnection(discoveredHost)
|
||||
|
||||
// Send the result back to the webview
|
||||
await this.postMessageToWebview({
|
||||
type: "browserConnectionResult",
|
||||
success: true,
|
||||
text: `Successfully discovered and connected to Chrome at ${discoveredHost}`,
|
||||
values: { endpoint: result.endpoint },
|
||||
})
|
||||
} else {
|
||||
await this.postMessageToWebview({
|
||||
type: "browserConnectionResult",
|
||||
success: false,
|
||||
text: "No Chrome instances found on the network. Make sure Chrome is running with remote debugging enabled (--remote-debugging-port=9222).",
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
await this.postMessageToWebview({
|
||||
type: "browserConnectionResult",
|
||||
success: false,
|
||||
text: `Error discovering browser: ${error instanceof Error ? error.message : String(error)}`,
|
||||
})
|
||||
}
|
||||
break
|
||||
case "fuzzyMatchThreshold":
|
||||
await this.updateGlobalState("fuzzyMatchThreshold", message.value)
|
||||
await this.postStateToWebview()
|
||||
|
|
@ -1826,6 +1927,7 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
fuzzyMatchThreshold,
|
||||
experiments,
|
||||
enableMcpServerCreation,
|
||||
browserToolEnabled,
|
||||
} = await this.getState()
|
||||
|
||||
// Create diffStrategy based on current model and settings
|
||||
|
|
@ -1841,10 +1943,14 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
|
||||
const rooIgnoreInstructions = this.getCurrentCline()?.rooIgnoreController?.getInstructions()
|
||||
|
||||
// Determine if browser tools can be used based on model support and user settings
|
||||
const modelSupportsComputerUse = this.getCurrentCline()?.api.getModel().info.supportsComputerUse ?? false
|
||||
const canUseBrowserTool = modelSupportsComputerUse && (browserToolEnabled ?? true)
|
||||
|
||||
const systemPrompt = await SYSTEM_PROMPT(
|
||||
this.context,
|
||||
cwd,
|
||||
apiConfiguration.openRouterModelInfo?.supportsComputerUse ?? false,
|
||||
canUseBrowserTool,
|
||||
mcpEnabled ? this.mcpHub : undefined,
|
||||
diffStrategy,
|
||||
browserViewportSize ?? "900x600",
|
||||
|
|
@ -1971,11 +2077,24 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
// MCP
|
||||
|
||||
async ensureMcpServersDirectoryExists(): Promise<string> {
|
||||
const mcpServersDir = path.join(os.homedir(), "Documents", "Cline", "MCP")
|
||||
// Get platform-specific application data directory
|
||||
let mcpServersDir: string
|
||||
if (process.platform === "win32") {
|
||||
// Windows: %APPDATA%\Roo-Code\MCP
|
||||
mcpServersDir = path.join(os.homedir(), "AppData", "Roaming", "Roo-Code", "MCP")
|
||||
} else if (process.platform === "darwin") {
|
||||
// macOS: ~/Documents/Cline/MCP
|
||||
mcpServersDir = path.join(os.homedir(), "Documents", "Cline", "MCP")
|
||||
} else {
|
||||
// Linux: ~/.local/share/Cline/MCP
|
||||
mcpServersDir = path.join(os.homedir(), ".local", "share", "Roo-Code", "MCP")
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.mkdir(mcpServersDir, { recursive: true })
|
||||
} catch (error) {
|
||||
return "~/Documents/Cline/MCP" // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
|
||||
// Fallback to a relative path if directory creation fails
|
||||
return path.join(os.homedir(), ".roo-code", "mcp")
|
||||
}
|
||||
return mcpServersDir
|
||||
}
|
||||
|
|
@ -2192,6 +2311,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
soundVolume,
|
||||
browserViewportSize,
|
||||
screenshotQuality,
|
||||
remoteBrowserHost,
|
||||
remoteBrowserEnabled,
|
||||
preferredLanguage,
|
||||
writeDelayMs,
|
||||
terminalOutputLimit,
|
||||
|
|
@ -2250,6 +2371,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
soundVolume: soundVolume ?? 0.5,
|
||||
browserViewportSize: browserViewportSize ?? "900x600",
|
||||
screenshotQuality: screenshotQuality ?? 75,
|
||||
remoteBrowserHost,
|
||||
remoteBrowserEnabled: remoteBrowserEnabled ?? false,
|
||||
preferredLanguage: preferredLanguage ?? "English",
|
||||
writeDelayMs: writeDelayMs ?? 1000,
|
||||
terminalOutputLimit: terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,
|
||||
|
|
@ -2403,6 +2526,8 @@ export class ClineProvider implements vscode.WebviewViewProvider {
|
|||
soundVolume: stateValues.soundVolume,
|
||||
browserViewportSize: stateValues.browserViewportSize ?? "900x600",
|
||||
screenshotQuality: stateValues.screenshotQuality ?? 75,
|
||||
remoteBrowserHost: stateValues.remoteBrowserHost,
|
||||
remoteBrowserEnabled: stateValues.remoteBrowserEnabled ?? false,
|
||||
fuzzyMatchThreshold: stateValues.fuzzyMatchThreshold ?? 1.0,
|
||||
writeDelayMs: stateValues.writeDelayMs ?? 1000,
|
||||
terminalOutputLimit: stateValues.terminalOutputLimit ?? TERMINAL_OUTPUT_LIMIT,
|
||||
|
|
|
|||
|
|
@ -55,6 +55,34 @@ jest.mock("../../contextProxy", () => {
|
|||
// Mock dependencies
|
||||
jest.mock("vscode")
|
||||
jest.mock("delay")
|
||||
|
||||
// Mock BrowserSession
|
||||
jest.mock("../../../services/browser/BrowserSession", () => ({
|
||||
BrowserSession: jest.fn().mockImplementation(() => ({
|
||||
testConnection: jest.fn().mockImplementation(async (url) => {
|
||||
if (url === "http://localhost:9222") {
|
||||
return {
|
||||
success: true,
|
||||
message: "Successfully connected to Chrome",
|
||||
endpoint: "ws://localhost:9222/devtools/browser/123",
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to connect to Chrome",
|
||||
endpoint: undefined,
|
||||
}
|
||||
}
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
// Mock browserDiscovery
|
||||
jest.mock("../../../services/browser/browserDiscovery", () => ({
|
||||
discoverChromeInstances: jest.fn().mockImplementation(async () => {
|
||||
return "http://localhost:9222"
|
||||
}),
|
||||
}))
|
||||
jest.mock(
|
||||
"@modelcontextprotocol/sdk/types.js",
|
||||
() => ({
|
||||
|
|
@ -94,31 +122,7 @@ jest.mock("delay", () => {
|
|||
return delayFn
|
||||
})
|
||||
|
||||
// Mock MCP-related modules
|
||||
jest.mock(
|
||||
"@modelcontextprotocol/sdk/types.js",
|
||||
() => ({
|
||||
CallToolResultSchema: {},
|
||||
ListResourcesResultSchema: {},
|
||||
ListResourceTemplatesResultSchema: {},
|
||||
ListToolsResultSchema: {},
|
||||
ReadResourceResultSchema: {},
|
||||
ErrorCode: {
|
||||
InvalidRequest: "InvalidRequest",
|
||||
MethodNotFound: "MethodNotFound",
|
||||
InternalError: "InternalError",
|
||||
},
|
||||
McpError: class McpError extends Error {
|
||||
code: string
|
||||
constructor(code: string, message: string) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.name = "McpError"
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ virtual: true },
|
||||
)
|
||||
// MCP-related modules are mocked once above (lines 87-109)
|
||||
|
||||
jest.mock(
|
||||
"@modelcontextprotocol/sdk/client/index.js",
|
||||
|
|
@ -598,7 +602,7 @@ describe("ClineProvider", () => {
|
|||
expect(mockPostMessage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("requestDelaySeconds defaults to 5 seconds", async () => {
|
||||
test("requestDelaySeconds defaults to 10 seconds", async () => {
|
||||
// Mock globalState.get to return undefined for requestDelaySeconds
|
||||
;(mockContext.globalState.get as jest.Mock).mockImplementation((key: string) => {
|
||||
if (key === "requestDelaySeconds") {
|
||||
|
|
@ -1160,6 +1164,17 @@ describe("ClineProvider", () => {
|
|||
})
|
||||
|
||||
test("passes diffStrategy and diffEnabled to SYSTEM_PROMPT when previewing", async () => {
|
||||
// Setup Cline instance with mocked api.getModel()
|
||||
const { Cline } = require("../../Cline")
|
||||
const mockCline = new Cline()
|
||||
mockCline.api = {
|
||||
getModel: jest.fn().mockReturnValue({
|
||||
id: "claude-3-sonnet",
|
||||
info: { supportsComputerUse: true },
|
||||
}),
|
||||
}
|
||||
await provider.addClineToStack(mockCline)
|
||||
|
||||
// Mock getState to return experimentalDiffStrategy, diffEnabled and fuzzyMatchThreshold
|
||||
jest.spyOn(provider, "getState").mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
|
|
@ -1176,6 +1191,7 @@ describe("ClineProvider", () => {
|
|||
diffEnabled: true,
|
||||
fuzzyMatchThreshold: 0.8,
|
||||
experiments: experimentDefault,
|
||||
browserToolEnabled: true,
|
||||
} as any)
|
||||
|
||||
// Mock SYSTEM_PROMPT to verify diffStrategy and diffEnabled are passed
|
||||
|
|
@ -1186,27 +1202,19 @@ describe("ClineProvider", () => {
|
|||
const handler = getMessageHandler()
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
||||
// Verify SYSTEM_PROMPT was called with correct arguments
|
||||
expect(systemPromptSpy).toHaveBeenCalledWith(
|
||||
expect.anything(), // context
|
||||
expect.any(String), // cwd
|
||||
true, // supportsComputerUse
|
||||
undefined, // mcpHub (disabled)
|
||||
expect.objectContaining({
|
||||
// diffStrategy
|
||||
getToolDescription: expect.any(Function),
|
||||
}),
|
||||
"900x600", // browserViewportSize
|
||||
"code", // mode
|
||||
{}, // customModePrompts
|
||||
{ customModes: [] }, // customModes
|
||||
undefined, // effectiveInstructions
|
||||
undefined, // preferredLanguage
|
||||
true, // diffEnabled
|
||||
experimentDefault,
|
||||
true,
|
||||
undefined, // rooIgnoreInstructions
|
||||
)
|
||||
// Verify SYSTEM_PROMPT was called
|
||||
expect(systemPromptSpy).toHaveBeenCalled()
|
||||
|
||||
// Get the actual arguments passed to SYSTEM_PROMPT
|
||||
const callArgs = systemPromptSpy.mock.calls[0]
|
||||
|
||||
// Verify key parameters
|
||||
expect(callArgs[2]).toBe(true) // supportsComputerUse
|
||||
expect(callArgs[3]).toBeUndefined() // mcpHub (disabled)
|
||||
expect(callArgs[4]).toHaveProperty("getToolDescription") // diffStrategy
|
||||
expect(callArgs[5]).toBe("900x600") // browserViewportSize
|
||||
expect(callArgs[6]).toBe("code") // mode
|
||||
expect(callArgs[11]).toBe(true) // diffEnabled
|
||||
|
||||
// Run the test again to verify it's consistent
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
|
@ -1214,6 +1222,17 @@ describe("ClineProvider", () => {
|
|||
})
|
||||
|
||||
test("passes diffEnabled: false to SYSTEM_PROMPT when diff is disabled", async () => {
|
||||
// Setup Cline instance with mocked api.getModel()
|
||||
const { Cline } = require("../../Cline")
|
||||
const mockCline = new Cline()
|
||||
mockCline.api = {
|
||||
getModel: jest.fn().mockReturnValue({
|
||||
id: "claude-3-sonnet",
|
||||
info: { supportsComputerUse: true },
|
||||
}),
|
||||
}
|
||||
await provider.addClineToStack(mockCline)
|
||||
|
||||
// Mock getState to return diffEnabled: false
|
||||
jest.spyOn(provider, "getState").mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
|
|
@ -1230,6 +1249,7 @@ describe("ClineProvider", () => {
|
|||
fuzzyMatchThreshold: 0.8,
|
||||
experiments: experimentDefault,
|
||||
enableMcpServerCreation: true,
|
||||
browserToolEnabled: true,
|
||||
} as any)
|
||||
|
||||
// Mock SYSTEM_PROMPT to verify diffEnabled is passed as false
|
||||
|
|
@ -1240,27 +1260,19 @@ describe("ClineProvider", () => {
|
|||
const handler = getMessageHandler()
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
||||
// Verify SYSTEM_PROMPT was called with diffEnabled: false
|
||||
expect(systemPromptSpy).toHaveBeenCalledWith(
|
||||
expect.anything(), // context
|
||||
expect.any(String), // cwd
|
||||
true, // supportsComputerUse
|
||||
undefined, // mcpHub (disabled)
|
||||
expect.objectContaining({
|
||||
// diffStrategy
|
||||
getToolDescription: expect.any(Function),
|
||||
}),
|
||||
"900x600", // browserViewportSize
|
||||
"code", // mode
|
||||
{}, // customModePrompts
|
||||
{ customModes: [] }, // customModes
|
||||
undefined, // effectiveInstructions
|
||||
undefined, // preferredLanguage
|
||||
false, // diffEnabled
|
||||
experimentDefault,
|
||||
true,
|
||||
undefined, // rooIgnoreInstructions
|
||||
)
|
||||
// Verify SYSTEM_PROMPT was called
|
||||
expect(systemPromptSpy).toHaveBeenCalled()
|
||||
|
||||
// Get the actual arguments passed to SYSTEM_PROMPT
|
||||
const callArgs = systemPromptSpy.mock.calls[0]
|
||||
|
||||
// Verify key parameters
|
||||
expect(callArgs[2]).toBe(true) // supportsComputerUse
|
||||
expect(callArgs[3]).toBeUndefined() // mcpHub (disabled)
|
||||
expect(callArgs[4]).toHaveProperty("getToolDescription") // diffStrategy
|
||||
expect(callArgs[5]).toBe("900x600") // browserViewportSize
|
||||
expect(callArgs[6]).toBe("code") // mode
|
||||
expect(callArgs[11]).toBe(false) // diffEnabled should be false
|
||||
})
|
||||
|
||||
test("uses correct mode-specific instructions when mode is specified", async () => {
|
||||
|
|
@ -1299,6 +1311,188 @@ describe("ClineProvider", () => {
|
|||
expect.any(String),
|
||||
)
|
||||
})
|
||||
|
||||
// Tests for browser tool support
|
||||
test("correctly extracts modelSupportsComputerUse from Cline instance", async () => {
|
||||
// Setup Cline instance with mocked api.getModel()
|
||||
const { Cline } = require("../../Cline")
|
||||
const mockCline = new Cline()
|
||||
mockCline.api = {
|
||||
getModel: jest.fn().mockReturnValue({
|
||||
id: "claude-3-sonnet",
|
||||
info: { supportsComputerUse: true },
|
||||
}),
|
||||
}
|
||||
await provider.addClineToStack(mockCline)
|
||||
|
||||
// Mock SYSTEM_PROMPT to verify supportsComputerUse is passed correctly
|
||||
const systemPromptModule = require("../../prompts/system")
|
||||
const systemPromptSpy = jest.spyOn(systemPromptModule, "SYSTEM_PROMPT")
|
||||
|
||||
// Mock getState to return browserToolEnabled: true
|
||||
jest.spyOn(provider, "getState").mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
apiProvider: "openrouter",
|
||||
},
|
||||
browserToolEnabled: true,
|
||||
mode: "code",
|
||||
experiments: experimentDefault,
|
||||
} as any)
|
||||
|
||||
// Trigger getSystemPrompt
|
||||
const handler = getMessageHandler()
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
||||
// Verify SYSTEM_PROMPT was called
|
||||
expect(systemPromptSpy).toHaveBeenCalled()
|
||||
|
||||
// Get the actual arguments passed to SYSTEM_PROMPT
|
||||
const callArgs = systemPromptSpy.mock.calls[0]
|
||||
|
||||
// Verify the supportsComputerUse parameter (3rd parameter, index 2)
|
||||
expect(callArgs[2]).toBe(true)
|
||||
})
|
||||
|
||||
test("correctly handles when model doesn't support computer use", async () => {
|
||||
// Setup Cline instance with mocked api.getModel() that doesn't support computer use
|
||||
const { Cline } = require("../../Cline")
|
||||
const mockCline = new Cline()
|
||||
mockCline.api = {
|
||||
getModel: jest.fn().mockReturnValue({
|
||||
id: "non-computer-use-model",
|
||||
info: { supportsComputerUse: false },
|
||||
}),
|
||||
}
|
||||
await provider.addClineToStack(mockCline)
|
||||
|
||||
// Mock SYSTEM_PROMPT to verify supportsComputerUse is passed correctly
|
||||
const systemPromptModule = require("../../prompts/system")
|
||||
const systemPromptSpy = jest.spyOn(systemPromptModule, "SYSTEM_PROMPT")
|
||||
|
||||
// Mock getState to return browserToolEnabled: true
|
||||
jest.spyOn(provider, "getState").mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
apiProvider: "openrouter",
|
||||
},
|
||||
browserToolEnabled: true,
|
||||
mode: "code",
|
||||
experiments: experimentDefault,
|
||||
} as any)
|
||||
|
||||
// Trigger getSystemPrompt
|
||||
const handler = getMessageHandler()
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
||||
// Verify SYSTEM_PROMPT was called
|
||||
expect(systemPromptSpy).toHaveBeenCalled()
|
||||
|
||||
// Get the actual arguments passed to SYSTEM_PROMPT
|
||||
const callArgs = systemPromptSpy.mock.calls[0]
|
||||
|
||||
// Verify the supportsComputerUse parameter (3rd parameter, index 2)
|
||||
// Even though browserToolEnabled is true, the model doesn't support it
|
||||
expect(callArgs[2]).toBe(false)
|
||||
})
|
||||
|
||||
test("correctly handles when browserToolEnabled is false", async () => {
|
||||
// Setup Cline instance with mocked api.getModel() that supports computer use
|
||||
const { Cline } = require("../../Cline")
|
||||
const mockCline = new Cline()
|
||||
mockCline.api = {
|
||||
getModel: jest.fn().mockReturnValue({
|
||||
id: "claude-3-sonnet",
|
||||
info: { supportsComputerUse: true },
|
||||
}),
|
||||
}
|
||||
await provider.addClineToStack(mockCline)
|
||||
|
||||
// Mock SYSTEM_PROMPT to verify supportsComputerUse is passed correctly
|
||||
const systemPromptModule = require("../../prompts/system")
|
||||
const systemPromptSpy = jest.spyOn(systemPromptModule, "SYSTEM_PROMPT")
|
||||
|
||||
// Mock getState to return browserToolEnabled: false
|
||||
jest.spyOn(provider, "getState").mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
apiProvider: "openrouter",
|
||||
},
|
||||
browserToolEnabled: false,
|
||||
mode: "code",
|
||||
experiments: experimentDefault,
|
||||
} as any)
|
||||
|
||||
// Trigger getSystemPrompt
|
||||
const handler = getMessageHandler()
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
||||
// Verify SYSTEM_PROMPT was called
|
||||
expect(systemPromptSpy).toHaveBeenCalled()
|
||||
|
||||
// Get the actual arguments passed to SYSTEM_PROMPT
|
||||
const callArgs = systemPromptSpy.mock.calls[0]
|
||||
|
||||
// Verify the supportsComputerUse parameter (3rd parameter, index 2)
|
||||
// Even though model supports it, browserToolEnabled is false
|
||||
expect(callArgs[2]).toBe(false)
|
||||
})
|
||||
|
||||
test("correctly calculates canUseBrowserTool as combination of model support and setting", async () => {
|
||||
// Setup Cline instance with mocked api.getModel()
|
||||
const { Cline } = require("../../Cline")
|
||||
const mockCline = new Cline()
|
||||
mockCline.api = {
|
||||
getModel: jest.fn().mockReturnValue({
|
||||
id: "claude-3-sonnet",
|
||||
info: { supportsComputerUse: true },
|
||||
}),
|
||||
}
|
||||
await provider.addClineToStack(mockCline)
|
||||
|
||||
// Mock SYSTEM_PROMPT
|
||||
const systemPromptModule = require("../../prompts/system")
|
||||
const systemPromptSpy = jest.spyOn(systemPromptModule, "SYSTEM_PROMPT")
|
||||
|
||||
// Test all combinations of model support and browserToolEnabled
|
||||
const testCases = [
|
||||
{ modelSupports: true, settingEnabled: true, expected: true },
|
||||
{ modelSupports: true, settingEnabled: false, expected: false },
|
||||
{ modelSupports: false, settingEnabled: true, expected: false },
|
||||
{ modelSupports: false, settingEnabled: false, expected: false },
|
||||
]
|
||||
|
||||
for (const testCase of testCases) {
|
||||
// Reset mocks
|
||||
systemPromptSpy.mockClear()
|
||||
|
||||
// Update mock Cline instance
|
||||
mockCline.api.getModel = jest.fn().mockReturnValue({
|
||||
id: "test-model",
|
||||
info: { supportsComputerUse: testCase.modelSupports },
|
||||
})
|
||||
|
||||
// Mock getState
|
||||
jest.spyOn(provider, "getState").mockResolvedValue({
|
||||
apiConfiguration: {
|
||||
apiProvider: "openrouter",
|
||||
},
|
||||
browserToolEnabled: testCase.settingEnabled,
|
||||
mode: "code",
|
||||
experiments: experimentDefault,
|
||||
} as any)
|
||||
|
||||
// Trigger getSystemPrompt
|
||||
const handler = getMessageHandler()
|
||||
await handler({ type: "getSystemPrompt", mode: "code" })
|
||||
|
||||
// Verify SYSTEM_PROMPT was called
|
||||
expect(systemPromptSpy).toHaveBeenCalled()
|
||||
|
||||
// Get the actual arguments passed to SYSTEM_PROMPT
|
||||
const callArgs = systemPromptSpy.mock.calls[0]
|
||||
|
||||
// Verify the supportsComputerUse parameter (3rd parameter, index 2)
|
||||
expect(callArgs[2]).toBe(testCase.expected)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("handleModeSwitch", () => {
|
||||
|
|
@ -1591,6 +1785,173 @@ describe("ClineProvider", () => {
|
|||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("browser connection features", () => {
|
||||
beforeEach(async () => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks()
|
||||
await provider.resolveWebviewView(mockWebviewView)
|
||||
})
|
||||
|
||||
// Mock BrowserSession and discoverChromeInstances
|
||||
jest.mock("../../../services/browser/BrowserSession", () => ({
|
||||
BrowserSession: jest.fn().mockImplementation(() => ({
|
||||
testConnection: jest.fn().mockImplementation(async (url) => {
|
||||
if (url === "http://localhost:9222") {
|
||||
return {
|
||||
success: true,
|
||||
message: "Successfully connected to Chrome",
|
||||
endpoint: "ws://localhost:9222/devtools/browser/123",
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
message: "Failed to connect to Chrome",
|
||||
endpoint: undefined,
|
||||
}
|
||||
}
|
||||
}),
|
||||
})),
|
||||
}))
|
||||
|
||||
jest.mock("../../../services/browser/browserDiscovery", () => ({
|
||||
discoverChromeInstances: jest.fn().mockImplementation(async () => {
|
||||
return "http://localhost:9222"
|
||||
}),
|
||||
}))
|
||||
|
||||
test("handles testBrowserConnection with provided URL", async () => {
|
||||
// Get the message handler
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
|
||||
// Test with valid URL
|
||||
await messageHandler({
|
||||
type: "testBrowserConnection",
|
||||
text: "http://localhost:9222",
|
||||
})
|
||||
|
||||
// Verify postMessage was called with success result
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "browserConnectionResult",
|
||||
success: true,
|
||||
text: expect.stringContaining("Successfully connected to Chrome"),
|
||||
}),
|
||||
)
|
||||
|
||||
// Reset mock
|
||||
mockPostMessage.mockClear()
|
||||
|
||||
// Test with invalid URL
|
||||
await messageHandler({
|
||||
type: "testBrowserConnection",
|
||||
text: "http://inlocalhost:9222",
|
||||
})
|
||||
|
||||
// Verify postMessage was called with failure result
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "browserConnectionResult",
|
||||
success: false,
|
||||
text: expect.stringContaining("Failed to connect to Chrome"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("handles testBrowserConnection with auto-discovery", async () => {
|
||||
// Get the message handler
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
|
||||
// Test auto-discovery (no URL provided)
|
||||
await messageHandler({
|
||||
type: "testBrowserConnection",
|
||||
})
|
||||
|
||||
// Verify discoverChromeInstances was called
|
||||
const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
|
||||
expect(discoverChromeInstances).toHaveBeenCalled()
|
||||
|
||||
// Verify postMessage was called with success result
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "browserConnectionResult",
|
||||
success: true,
|
||||
text: expect.stringContaining("Auto-discovered and tested connection to Chrome"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("handles discoverBrowser message", async () => {
|
||||
// Get the message handler
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
|
||||
// Test browser discovery
|
||||
await messageHandler({
|
||||
type: "discoverBrowser",
|
||||
})
|
||||
|
||||
// Verify discoverChromeInstances was called
|
||||
const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
|
||||
expect(discoverChromeInstances).toHaveBeenCalled()
|
||||
|
||||
// Verify postMessage was called with success result
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "browserConnectionResult",
|
||||
success: true,
|
||||
text: expect.stringContaining("Successfully discovered and connected to Chrome"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("handles errors during browser discovery", async () => {
|
||||
// Mock discoverChromeInstances to throw an error
|
||||
const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
|
||||
discoverChromeInstances.mockImplementationOnce(() => {
|
||||
throw new Error("Discovery error")
|
||||
})
|
||||
|
||||
// Get the message handler
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
|
||||
// Test browser discovery with error
|
||||
await messageHandler({
|
||||
type: "discoverBrowser",
|
||||
})
|
||||
|
||||
// Verify postMessage was called with error result
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "browserConnectionResult",
|
||||
success: false,
|
||||
text: expect.stringContaining("Error discovering browser"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test("handles case when no browsers are discovered", async () => {
|
||||
// Mock discoverChromeInstances to return null (no browsers found)
|
||||
const { discoverChromeInstances } = require("../../../services/browser/browserDiscovery")
|
||||
discoverChromeInstances.mockImplementationOnce(() => null)
|
||||
|
||||
// Get the message handler
|
||||
const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as jest.Mock).mock.calls[0][0]
|
||||
|
||||
// Test browser discovery with no browsers found
|
||||
await messageHandler({
|
||||
type: "discoverBrowser",
|
||||
})
|
||||
|
||||
// Verify postMessage was called with failure result
|
||||
expect(mockPostMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "browserConnectionResult",
|
||||
success: false,
|
||||
text: expect.stringContaining("No Chrome instances found"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("ContextProxy integration", () => {
|
||||
|
|
|
|||
|
|
@ -1,55 +1,51 @@
|
|||
# Cline API
|
||||
# Roo Code API
|
||||
|
||||
The Cline extension exposes an API that can be used by other extensions. To use this API in your extension:
|
||||
The Roo Code extension exposes an API that can be used by other extensions. To use this API in your extension:
|
||||
|
||||
1. Copy `src/extension-api/cline.d.ts` to your extension's source directory.
|
||||
2. Include `cline.d.ts` in your extension's compilation.
|
||||
1. Copy `src/extension-api/roo-code.d.ts` to your extension's source directory.
|
||||
2. Include `roo-code.d.ts` in your extension's compilation.
|
||||
3. Get access to the API with the following code:
|
||||
|
||||
```ts
|
||||
const clineExtension = vscode.extensions.getExtension<ClineAPI>("rooveterinaryinc.roo-cline")
|
||||
```typescript
|
||||
const extension = vscode.extensions.getExtension<RooCodeAPI>("rooveterinaryinc.roo-cline")
|
||||
|
||||
if (!clineExtension?.isActive) {
|
||||
throw new Error("Cline extension is not activated")
|
||||
}
|
||||
if (!extension?.isActive) {
|
||||
throw new Error("Extension is not activated")
|
||||
}
|
||||
|
||||
const cline = clineExtension.exports
|
||||
const api = extension.exports
|
||||
|
||||
if (cline) {
|
||||
// Now you can use the API
|
||||
if (!api) {
|
||||
throw new Error("API is not available")
|
||||
}
|
||||
|
||||
// Set custom instructions
|
||||
await cline.setCustomInstructions("Talk like a pirate")
|
||||
// Set custom instructions.
|
||||
await api.setCustomInstructions("Talk like a pirate")
|
||||
|
||||
// Get custom instructions
|
||||
const instructions = await cline.getCustomInstructions()
|
||||
console.log("Current custom instructions:", instructions)
|
||||
// Get custom instructions.
|
||||
const instructions = await api.getCustomInstructions()
|
||||
console.log("Current custom instructions:", instructions)
|
||||
|
||||
// Start a new task with an initial message
|
||||
await cline.startNewTask("Hello, Cline! Let's make a new project...")
|
||||
// Start a new task with an initial message.
|
||||
await api.startNewTask("Hello, Roo Code API! Let's make a new project...")
|
||||
|
||||
// Start a new task with an initial message and images
|
||||
await cline.startNewTask("Use this design language", ["data:image/webp;base64,..."])
|
||||
// Start a new task with an initial message and images.
|
||||
await api.startNewTask("Use this design language", ["data:image/webp;base64,..."])
|
||||
|
||||
// Send a message to the current task
|
||||
await cline.sendMessage("Can you fix the @problems?")
|
||||
// Send a message to the current task.
|
||||
await api.sendMessage("Can you fix the @problems?")
|
||||
|
||||
// Simulate pressing the primary button in the chat interface (e.g. 'Save' or 'Proceed While Running')
|
||||
await cline.pressPrimaryButton()
|
||||
// Simulate pressing the primary button in the chat interface (e.g. 'Save' or 'Proceed While Running').
|
||||
await api.pressPrimaryButton()
|
||||
|
||||
// Simulate pressing the secondary button in the chat interface (e.g. 'Reject')
|
||||
await cline.pressSecondaryButton()
|
||||
} else {
|
||||
console.error("Cline API is not available")
|
||||
}
|
||||
```
|
||||
// Simulate pressing the secondary button in the chat interface (e.g. 'Reject').
|
||||
await api.pressSecondaryButton()
|
||||
```
|
||||
|
||||
**Note:** To ensure that the `rooveterinaryinc.roo-cline` extension is activated before your extension, add it to the `extensionDependencies` in your `package.json`:
|
||||
**NOTE:** To ensure that the `rooveterinaryinc.roo-cline` extension is activated before your extension, add it to the `extensionDependencies` in your `package.json`:
|
||||
|
||||
```json
|
||||
"extensionDependencies": [
|
||||
"rooveterinaryinc.roo-cline"
|
||||
]
|
||||
```
|
||||
```json
|
||||
"extensionDependencies": ["rooveterinaryinc.roo-cline"]
|
||||
```
|
||||
|
||||
For detailed information on the available methods and their usage, refer to the `cline.d.ts` file.
|
||||
For detailed information on the available methods and their usage, refer to the `roo-code.d.ts` file.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export interface ClineAPI {
|
||||
export interface RooCodeAPI {
|
||||
/**
|
||||
* Sets the custom instructions in the global storage.
|
||||
* @param value The custom instructions to be saved.
|
||||
|
|
@ -38,7 +38,61 @@ export interface ClineAPI {
|
|||
/**
|
||||
* The sidebar provider instance.
|
||||
*/
|
||||
sidebarProvider: ClineSidebarProvider
|
||||
sidebarProvider: ClineProvider
|
||||
}
|
||||
|
||||
export type ClineAsk =
|
||||
| "followup"
|
||||
| "command"
|
||||
| "command_output"
|
||||
| "completion_result"
|
||||
| "tool"
|
||||
| "api_req_failed"
|
||||
| "resume_task"
|
||||
| "resume_completed_task"
|
||||
| "mistake_limit_reached"
|
||||
| "browser_action_launch"
|
||||
| "use_mcp_server"
|
||||
| "finishTask"
|
||||
|
||||
export type ClineSay =
|
||||
| "task"
|
||||
| "error"
|
||||
| "api_req_started"
|
||||
| "api_req_finished"
|
||||
| "api_req_retried"
|
||||
| "api_req_retry_delayed"
|
||||
| "api_req_deleted"
|
||||
| "text"
|
||||
| "reasoning"
|
||||
| "completion_result"
|
||||
| "user_feedback"
|
||||
| "user_feedback_diff"
|
||||
| "command_output"
|
||||
| "tool"
|
||||
| "shell_integration_warning"
|
||||
| "browser_action"
|
||||
| "browser_action_result"
|
||||
| "command"
|
||||
| "mcp_server_request_started"
|
||||
| "mcp_server_response"
|
||||
| "new_task_started"
|
||||
| "new_task"
|
||||
| "checkpoint_saved"
|
||||
| "rooignore_error"
|
||||
|
||||
export interface ClineMessage {
|
||||
ts: number
|
||||
type: "ask" | "say"
|
||||
ask?: ClineAsk
|
||||
say?: ClineSay
|
||||
text?: string
|
||||
images?: string[]
|
||||
partial?: boolean
|
||||
reasoning?: string
|
||||
conversationHistoryIndex?: number
|
||||
checkpoint?: Record<string, unknown>
|
||||
progressStatus?: ToolProgressStatus
|
||||
}
|
||||
|
||||
export interface ClineProvider {
|
||||
|
|
@ -82,11 +136,6 @@ export interface ClineProvider {
|
|||
*/
|
||||
cancelTask(): Promise<void>
|
||||
|
||||
/**
|
||||
* Clears the current task
|
||||
*/
|
||||
clearTask(): Promise<void>
|
||||
|
||||
/**
|
||||
* Gets the current state
|
||||
*/
|
||||
|
|
@ -112,12 +161,6 @@ export interface ClineProvider {
|
|||
*/
|
||||
storeSecret(key: SecretKey, value?: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Retrieves a secret value from secure storage
|
||||
* @param key The key of the secret to retrieve
|
||||
*/
|
||||
getSecret(key: SecretKey): Promise<string | undefined>
|
||||
|
||||
/**
|
||||
* Resets the state
|
||||
*/
|
||||
|
|
@ -11,15 +11,16 @@ try {
|
|||
console.warn("Failed to load environment variables:", e)
|
||||
}
|
||||
|
||||
import { ClineProvider } from "./core/webview/ClineProvider"
|
||||
import { createClineAPI } from "./exports"
|
||||
import "./utils/path" // Necessary to have access to String.prototype.toPosix.
|
||||
|
||||
import { ClineProvider } from "./core/webview/ClineProvider"
|
||||
import { CodeActionProvider } from "./core/CodeActionProvider"
|
||||
import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider"
|
||||
import { handleUri, registerCommands, registerCodeActions } from "./activate"
|
||||
import { McpServerManager } from "./services/mcp/McpServerManager"
|
||||
import { telemetryService } from "./services/telemetry/TelemetryService"
|
||||
|
||||
import { handleUri, registerCommands, registerCodeActions, createRooCodeAPI } from "./activate"
|
||||
|
||||
/**
|
||||
* Built using https://github.com/microsoft/vscode-webview-ui-toolkit
|
||||
*
|
||||
|
|
@ -31,18 +32,6 @@ import { telemetryService } from "./services/telemetry/TelemetryService"
|
|||
let outputChannel: vscode.OutputChannel
|
||||
let extensionContext: vscode.ExtensionContext
|
||||
|
||||
// Callback mapping of human relay response
|
||||
const humanRelayCallbacks = new Map<string, (response: string | undefined) => void>()
|
||||
|
||||
/**
|
||||
* Register a callback function for human relay response
|
||||
* @param requestId
|
||||
* @param callback
|
||||
*/
|
||||
export function registerHumanRelayCallback(requestId: string, callback: (response: string | undefined) => void): void {
|
||||
humanRelayCallbacks.set(requestId, callback)
|
||||
}
|
||||
|
||||
// This method is called when your extension is activated.
|
||||
// Your extension is activated the very first time the command is executed.
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
|
|
@ -72,40 +61,6 @@ export function activate(context: vscode.ExtensionContext) {
|
|||
|
||||
registerCommands({ context, outputChannel, provider: sidebarProvider })
|
||||
|
||||
// Register human relay callback registration command
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"roo-cline.registerHumanRelayCallback",
|
||||
(requestId: string, callback: (response: string | undefined) => void) => {
|
||||
registerHumanRelayCallback(requestId, callback)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Register human relay response processing command
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
"roo-cline.handleHumanRelayResponse",
|
||||
(response: { requestId: string; text?: string; cancelled?: boolean }) => {
|
||||
const callback = humanRelayCallbacks.get(response.requestId)
|
||||
if (callback) {
|
||||
if (response.cancelled) {
|
||||
callback(undefined)
|
||||
} else {
|
||||
callback(response.text)
|
||||
}
|
||||
humanRelayCallbacks.delete(response.requestId)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand("roo-cline.unregisterHumanRelayCallback", (requestId: string) => {
|
||||
humanRelayCallbacks.delete(requestId)
|
||||
}),
|
||||
)
|
||||
|
||||
/**
|
||||
* We use the text document content provider API to show the left side for diff
|
||||
* view by creating a virtual document for the original content. This makes it
|
||||
|
|
@ -143,7 +98,7 @@ export function activate(context: vscode.ExtensionContext) {
|
|||
|
||||
registerCodeActions(context)
|
||||
|
||||
return createClineAPI(outputChannel, sidebarProvider)
|
||||
return createRooCodeAPI(outputChannel, sidebarProvider)
|
||||
}
|
||||
|
||||
// This method is called when your extension is deactivated.
|
||||
|
|
|
|||
|
|
@ -1,13 +1,15 @@
|
|||
import * as vscode from "vscode"
|
||||
import * as fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { Browser, Page, ScreenshotOptions, TimeoutError, launch } from "puppeteer-core"
|
||||
import { Browser, Page, ScreenshotOptions, TimeoutError, launch, connect } from "puppeteer-core"
|
||||
// @ts-ignore
|
||||
import PCR from "puppeteer-chromium-resolver"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import delay from "delay"
|
||||
import axios from "axios"
|
||||
import { fileExistsAtPath } from "../../utils/fs"
|
||||
import { BrowserActionResult } from "../../shared/ExtensionMessage"
|
||||
import { discoverChromeInstances, testBrowserConnection } from "./browserDiscovery"
|
||||
|
||||
interface PCRStats {
|
||||
puppeteer: { launch: typeof launch }
|
||||
|
|
@ -19,11 +21,20 @@ export class BrowserSession {
|
|||
private browser?: Browser
|
||||
private page?: Page
|
||||
private currentMousePosition?: string
|
||||
private cachedWebSocketEndpoint?: string
|
||||
private lastConnectionAttempt: number = 0
|
||||
|
||||
constructor(context: vscode.ExtensionContext) {
|
||||
this.context = context
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to a remote browser
|
||||
*/
|
||||
async testConnection(host: string): Promise<{ success: boolean; message: string; endpoint?: string }> {
|
||||
return testBrowserConnection(host)
|
||||
}
|
||||
|
||||
private async ensureChromiumExists(): Promise<PCRStats> {
|
||||
const globalStoragePath = this.context?.globalStorageUri?.fsPath
|
||||
if (!globalStoragePath) {
|
||||
|
|
@ -52,17 +63,131 @@ export class BrowserSession {
|
|||
await this.closeBrowser() // this may happen when the model launches a browser again after having used it already before
|
||||
}
|
||||
|
||||
// Function to get viewport size
|
||||
const getViewport = () => {
|
||||
const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600"
|
||||
const [width, height] = size.split("x").map(Number)
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
// Check if remote browser connection is enabled
|
||||
const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as boolean | undefined
|
||||
|
||||
// If remote browser connection is not enabled, use local browser
|
||||
if (!remoteBrowserEnabled) {
|
||||
console.log("Remote browser connection is disabled, using local browser")
|
||||
const stats = await this.ensureChromiumExists()
|
||||
this.browser = await stats.puppeteer.launch({
|
||||
args: [
|
||||
"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
],
|
||||
executablePath: stats.executablePath,
|
||||
defaultViewport: getViewport(),
|
||||
// headless: false,
|
||||
})
|
||||
this.page = await this.browser?.newPage()
|
||||
return
|
||||
}
|
||||
// Remote browser connection is enabled
|
||||
let remoteBrowserHost = this.context.globalState.get("remoteBrowserHost") as string | undefined
|
||||
let browserWSEndpoint: string | undefined = this.cachedWebSocketEndpoint
|
||||
let reconnectionAttempted = false
|
||||
|
||||
// Try to connect with cached endpoint first if it exists and is recent (less than 1 hour old)
|
||||
if (browserWSEndpoint && Date.now() - this.lastConnectionAttempt < 3600000) {
|
||||
try {
|
||||
console.log(`Attempting to connect using cached WebSocket endpoint: ${browserWSEndpoint}`)
|
||||
this.browser = await connect({
|
||||
browserWSEndpoint,
|
||||
defaultViewport: getViewport(),
|
||||
})
|
||||
this.page = await this.browser?.newPage()
|
||||
return
|
||||
} catch (error) {
|
||||
console.log(`Failed to connect using cached endpoint: ${error}`)
|
||||
// Clear the cached endpoint since it's no longer valid
|
||||
this.cachedWebSocketEndpoint = undefined
|
||||
// User wants to give up after one reconnection attempt
|
||||
if (remoteBrowserHost) {
|
||||
reconnectionAttempted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If user provided a remote browser host, try to connect to it
|
||||
if (remoteBrowserHost && !reconnectionAttempted) {
|
||||
console.log(`Attempting to connect to remote browser at ${remoteBrowserHost}`)
|
||||
try {
|
||||
// Fetch the WebSocket endpoint from the Chrome DevTools Protocol
|
||||
const versionUrl = `${remoteBrowserHost.replace(/\/$/, "")}/json/version`
|
||||
console.log(`Fetching WebSocket endpoint from ${versionUrl}`)
|
||||
|
||||
const response = await axios.get(versionUrl)
|
||||
browserWSEndpoint = response.data.webSocketDebuggerUrl
|
||||
|
||||
if (!browserWSEndpoint) {
|
||||
throw new Error("Could not find webSocketDebuggerUrl in the response")
|
||||
}
|
||||
|
||||
console.log(`Found WebSocket endpoint: ${browserWSEndpoint}`)
|
||||
|
||||
// Cache the successful endpoint
|
||||
this.cachedWebSocketEndpoint = browserWSEndpoint
|
||||
this.lastConnectionAttempt = Date.now()
|
||||
|
||||
this.browser = await connect({
|
||||
browserWSEndpoint,
|
||||
defaultViewport: getViewport(),
|
||||
})
|
||||
this.page = await this.browser?.newPage()
|
||||
return
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to remote browser: ${error}`)
|
||||
// Fall back to auto-discovery if remote connection fails
|
||||
}
|
||||
}
|
||||
|
||||
// Always try auto-discovery if no custom URL is specified or if connection failed
|
||||
try {
|
||||
console.log("Attempting auto-discovery...")
|
||||
const discoveredHost = await discoverChromeInstances()
|
||||
|
||||
if (discoveredHost) {
|
||||
console.log(`Auto-discovered Chrome at ${discoveredHost}`)
|
||||
|
||||
// Don't save the discovered host to global state to avoid overriding user preference
|
||||
// We'll just use it for this session
|
||||
|
||||
// Try to connect to the discovered host
|
||||
const testResult = await testBrowserConnection(discoveredHost)
|
||||
|
||||
if (testResult.success && testResult.endpoint) {
|
||||
// Cache the successful endpoint
|
||||
this.cachedWebSocketEndpoint = testResult.endpoint
|
||||
this.lastConnectionAttempt = Date.now()
|
||||
|
||||
this.browser = await connect({
|
||||
browserWSEndpoint: testResult.endpoint,
|
||||
defaultViewport: getViewport(),
|
||||
})
|
||||
this.page = await this.browser?.newPage()
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Auto-discovery failed: ${error}`)
|
||||
// Fall back to local browser if auto-discovery fails
|
||||
}
|
||||
|
||||
// If all remote connection attempts fail, fall back to local browser
|
||||
console.log("Falling back to local browser")
|
||||
const stats = await this.ensureChromiumExists()
|
||||
this.browser = await stats.puppeteer.launch({
|
||||
args: [
|
||||
"--user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36",
|
||||
],
|
||||
executablePath: stats.executablePath,
|
||||
defaultViewport: (() => {
|
||||
const size = (this.context.globalState.get("browserViewportSize") as string | undefined) || "900x600"
|
||||
const [width, height] = size.split("x").map(Number)
|
||||
return { width, height }
|
||||
})(),
|
||||
defaultViewport: getViewport(),
|
||||
// headless: false,
|
||||
})
|
||||
// (latest version of puppeteer does not add headless to user agent)
|
||||
|
|
@ -72,7 +197,14 @@ export class BrowserSession {
|
|||
async closeBrowser(): Promise<BrowserActionResult> {
|
||||
if (this.browser || this.page) {
|
||||
console.log("closing browser...")
|
||||
await this.browser?.close().catch(() => {})
|
||||
|
||||
const remoteBrowserEnabled = this.context.globalState.get("remoteBrowserEnabled") as string | undefined
|
||||
if (remoteBrowserEnabled && this.browser) {
|
||||
await this.browser.disconnect().catch(() => {})
|
||||
} else {
|
||||
await this.browser?.close().catch(() => {})
|
||||
}
|
||||
|
||||
this.browser = undefined
|
||||
this.page = undefined
|
||||
this.currentMousePosition = undefined
|
||||
|
|
|
|||
246
src/services/browser/browserDiscovery.ts
Normal file
246
src/services/browser/browserDiscovery.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import * as vscode from "vscode"
|
||||
import * as os from "os"
|
||||
import * as net from "net"
|
||||
import axios from "axios"
|
||||
|
||||
/**
|
||||
* Check if a port is open on a given host
|
||||
*/
|
||||
export async function isPortOpen(host: string, port: number, timeout = 1000): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket()
|
||||
let status = false
|
||||
|
||||
// Set timeout
|
||||
socket.setTimeout(timeout)
|
||||
|
||||
// Handle successful connection
|
||||
socket.on("connect", () => {
|
||||
status = true
|
||||
socket.destroy()
|
||||
})
|
||||
|
||||
// Handle any errors
|
||||
socket.on("error", () => {
|
||||
socket.destroy()
|
||||
})
|
||||
|
||||
// Handle timeout
|
||||
socket.on("timeout", () => {
|
||||
socket.destroy()
|
||||
})
|
||||
|
||||
// Handle close
|
||||
socket.on("close", () => {
|
||||
resolve(status)
|
||||
})
|
||||
|
||||
// Attempt to connect
|
||||
socket.connect(port, host)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to connect to Chrome at a specific IP address
|
||||
*/
|
||||
export async function tryConnect(ipAddress: string): Promise<{ endpoint: string; ip: string } | null> {
|
||||
try {
|
||||
console.log(`Trying to connect to Chrome at: http://${ipAddress}:9222/json/version`)
|
||||
const response = await axios.get(`http://${ipAddress}:9222/json/version`, { timeout: 1000 })
|
||||
const data = response.data
|
||||
return { endpoint: data.webSocketDebuggerUrl, ip: ipAddress }
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a shell command and return stdout and stderr
|
||||
*/
|
||||
export async function executeShellCommand(command: string): Promise<{ stdout: string; stderr: string }> {
|
||||
return new Promise<{ stdout: string; stderr: string }>((resolve) => {
|
||||
const cp = require("child_process")
|
||||
cp.exec(command, (err: any, stdout: string, stderr: string) => {
|
||||
resolve({ stdout, stderr })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Docker gateway IP without UI feedback
|
||||
*/
|
||||
export async function getDockerGatewayIP(): Promise<string | null> {
|
||||
try {
|
||||
if (process.platform === "linux") {
|
||||
try {
|
||||
const { stdout } = await executeShellCommand("ip route | grep default | awk '{print $3}'")
|
||||
return stdout.trim()
|
||||
} catch (error) {
|
||||
console.log("Could not determine Docker gateway IP:", error)
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
console.log("Could not determine Docker gateway IP:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Docker host IP
|
||||
*/
|
||||
export async function getDockerHostIP(): Promise<string | null> {
|
||||
try {
|
||||
// Try to resolve host.docker.internal (works on Docker Desktop)
|
||||
return new Promise((resolve) => {
|
||||
const dns = require("dns")
|
||||
dns.lookup("host.docker.internal", (err: any, address: string) => {
|
||||
if (err) {
|
||||
resolve(null)
|
||||
} else {
|
||||
resolve(address)
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error) {
|
||||
console.log("Could not determine Docker host IP:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a network range for Chrome debugging port
|
||||
*/
|
||||
export async function scanNetworkForChrome(baseIP: string): Promise<string | null> {
|
||||
if (!baseIP || !baseIP.match(/^\d+\.\d+\.\d+\./)) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract the network prefix (e.g., "192.168.65.")
|
||||
const networkPrefix = baseIP.split(".").slice(0, 3).join(".") + "."
|
||||
|
||||
// Common Docker host IPs to try first
|
||||
const priorityIPs = [
|
||||
networkPrefix + "1", // Common gateway
|
||||
networkPrefix + "2", // Common host
|
||||
networkPrefix + "254", // Common host in some Docker setups
|
||||
]
|
||||
|
||||
console.log(`Scanning priority IPs in network ${networkPrefix}*`)
|
||||
|
||||
// Check priority IPs first
|
||||
for (const ip of priorityIPs) {
|
||||
const isOpen = await isPortOpen(ip, 9222)
|
||||
if (isOpen) {
|
||||
console.log(`Found Chrome debugging port open on ${ip}`)
|
||||
return ip
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover Chrome instances on the network
|
||||
*/
|
||||
export async function discoverChromeInstances(): Promise<string | null> {
|
||||
// Get all network interfaces
|
||||
const networkInterfaces = os.networkInterfaces()
|
||||
const ipAddresses = []
|
||||
|
||||
// Always try localhost first
|
||||
ipAddresses.push("localhost")
|
||||
ipAddresses.push("127.0.0.1")
|
||||
|
||||
// Try to get Docker gateway IP (headless mode)
|
||||
const gatewayIP = await getDockerGatewayIP()
|
||||
if (gatewayIP) {
|
||||
console.log("Found Docker gateway IP:", gatewayIP)
|
||||
ipAddresses.push(gatewayIP)
|
||||
}
|
||||
|
||||
// Try to get Docker host IP
|
||||
const hostIP = await getDockerHostIP()
|
||||
if (hostIP) {
|
||||
console.log("Found Docker host IP:", hostIP)
|
||||
ipAddresses.push(hostIP)
|
||||
}
|
||||
|
||||
// Add all local IP addresses from network interfaces
|
||||
const localIPs: string[] = []
|
||||
Object.values(networkInterfaces).forEach((interfaces) => {
|
||||
if (!interfaces) return
|
||||
interfaces.forEach((iface) => {
|
||||
// Only consider IPv4 addresses
|
||||
if (iface.family === "IPv4" || iface.family === (4 as any)) {
|
||||
localIPs.push(iface.address)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Add local IPs to the list
|
||||
ipAddresses.push(...localIPs)
|
||||
|
||||
// Scan network for Chrome debugging port
|
||||
for (const ip of localIPs) {
|
||||
const chromeIP = await scanNetworkForChrome(ip)
|
||||
if (chromeIP && !ipAddresses.includes(chromeIP)) {
|
||||
console.log("Found potential Chrome host via network scan:", chromeIP)
|
||||
ipAddresses.push(chromeIP)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
const uniqueIPs = [...new Set(ipAddresses)]
|
||||
console.log("IP Addresses to try:", uniqueIPs)
|
||||
|
||||
// Try connecting to each IP address
|
||||
for (const ip of uniqueIPs) {
|
||||
const connection = await tryConnect(ip)
|
||||
if (connection) {
|
||||
console.log(`Successfully connected to Chrome at: ${connection.ip}`)
|
||||
// Store the successful IP for future use
|
||||
console.log(`✅ Found Chrome at ${connection.ip} - You can hardcode this IP if needed`)
|
||||
|
||||
// Return the host URL and endpoint
|
||||
return `http://${connection.ip}:9222`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to a remote browser
|
||||
*/
|
||||
export async function testBrowserConnection(
|
||||
host: string,
|
||||
): Promise<{ success: boolean; message: string; endpoint?: string }> {
|
||||
try {
|
||||
// Fetch the WebSocket endpoint from the Chrome DevTools Protocol
|
||||
const versionUrl = `${host.replace(/\/$/, "")}/json/version`
|
||||
console.log(`Testing connection to ${versionUrl}`)
|
||||
|
||||
const response = await axios.get(versionUrl, { timeout: 3000 })
|
||||
const browserWSEndpoint = response.data.webSocketDebuggerUrl
|
||||
|
||||
if (!browserWSEndpoint) {
|
||||
return {
|
||||
success: false,
|
||||
message: "Could not find webSocketDebuggerUrl in the response",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Successfully connected to Chrome browser",
|
||||
endpoint: browserWSEndpoint,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to connect to remote browser: ${error}`)
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to connect: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
// type that represents json data that is sent from extension to webview, called ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or 'settingsButtonClicked' or 'hello'
|
||||
|
||||
import { ApiConfiguration, ApiProvider, ModelInfo } from "./api"
|
||||
import { HistoryItem } from "./HistoryItem"
|
||||
import { McpServer } from "./mcp"
|
||||
|
|
@ -9,6 +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"
|
||||
|
||||
export interface LanguageModelChatSelector {
|
||||
vendor?: string
|
||||
|
|
@ -17,7 +16,9 @@ export interface LanguageModelChatSelector {
|
|||
id?: string
|
||||
}
|
||||
|
||||
// webview will hold state
|
||||
// Represents JSON data that is sent from extension to webview, called
|
||||
// ExtensionMessage and has 'type' enum which can be 'plusButtonClicked' or
|
||||
// 'settingsButtonClicked' or 'hello'. Webview will hold state.
|
||||
export interface ExtensionMessage {
|
||||
type:
|
||||
| "action"
|
||||
|
|
@ -51,6 +52,8 @@ export interface ExtensionMessage {
|
|||
| "humanRelayResponse"
|
||||
| "humanRelayCancel"
|
||||
| "browserToolEnabled"
|
||||
| "browserConnectionResult"
|
||||
| "remoteBrowserEnabled"
|
||||
text?: string
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
|
|
@ -83,6 +86,8 @@ export interface ExtensionMessage {
|
|||
mode?: Mode
|
||||
customMode?: ModeConfig
|
||||
slug?: string
|
||||
success?: boolean
|
||||
values?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ApiConfigMeta {
|
||||
|
|
@ -123,6 +128,8 @@ export interface ExtensionState {
|
|||
checkpointStorage: CheckpointStorage
|
||||
browserViewportSize?: string
|
||||
screenshotQuality?: number
|
||||
remoteBrowserHost?: string
|
||||
remoteBrowserEnabled?: boolean
|
||||
fuzzyMatchThreshold?: number
|
||||
preferredLanguage: string
|
||||
writeDelayMs: number
|
||||
|
|
@ -145,58 +152,7 @@ export interface ExtensionState {
|
|||
showRooIgnoredFiles: boolean // Whether to show .rooignore'd files in listings
|
||||
}
|
||||
|
||||
export interface ClineMessage {
|
||||
ts: number
|
||||
type: "ask" | "say"
|
||||
ask?: ClineAsk
|
||||
say?: ClineSay
|
||||
text?: string
|
||||
images?: string[]
|
||||
partial?: boolean
|
||||
reasoning?: string
|
||||
conversationHistoryIndex?: number
|
||||
checkpoint?: Record<string, unknown>
|
||||
}
|
||||
|
||||
export type ClineAsk =
|
||||
| "followup"
|
||||
| "command"
|
||||
| "command_output"
|
||||
| "completion_result"
|
||||
| "tool"
|
||||
| "api_req_failed"
|
||||
| "resume_task"
|
||||
| "resume_completed_task"
|
||||
| "mistake_limit_reached"
|
||||
| "browser_action_launch"
|
||||
| "use_mcp_server"
|
||||
| "finishTask"
|
||||
|
||||
export type ClineSay =
|
||||
| "task"
|
||||
| "error"
|
||||
| "api_req_started"
|
||||
| "api_req_finished"
|
||||
| "api_req_retried"
|
||||
| "api_req_retry_delayed"
|
||||
| "api_req_deleted"
|
||||
| "text"
|
||||
| "reasoning"
|
||||
| "completion_result"
|
||||
| "user_feedback"
|
||||
| "user_feedback_diff"
|
||||
| "command_output"
|
||||
| "tool"
|
||||
| "shell_integration_warning"
|
||||
| "browser_action"
|
||||
| "browser_action_result"
|
||||
| "command"
|
||||
| "mcp_server_request_started"
|
||||
| "mcp_server_response"
|
||||
| "new_task_started"
|
||||
| "new_task"
|
||||
| "checkpoint_saved"
|
||||
| "rooignore_error"
|
||||
export type { ClineMessage, ClineAsk, ClineSay }
|
||||
|
||||
export interface ClineSayTool {
|
||||
tool:
|
||||
|
|
@ -220,8 +176,9 @@ export interface ClineSayTool {
|
|||
reason?: string
|
||||
}
|
||||
|
||||
// must keep in sync with system prompt
|
||||
// Must keep in sync with system prompt.
|
||||
export const browserActions = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
|
||||
|
||||
export type BrowserAction = (typeof browserActions)[number]
|
||||
|
||||
export interface ClineSayBrowserAction {
|
||||
|
|
@ -256,7 +213,6 @@ export interface ClineApiReqInfo {
|
|||
streamingFailedMessage?: string
|
||||
}
|
||||
|
||||
// Human relay related message types
|
||||
export interface ShowHumanRelayDialogMessage {
|
||||
type: "showHumanRelayDialog"
|
||||
requestId: string
|
||||
|
|
@ -275,3 +231,8 @@ export interface HumanRelayCancelMessage {
|
|||
}
|
||||
|
||||
export type ClineApiReqCancelReason = "streaming_failed" | "user_cancelled"
|
||||
|
||||
export type ToolProgressStatus = {
|
||||
icon?: string
|
||||
text?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ export interface WebviewMessage {
|
|||
| "checkpointStorage"
|
||||
| "browserViewportSize"
|
||||
| "screenshotQuality"
|
||||
| "remoteBrowserHost"
|
||||
| "openMcpSettings"
|
||||
| "restartMcpServer"
|
||||
| "toggleToolAlwaysAllow"
|
||||
|
|
@ -102,6 +103,10 @@ export interface WebviewMessage {
|
|||
| "browserToolEnabled"
|
||||
| "telemetrySetting"
|
||||
| "showRooIgnoredFiles"
|
||||
| "testBrowserConnection"
|
||||
| "discoverBrowser"
|
||||
| "browserConnectionResult"
|
||||
| "remoteBrowserEnabled"
|
||||
text?: string
|
||||
disabled?: boolean
|
||||
askResponse?: ClineAskResponse
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ export interface ApiHandlerOptions {
|
|||
awspromptCacheId?: string
|
||||
awsProfile?: string
|
||||
awsUseProfile?: boolean
|
||||
awsCustomArn?: string
|
||||
vertexKeyFile?: string
|
||||
vertexJsonCredentials?: string
|
||||
vertexProjectId?: string
|
||||
|
|
@ -99,6 +100,7 @@ export const API_CONFIG_KEYS: GlobalStateKey[] = [
|
|||
// "awspromptCacheId", // NOT exist on GlobalStateKey
|
||||
"awsProfile",
|
||||
"awsUseProfile",
|
||||
"awsCustomArn",
|
||||
"vertexKeyFile",
|
||||
"vertexJsonCredentials",
|
||||
"vertexProjectId",
|
||||
|
|
@ -496,6 +498,14 @@ export const vertexModels = {
|
|||
inputPrice: 0.15,
|
||||
outputPrice: 0.6,
|
||||
},
|
||||
"gemini-2.0-pro-exp-02-05": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 2_097_152,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
},
|
||||
"gemini-2.0-flash-lite-001": {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 1_048_576,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export const GLOBAL_STATE_KEYS = [
|
|||
"awsUseCrossRegionInference",
|
||||
"awsProfile",
|
||||
"awsUseProfile",
|
||||
"awsCustomArn",
|
||||
"vertexKeyFile",
|
||||
"vertexJsonCredentials",
|
||||
"vertexProjectId",
|
||||
|
|
@ -66,6 +67,7 @@ export const GLOBAL_STATE_KEYS = [
|
|||
"checkpointStorage",
|
||||
"browserViewportSize",
|
||||
"screenshotQuality",
|
||||
"remoteBrowserHost",
|
||||
"fuzzyMatchThreshold",
|
||||
"preferredLanguage", // Language setting for Cline's communication
|
||||
"writeDelayMs",
|
||||
|
|
@ -100,6 +102,7 @@ export const GLOBAL_STATE_KEYS = [
|
|||
"lmStudioDraftModelId",
|
||||
"telemetrySetting",
|
||||
"showRooIgnoredFiles",
|
||||
"remoteBrowserEnabled",
|
||||
] as const
|
||||
|
||||
// Derive the type from the array - creates a union of string literals
|
||||
|
|
|
|||
|
|
@ -258,6 +258,7 @@ export const ChatRowContent = ({
|
|||
<span style={{ fontWeight: "bold" }}>Roo wants to edit this file:</span>
|
||||
</div>
|
||||
<CodeAccordian
|
||||
progressStatus={message.progressStatus}
|
||||
isLoading={message.partial}
|
||||
diff={tool.diff!}
|
||||
path={tool.path!}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { memo, useMemo } from "react"
|
||||
import { getLanguageFromPath } from "../../utils/getLanguageFromPath"
|
||||
import CodeBlock, { CODE_BLOCK_BG_COLOR } from "./CodeBlock"
|
||||
import { ToolProgressStatus } from "../../../../src/shared/ExtensionMessage"
|
||||
|
||||
interface CodeAccordianProps {
|
||||
code?: string
|
||||
|
|
@ -12,6 +13,7 @@ interface CodeAccordianProps {
|
|||
isExpanded: boolean
|
||||
onToggleExpand: () => void
|
||||
isLoading?: boolean
|
||||
progressStatus?: ToolProgressStatus
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -32,6 +34,7 @@ const CodeAccordian = ({
|
|||
isExpanded,
|
||||
onToggleExpand,
|
||||
isLoading,
|
||||
progressStatus,
|
||||
}: CodeAccordianProps) => {
|
||||
const inferredLanguage = useMemo(
|
||||
() => code && (language ?? (path ? getLanguageFromPath(path) : undefined)),
|
||||
|
|
@ -95,6 +98,14 @@ const CodeAccordian = ({
|
|||
</>
|
||||
)}
|
||||
<div style={{ flexGrow: 1 }}></div>
|
||||
{progressStatus && progressStatus.text && (
|
||||
<>
|
||||
{progressStatus.icon && <span className={`codicon codicon-${progressStatus.icon} mr-1`} />}
|
||||
<span className="mr-1 ml-auto text-vscode-descriptionForeground">
|
||||
{progressStatus.text}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
<span className={`codicon codicon-chevron-${isExpanded ? "up" : "down"}`}></span>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ import { VSCodeButtonLink } from "../common/VSCodeButtonLink"
|
|||
import { ModelInfoView } from "./ModelInfoView"
|
||||
import { ModelPicker } from "./ModelPicker"
|
||||
import { TemperatureControl } from "./TemperatureControl"
|
||||
import { validateApiConfiguration, validateModelId } from "@/utils/validate"
|
||||
import { validateApiConfiguration, validateModelId, validateBedrockArn } from "@/utils/validate"
|
||||
import { ApiErrorMessage } from "./ApiErrorMessage"
|
||||
import { ThinkingBudget } from "./ThinkingBudget"
|
||||
|
||||
|
|
@ -1267,14 +1267,82 @@ const ApiOptions = ({
|
|||
</label>
|
||||
<Dropdown
|
||||
id="model-id"
|
||||
value={selectedModelId}
|
||||
value={selectedModelId === "custom-arn" ? "custom-arn" : selectedModelId}
|
||||
onChange={(value) => {
|
||||
setApiConfigurationField("apiModelId", typeof value == "string" ? value : value?.value)
|
||||
const modelValue = typeof value == "string" ? value : value?.value
|
||||
setApiConfigurationField("apiModelId", modelValue)
|
||||
|
||||
// Clear custom ARN if not using custom ARN option
|
||||
if (modelValue !== "custom-arn" && selectedProvider === "bedrock") {
|
||||
setApiConfigurationField("awsCustomArn", "")
|
||||
}
|
||||
}}
|
||||
options={selectedProviderModelOptions}
|
||||
options={[
|
||||
...selectedProviderModelOptions,
|
||||
...(selectedProvider === "bedrock"
|
||||
? [{ value: "custom-arn", label: "Use custom ARN..." }]
|
||||
: []),
|
||||
]}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedProvider === "bedrock" && selectedModelId === "custom-arn" && (
|
||||
<>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.awsCustomArn || ""}
|
||||
onInput={(e) => {
|
||||
const value = (e.target as HTMLInputElement).value
|
||||
setApiConfigurationField("awsCustomArn", value)
|
||||
}}
|
||||
placeholder="Enter ARN (e.g. arn:aws:bedrock:us-east-1:123456789012:foundation-model/my-model)"
|
||||
className="w-full">
|
||||
<span className="font-medium">Custom ARN</span>
|
||||
</VSCodeTextField>
|
||||
<div className="text-sm text-vscode-descriptionForeground -mt-2">
|
||||
Enter a valid AWS Bedrock ARN for the model you want to use. Format examples:
|
||||
<ul className="list-disc pl-5 mt-1">
|
||||
<li>
|
||||
arn:aws:bedrock:us-east-1:123456789012:foundation-model/anthropic.claude-3-sonnet-20240229-v1:0
|
||||
</li>
|
||||
<li>
|
||||
arn:aws:bedrock:us-west-2:123456789012:provisioned-model/my-provisioned-model
|
||||
</li>
|
||||
<li>
|
||||
arn:aws:bedrock:us-east-1:123456789012:default-prompt-router/anthropic.claude:1
|
||||
</li>
|
||||
</ul>
|
||||
Make sure the region in the ARN matches your selected AWS Region above.
|
||||
</div>
|
||||
{apiConfiguration?.awsCustomArn &&
|
||||
(() => {
|
||||
const validation = validateBedrockArn(
|
||||
apiConfiguration.awsCustomArn,
|
||||
apiConfiguration.awsRegion,
|
||||
)
|
||||
|
||||
if (!validation.isValid) {
|
||||
return (
|
||||
<div className="text-sm text-vscode-errorForeground mt-2">
|
||||
{validation.errorMessage ||
|
||||
"Invalid ARN format. Please check the examples above."}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (validation.errorMessage) {
|
||||
return (
|
||||
<div className="text-sm text-vscode-errorForeground mt-2">
|
||||
{validation.errorMessage}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
})()}
|
||||
=======
|
||||
</>
|
||||
)}
|
||||
<ModelInfoView
|
||||
selectedModelId={selectedModelId}
|
||||
modelInfo={selectedModelInfo}
|
||||
|
|
@ -1333,6 +1401,19 @@ export function normalizeApiConfiguration(apiConfiguration?: ApiConfiguration) {
|
|||
case "anthropic":
|
||||
return getProviderData(anthropicModels, anthropicDefaultModelId)
|
||||
case "bedrock":
|
||||
// Special case for custom ARN
|
||||
if (modelId === "custom-arn") {
|
||||
return {
|
||||
selectedProvider: provider,
|
||||
selectedModelId: "custom-arn",
|
||||
selectedModelInfo: {
|
||||
maxTokens: 5000,
|
||||
contextWindow: 128_000,
|
||||
supportsPromptCache: false,
|
||||
supportsImages: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
return getProviderData(bedrockModels, bedrockDefaultModelId)
|
||||
case "vertex":
|
||||
return getProviderData(vertexModels, vertexDefaultModelId)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { HTMLAttributes } from "react"
|
||||
import { VSCodeCheckbox } from "@vscode/webview-ui-toolkit/react"
|
||||
import React, { HTMLAttributes, useState, useEffect } from "react"
|
||||
import { VSCodeButton, VSCodeCheckbox, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
|
||||
import { Dropdown, type DropdownOption } from "vscrui"
|
||||
import { SquareMousePointer } from "lucide-react"
|
||||
|
||||
|
|
@ -7,21 +7,96 @@ import { SetCachedStateField } from "./types"
|
|||
import { sliderLabelStyle } from "./styles"
|
||||
import { SectionHeader } from "./SectionHeader"
|
||||
import { Section } from "./Section"
|
||||
import { vscode } from "../../utils/vscode"
|
||||
|
||||
type BrowserSettingsProps = HTMLAttributes<HTMLDivElement> & {
|
||||
browserToolEnabled?: boolean
|
||||
browserViewportSize?: string
|
||||
screenshotQuality?: number
|
||||
setCachedStateField: SetCachedStateField<"browserToolEnabled" | "browserViewportSize" | "screenshotQuality">
|
||||
remoteBrowserHost?: string
|
||||
remoteBrowserEnabled?: boolean
|
||||
setCachedStateField: SetCachedStateField<
|
||||
| "browserToolEnabled"
|
||||
| "browserViewportSize"
|
||||
| "screenshotQuality"
|
||||
| "remoteBrowserHost"
|
||||
| "remoteBrowserEnabled"
|
||||
>
|
||||
}
|
||||
|
||||
export const BrowserSettings = ({
|
||||
browserToolEnabled,
|
||||
browserViewportSize,
|
||||
screenshotQuality,
|
||||
remoteBrowserHost,
|
||||
remoteBrowserEnabled,
|
||||
setCachedStateField,
|
||||
...props
|
||||
}: BrowserSettingsProps) => {
|
||||
const [testingConnection, setTestingConnection] = useState(false)
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const [discovering, setDiscovering] = useState(false)
|
||||
// We don't need a local state for useRemoteBrowser since we're using the enableRemoteBrowser prop directly
|
||||
// This ensures the checkbox always reflects the current global state
|
||||
|
||||
// Set up message listener for browser connection results
|
||||
useEffect(() => {
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
const message = event.data
|
||||
|
||||
if (message.type === "browserConnectionResult") {
|
||||
setTestResult({
|
||||
success: message.success,
|
||||
message: message.text,
|
||||
})
|
||||
setTestingConnection(false)
|
||||
setDiscovering(false)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("message", handleMessage)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const testConnection = async () => {
|
||||
setTestingConnection(true)
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
// Send a message to the extension to test the connection
|
||||
vscode.postMessage({
|
||||
type: "testBrowserConnection",
|
||||
text: remoteBrowserHost,
|
||||
})
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
})
|
||||
setTestingConnection(false)
|
||||
}
|
||||
}
|
||||
|
||||
const discoverBrowser = async () => {
|
||||
setDiscovering(true)
|
||||
setTestResult(null)
|
||||
|
||||
try {
|
||||
// Send a message to the extension to discover Chrome instances
|
||||
vscode.postMessage({
|
||||
type: "discoverBrowser",
|
||||
})
|
||||
} catch (error) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
message: `Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
})
|
||||
setDiscovering(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div {...props}>
|
||||
<SectionHeader>
|
||||
|
|
@ -96,6 +171,64 @@ export const BrowserSettings = ({
|
|||
screenshots but increase token usage.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="mb-2">
|
||||
<VSCodeCheckbox
|
||||
checked={remoteBrowserEnabled}
|
||||
onChange={(e: any) => {
|
||||
// Update the global state - remoteBrowserEnabled now means "enable remote browser connection"
|
||||
setCachedStateField("remoteBrowserEnabled", e.target.checked)
|
||||
if (!e.target.checked) {
|
||||
// If disabling remote browser, clear the custom URL
|
||||
setCachedStateField("remoteBrowserHost", undefined)
|
||||
}
|
||||
}}>
|
||||
<span className="font-medium">Use remote browser connection</span>
|
||||
</VSCodeCheckbox>
|
||||
<p className="text-vscode-descriptionForeground text-sm mt-0 ml-6">
|
||||
Connect to a Chrome browser running with remote debugging enabled
|
||||
(--remote-debugging-port=9222).
|
||||
</p>
|
||||
</div>
|
||||
{remoteBrowserEnabled && (
|
||||
<>
|
||||
<div style={{ display: "flex", gap: "5px", marginTop: "10px" }}>
|
||||
<VSCodeTextField
|
||||
value={remoteBrowserHost ?? ""}
|
||||
onChange={(e: any) =>
|
||||
setCachedStateField(
|
||||
"remoteBrowserHost",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
placeholder="Custom URL (e.g., http://localhost:9222)"
|
||||
style={{ flexGrow: 1 }}
|
||||
/>
|
||||
<VSCodeButton
|
||||
disabled={testingConnection}
|
||||
onClick={remoteBrowserHost ? testConnection : discoverBrowser}>
|
||||
{testingConnection || discovering ? "Testing..." : "Test Connection"}
|
||||
</VSCodeButton>
|
||||
</div>
|
||||
{testResult && (
|
||||
<div
|
||||
className={`p-2 mt-2 mb-2 rounded text-sm ${
|
||||
testResult.success
|
||||
? "bg-green-800/20 text-green-400"
|
||||
: "bg-red-800/20 text-red-400"
|
||||
}`}>
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-vscode-descriptionForeground text-sm mt-2">
|
||||
Enter the DevTools Protocol host address or
|
||||
<strong> leave empty to auto-discover Chrome local instances.</strong>
|
||||
The Test Connection button will try the custom URL if provided, or
|
||||
auto-discover if the field is empty.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { forwardRef, memo, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react"
|
||||
import { Button as VSCodeButton } from "vscrui"
|
||||
import { CheckCheck, SquareMousePointer, Webhook, GitBranch, Bell, Cog, FlaskConical } from "lucide-react"
|
||||
import {
|
||||
CheckCheck,
|
||||
SquareMousePointer,
|
||||
Webhook,
|
||||
GitBranch,
|
||||
Bell,
|
||||
Cog,
|
||||
FlaskConical,
|
||||
AlertTriangle,
|
||||
} from "lucide-react"
|
||||
|
||||
import { ApiConfiguration } from "../../../../src/shared/api"
|
||||
import { ExperimentId } from "../../../../src/shared/experiments"
|
||||
|
|
@ -78,6 +87,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
mcpEnabled,
|
||||
rateLimitSeconds,
|
||||
requestDelaySeconds,
|
||||
remoteBrowserHost,
|
||||
screenshotQuality,
|
||||
soundEnabled,
|
||||
soundVolume,
|
||||
|
|
@ -85,6 +95,7 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
terminalOutputLimit,
|
||||
writeDelayMs,
|
||||
showRooIgnoredFiles,
|
||||
remoteBrowserEnabled,
|
||||
} = cachedState
|
||||
|
||||
// Make sure apiConfiguration is initialized and managed by SettingsView.
|
||||
|
|
@ -173,6 +184,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
vscode.postMessage({ type: "enableCheckpoints", bool: enableCheckpoints })
|
||||
vscode.postMessage({ type: "checkpointStorage", text: checkpointStorage })
|
||||
vscode.postMessage({ type: "browserViewportSize", text: browserViewportSize })
|
||||
vscode.postMessage({ type: "remoteBrowserHost", text: remoteBrowserHost })
|
||||
vscode.postMessage({ type: "remoteBrowserEnabled", bool: remoteBrowserEnabled })
|
||||
vscode.postMessage({ type: "fuzzyMatchThreshold", value: fuzzyMatchThreshold ?? 1.0 })
|
||||
vscode.postMessage({ type: "writeDelayMs", value: writeDelayMs })
|
||||
vscode.postMessage({ type: "screenshotQuality", value: screenshotQuality ?? 75 })
|
||||
|
|
@ -367,6 +380,8 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
browserToolEnabled={browserToolEnabled}
|
||||
browserViewportSize={browserViewportSize}
|
||||
screenshotQuality={screenshotQuality}
|
||||
remoteBrowserHost={remoteBrowserHost}
|
||||
remoteBrowserEnabled={remoteBrowserEnabled}
|
||||
setCachedStateField={setCachedStateField}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -419,15 +434,17 @@ const SettingsView = forwardRef<SettingsViewRef, SettingsViewProps>(({ onDone },
|
|||
<AlertDialog open={isDiscardDialogShow} onOpenChange={setDiscardDialogShow}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unsaved changes</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span className={`codicon codicon-warning align-middle mr-1`} />
|
||||
Do you want to discard changes and continue?
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>
|
||||
<AlertTriangle className="w-5 h-5 text-yellow-500" />
|
||||
Unsaved Changes
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>Do you want to discard changes and continue?</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogAction onClick={() => onConfirmDialogResult(true)}>Yes</AlertDialogAction>
|
||||
<AlertDialogCancel onClick={() => onConfirmDialogResult(false)}>No</AlertDialogCancel>
|
||||
<AlertDialogCancel onClick={() => onConfirmDialogResult(false)}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => onConfirmDialogResult(true)}>
|
||||
Discard changes
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof
|
|||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-vscode-editor-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-3 rounded-sm border border-vscode-panel-border p-4 shadow-lg duration-200 sm:max-w-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -46,20 +46,14 @@ function AlertDialogContent({ className, ...props }: React.ComponentProps<typeof
|
|||
}
|
||||
|
||||
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <div data-slot="alert-dialog-header" className={cn("flex flex-col gap-1 text-left", className)} {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
|
||||
className={cn("flex flex-row justify-end gap-2 mt-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -69,7 +63,10 @@ function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof A
|
|||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
className={cn(
|
||||
"text-base font-medium text-vscode-editor-foreground flex items-center gap-2 text-left",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
|
|
@ -82,18 +79,36 @@ function AlertDialogDescription({
|
|||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn("text-vscode-descriptionForeground text-sm text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return <AlertDialogPrimitive.Action className={cn(buttonVariants(), className)} {...props} />
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(
|
||||
buttonVariants(),
|
||||
"bg-vscode-button-background text-vscode-button-foreground hover:bg-vscode-button-hoverBackground border border-transparent h-6 px-3 py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return <AlertDialogPrimitive.Cancel className={cn(buttonVariants({ variant: "outline" }), className)} {...props} />
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"bg-vscode-button-secondaryBackground text-vscode-button-secondaryForeground hover:bg-vscode-button-secondaryHoverBackground border border-vscode-button-border h-6 px-3 py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -75,6 +75,8 @@ export interface ExtensionStateContextType extends ExtensionState {
|
|||
setCustomModes: (value: ModeConfig[]) => void
|
||||
setMaxOpenTabsContext: (value: number) => void
|
||||
setTelemetrySetting: (value: TelemetrySetting) => void
|
||||
remoteBrowserEnabled?: boolean
|
||||
setRemoteBrowserEnabled: (value: boolean) => void
|
||||
machineId?: string
|
||||
}
|
||||
|
||||
|
|
@ -286,6 +288,7 @@ export const ExtensionStateContextProvider: React.FC<{ children: React.ReactNode
|
|||
setBrowserToolEnabled: (value) => setState((prevState) => ({ ...prevState, browserToolEnabled: value })),
|
||||
setTelemetrySetting: (value) => setState((prevState) => ({ ...prevState, telemetrySetting: value })),
|
||||
setShowRooIgnoredFiles: (value) => setState((prevState) => ({ ...prevState, showRooIgnoredFiles: value })),
|
||||
setRemoteBrowserEnabled: (value) => setState((prevState) => ({ ...prevState, remoteBrowserEnabled: value })),
|
||||
}
|
||||
|
||||
return <ExtensionStateContext.Provider value={contextValue}>{children}</ExtensionStateContext.Provider>
|
||||
|
|
|
|||
|
|
@ -80,6 +80,44 @@ export function validateApiConfiguration(apiConfiguration?: ApiConfiguration): s
|
|||
|
||||
return undefined
|
||||
}
|
||||
/**
|
||||
* Validates an AWS Bedrock ARN format and optionally checks if the region in the ARN matches the provided region
|
||||
* @param arn The ARN string to validate
|
||||
* @param region Optional region to check against the ARN's region
|
||||
* @returns An object with validation results: { isValid, arnRegion, errorMessage }
|
||||
*/
|
||||
export function validateBedrockArn(arn: string, region?: string) {
|
||||
// Validate ARN format
|
||||
const arnRegex = /^arn:aws:bedrock:([^:]+):(\d+):(foundation-model|provisioned-model|default-prompt-router)\/(.+)$/
|
||||
const match = arn.match(arnRegex)
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
isValid: false,
|
||||
arnRegion: undefined,
|
||||
errorMessage: "Invalid ARN format. Please check the format requirements.",
|
||||
}
|
||||
}
|
||||
|
||||
// Extract region from ARN
|
||||
const arnRegion = match[1]
|
||||
|
||||
// Check if region in ARN matches provided region (if specified)
|
||||
if (region && arnRegion !== region) {
|
||||
return {
|
||||
isValid: true,
|
||||
arnRegion,
|
||||
errorMessage: `Warning: 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.`,
|
||||
}
|
||||
}
|
||||
|
||||
// ARN is valid and region matches (or no region was provided to check against)
|
||||
return {
|
||||
isValid: true,
|
||||
arnRegion,
|
||||
errorMessage: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function validateModelId(
|
||||
apiConfiguration?: ApiConfiguration,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue