mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
Merge branch 'main' into cte/roo-code-api
This commit is contained in:
commit
90a607f111
21 changed files with 1908 additions and 223 deletions
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.
|
||||
|
||||
---
|
||||
|
|
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -32,18 +32,6 @@ import { handleUri, registerCommands, registerCodeActions, createRooCodeAPI } fr
|
|||
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) {
|
||||
|
|
@ -73,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
|
||||
|
|
|
|||
|
|
@ -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)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -52,6 +52,8 @@ export interface ExtensionMessage {
|
|||
| "humanRelayResponse"
|
||||
| "humanRelayCancel"
|
||||
| "browserToolEnabled"
|
||||
| "browserConnectionResult"
|
||||
| "remoteBrowserEnabled"
|
||||
text?: string
|
||||
action?:
|
||||
| "chatButtonClicked"
|
||||
|
|
@ -84,6 +86,8 @@ export interface ExtensionMessage {
|
|||
mode?: Mode
|
||||
customMode?: ModeConfig
|
||||
slug?: string
|
||||
success?: boolean
|
||||
values?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface ApiConfigMeta {
|
||||
|
|
@ -124,6 +128,8 @@ export interface ExtensionState {
|
|||
checkpointStorage: CheckpointStorage
|
||||
browserViewportSize?: string
|
||||
screenshotQuality?: number
|
||||
remoteBrowserHost?: string
|
||||
remoteBrowserEnabled?: boolean
|
||||
fuzzyMatchThreshold?: number
|
||||
preferredLanguage: string
|
||||
writeDelayMs: number
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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