Focus the extension when receiving bridge commands (#7633)

This commit is contained in:
Chris Estreich 2025-09-03 12:23:50 -07:00 committed by GitHub
parent d7896924a9
commit 280b75d2eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 206 additions and 114 deletions

View file

@ -13,6 +13,10 @@ export const Uri = {
parse: vi.fn((uri: string) => ({ toString: () => uri })),
}
export const commands = {
executeCommand: vi.fn().mockResolvedValue(undefined),
}
export interface ExtensionContext {
secrets: {
get: (key: string) => Promise<string | undefined>

View file

@ -1,4 +1,13 @@
import type { Socket } from "socket.io-client"
import * as vscode from "vscode"
import type { StaticAppProperties, GitProperties } from "@roo-code/types"
export interface BaseChannelOptions {
instanceId: string
appProperties: StaticAppProperties
gitProperties?: GitProperties
}
/**
* Abstract base class for communication channels in the bridge system.
@ -11,9 +20,13 @@ import type { Socket } from "socket.io-client"
export abstract class BaseChannel<TCommand = unknown, TEventName extends string = string, TEventData = unknown> {
protected socket: Socket | null = null
protected readonly instanceId: string
protected readonly appProperties: StaticAppProperties
protected readonly gitProperties?: GitProperties
constructor(instanceId: string) {
this.instanceId = instanceId
constructor(options: BaseChannelOptions) {
this.instanceId = options.instanceId
this.appProperties = options.appProperties
this.gitProperties = options.gitProperties
}
/**
@ -81,9 +94,26 @@ export abstract class BaseChannel<TCommand = unknown, TEventName extends string
}
/**
* Handle incoming commands - must be implemented by subclasses.
* Handle incoming commands - template method that ensures common functionality
* is executed before subclass-specific logic.
*
* This method should be called by subclasses to handle commands.
* It will execute common functionality and then delegate to the abstract
* handleCommandImplementation method.
*/
public abstract handleCommand(command: TCommand): Promise<void>
public async handleCommand(command: TCommand): Promise<void> {
// Common functionality: focus the sidebar.
await vscode.commands.executeCommand(`${this.appProperties.appName}.SidebarProvider.focus`)
// Delegate to subclass-specific implementation.
await this.handleCommandImplementation(command)
}
/**
* Handle command-specific logic - must be implemented by subclasses.
* This method is called after common functionality has been executed.
*/
protected abstract handleCommandImplementation(command: TCommand): Promise<void>
/**
* Handle connection-specific logic.

View file

@ -1,4 +1,5 @@
import crypto from "crypto"
import os from "os"
import {
type TaskProviderLike,
@ -6,6 +7,8 @@ import {
type CloudUserInfo,
type ExtensionBridgeCommand,
type TaskBridgeCommand,
type StaticAppProperties,
type GitProperties,
ConnectionState,
ExtensionSocketEvents,
TaskSocketEvents,
@ -39,6 +42,8 @@ export class BridgeOrchestrator {
private readonly token: string
private readonly provider: TaskProviderLike
private readonly instanceId: string
private readonly appProperties: StaticAppProperties
private readonly gitProperties?: GitProperties
// Components
private socketTransport: SocketTransport
@ -61,69 +66,75 @@ export class BridgeOrchestrator {
public static async connectOrDisconnect(
userInfo: CloudUserInfo | null,
remoteControlEnabled: boolean | undefined,
options?: BridgeOrchestratorOptions,
options: BridgeOrchestratorOptions,
): Promise<void> {
const isEnabled = BridgeOrchestrator.isEnabled(userInfo, remoteControlEnabled)
if (BridgeOrchestrator.isEnabled(userInfo, remoteControlEnabled)) {
await BridgeOrchestrator.connect(options)
} else {
await BridgeOrchestrator.disconnect()
}
}
public static async connect(options: BridgeOrchestratorOptions) {
const instance = BridgeOrchestrator.instance
if (isEnabled) {
if (!instance) {
if (!options) {
console.error(
`[BridgeOrchestrator#connectOrDisconnect] Cannot connect: options are required for connection`,
)
return
}
try {
console.log(`[BridgeOrchestrator#connectOrDisconnect] Connecting...`)
BridgeOrchestrator.instance = new BridgeOrchestrator(options)
await BridgeOrchestrator.instance.connect()
} catch (error) {
console.error(
`[BridgeOrchestrator#connectOrDisconnect] connect() failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
} else {
if (
instance.connectionState === ConnectionState.FAILED ||
instance.connectionState === ConnectionState.DISCONNECTED
) {
console.log(
`[BridgeOrchestrator#connectOrDisconnect] Re-connecting... (state: ${instance.connectionState})`,
)
if (!instance) {
try {
console.log(`[BridgeOrchestrator#connectOrDisconnect] Connecting...`)
// Populate telemetry properties before registering the instance.
await options.provider.getTelemetryProperties()
instance.reconnect().catch((error) => {
console.error(
`[BridgeOrchestrator#connectOrDisconnect] reconnect() failed: ${error instanceof Error ? error.message : String(error)}`,
)
})
} else {
console.log(
`[BridgeOrchestrator#connectOrDisconnect] Already connected or connecting (state: ${instance.connectionState})`,
)
}
BridgeOrchestrator.instance = new BridgeOrchestrator(options)
await BridgeOrchestrator.instance.connect()
} catch (error) {
console.error(
`[BridgeOrchestrator#connectOrDisconnect] connect() failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
} else {
if (instance) {
try {
console.log(
`[BridgeOrchestrator#connectOrDisconnect] Disconnecting... (state: ${instance.connectionState})`,
)
if (
instance.connectionState === ConnectionState.FAILED ||
instance.connectionState === ConnectionState.DISCONNECTED
) {
console.log(
`[BridgeOrchestrator#connectOrDisconnect] Re-connecting... (state: ${instance.connectionState})`,
)
await instance.disconnect()
} catch (error) {
instance.reconnect().catch((error) => {
console.error(
`[BridgeOrchestrator#connectOrDisconnect] disconnect() failed: ${error instanceof Error ? error.message : String(error)}`,
`[BridgeOrchestrator#connectOrDisconnect] reconnect() failed: ${error instanceof Error ? error.message : String(error)}`,
)
} finally {
BridgeOrchestrator.instance = null
}
})
} else {
console.log(`[BridgeOrchestrator#connectOrDisconnect] Already disconnected`)
console.log(
`[BridgeOrchestrator#connectOrDisconnect] Already connected or connecting (state: ${instance.connectionState})`,
)
}
}
}
public static async disconnect() {
const instance = BridgeOrchestrator.instance
if (instance) {
try {
console.log(
`[BridgeOrchestrator#connectOrDisconnect] Disconnecting... (state: ${instance.connectionState})`,
)
await instance.disconnect()
} catch (error) {
console.error(
`[BridgeOrchestrator#connectOrDisconnect] disconnect() failed: ${error instanceof Error ? error.message : String(error)}`,
)
} finally {
BridgeOrchestrator.instance = null
}
} else {
console.log(`[BridgeOrchestrator#connectOrDisconnect] Already disconnected`)
}
}
/**
* @TODO: What if subtasks also get spawned? We'd probably want deferred
* subscriptions for those too.
@ -146,6 +157,8 @@ export class BridgeOrchestrator {
this.token = options.token
this.provider = options.provider
this.instanceId = options.sessionId || crypto.randomUUID()
this.appProperties = { ...options.provider.appProperties, hostname: os.hostname() }
this.gitProperties = options.provider.gitProperties
this.socketTransport = new SocketTransport({
url: this.socketBridgeUrl,
@ -166,8 +179,19 @@ export class BridgeOrchestrator {
onReconnect: () => this.handleReconnect(),
})
this.extensionChannel = new ExtensionChannel(this.instanceId, this.userId, this.provider)
this.taskChannel = new TaskChannel(this.instanceId)
this.extensionChannel = new ExtensionChannel({
instanceId: this.instanceId,
appProperties: this.appProperties,
gitProperties: this.gitProperties,
userId: this.userId,
provider: this.provider,
})
this.taskChannel = new TaskChannel({
instanceId: this.instanceId,
appProperties: this.appProperties,
gitProperties: this.gitProperties,
})
}
private setupSocketListeners() {
@ -288,9 +312,6 @@ export class BridgeOrchestrator {
}
private async connect(): Promise<void> {
// Populate the app and git properties before registering the instance.
await this.provider.getTelemetryProperties()
await this.socketTransport.connect()
this.setupSocketListeners()
}

View file

@ -14,7 +14,12 @@ import {
HEARTBEAT_INTERVAL_MS,
} from "@roo-code/types"
import { BaseChannel } from "./BaseChannel.js"
import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js"
interface ExtensionChannelOptions extends BaseChannelOptions {
userId: string
provider: TaskProviderLike
}
/**
* Manages the extension-level communication channel.
@ -31,33 +36,36 @@ export class ExtensionChannel extends BaseChannel<
private heartbeatInterval: NodeJS.Timeout | null = null
private eventListeners: Map<RooCodeEventName, (...args: unknown[]) => void> = new Map()
constructor(instanceId: string, userId: string, provider: TaskProviderLike) {
super(instanceId)
this.userId = userId
this.provider = provider
constructor(options: ExtensionChannelOptions) {
super({
instanceId: options.instanceId,
appProperties: options.appProperties,
gitProperties: options.gitProperties,
})
this.userId = options.userId
this.provider = options.provider
this.extensionInstance = {
instanceId: this.instanceId,
userId: this.userId,
workspacePath: this.provider.cwd,
appProperties: this.provider.appProperties,
gitProperties: this.provider.gitProperties,
appProperties: this.appProperties,
gitProperties: this.gitProperties,
lastHeartbeat: Date.now(),
task: {
taskId: "",
taskStatus: TaskStatus.None,
},
task: { taskId: "", taskStatus: TaskStatus.None },
taskHistory: [],
}
this.setupListeners()
}
public async handleCommand(command: ExtensionBridgeCommand): Promise<void> {
protected async handleCommandImplementation(command: ExtensionBridgeCommand): Promise<void> {
if (command.instanceId !== this.instanceId) {
console.log(`[ExtensionChannel] command -> instance id mismatch | ${this.instanceId}`, {
messageInstanceId: command.instanceId,
})
return
}
@ -217,8 +225,6 @@ export class ExtensionChannel extends BaseChannel<
this.extensionInstance = {
...this.extensionInstance,
appProperties: this.extensionInstance.appProperties ?? this.provider.appProperties,
gitProperties: this.extensionInstance.gitProperties ?? this.provider.gitProperties,
lastHeartbeat: Date.now(),
task: task
? {

View file

@ -14,7 +14,7 @@ import {
TaskSocketEvents,
} from "@roo-code/types"
import { BaseChannel } from "./BaseChannel.js"
import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js"
type TaskEventListener = {
[K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise<void>
@ -26,6 +26,9 @@ type TaskEventMapping = {
createPayload: (task: TaskLike, ...args: any[]) => any // eslint-disable-line @typescript-eslint/no-explicit-any
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface TaskChannelOptions extends BaseChannelOptions {}
/**
* Manages task-level communication channels.
* Handles task subscriptions, messaging, and task-specific commands.
@ -69,11 +72,11 @@ export class TaskChannel extends BaseChannel<
},
] as const
constructor(instanceId: string) {
super(instanceId)
constructor(options: TaskChannelOptions) {
super(options)
}
public async handleCommand(command: TaskBridgeCommand): Promise<void> {
protected async handleCommandImplementation(command: TaskBridgeCommand): Promise<void> {
const task = this.subscribedTasks.get(command.taskId)
if (!task) {

View file

@ -5,6 +5,7 @@ import type { Socket } from "socket.io-client"
import {
type TaskProviderLike,
type TaskProviderEvents,
type StaticAppProperties,
RooCodeEventName,
ExtensionBridgeEventName,
ExtensionSocketEvents,
@ -19,6 +20,15 @@ describe("ExtensionChannel", () => {
const instanceId = "test-instance-123"
const userId = "test-user-456"
const appProperties: StaticAppProperties = {
appName: "roo-code",
appVersion: "1.0.0",
vscodeVersion: "1.0.0",
platform: "darwin",
editorName: "Roo Code",
hostname: "test-host",
}
// Track registered event listeners
const eventListeners = new Map<keyof TaskProviderEvents, Set<(...args: unknown[]) => unknown>>()
@ -80,7 +90,12 @@ describe("ExtensionChannel", () => {
} as unknown as TaskProviderLike
// Create extension channel instance
extensionChannel = new ExtensionChannel(instanceId, userId, mockProvider)
extensionChannel = new ExtensionChannel({
instanceId,
appProperties,
userId,
provider: mockProvider,
})
})
afterEach(() => {
@ -155,7 +170,12 @@ describe("ExtensionChannel", () => {
it("should not have duplicate listeners after multiple channel creations", () => {
// Create a second channel with the same provider
const secondChannel = new ExtensionChannel("instance-2", userId, mockProvider)
const secondChannel = new ExtensionChannel({
instanceId: "instance-2",
appProperties,
userId,
provider: mockProvider,
})
// Each event should have exactly 2 listeners (one from each channel)
eventListeners.forEach((listeners) => {

View file

@ -6,6 +6,7 @@ import type { Socket } from "socket.io-client"
import {
type TaskLike,
type ClineMessage,
type StaticAppProperties,
RooCodeEventName,
TaskBridgeEventName,
TaskBridgeCommandName,
@ -22,6 +23,15 @@ describe("TaskChannel", () => {
const instanceId = "test-instance-123"
const taskId = "test-task-456"
const appProperties: StaticAppProperties = {
appName: "roo-code",
appVersion: "1.0.0",
vscodeVersion: "1.0.0",
platform: "darwin",
editorName: "Roo Code",
hostname: "test-host",
}
beforeEach(() => {
// Create mock socket
mockSocket = {
@ -75,7 +85,10 @@ describe("TaskChannel", () => {
}
// Create task channel instance
taskChannel = new TaskChannel(instanceId)
taskChannel = new TaskChannel({
instanceId,
appProperties,
})
})
afterEach(() => {
@ -320,7 +333,7 @@ describe("TaskChannel", () => {
channel.subscribedTasks.set(taskId, mockTask)
})
it("should handle Message command", () => {
it("should handle Message command", async () => {
const command = {
type: TaskBridgeCommandName.Message,
taskId,
@ -331,7 +344,7 @@ describe("TaskChannel", () => {
},
}
taskChannel.handleCommand(command)
await taskChannel.handleCommand(command)
expect(mockTask.submitUserMessage).toHaveBeenCalledWith(
command.payload.text,
@ -341,7 +354,7 @@ describe("TaskChannel", () => {
)
})
it("should handle ApproveAsk command", () => {
it("should handle ApproveAsk command", async () => {
const command = {
type: TaskBridgeCommandName.ApproveAsk,
taskId,
@ -351,12 +364,12 @@ describe("TaskChannel", () => {
},
}
taskChannel.handleCommand(command)
await taskChannel.handleCommand(command)
expect(mockTask.approveAsk).toHaveBeenCalledWith(command.payload)
})
it("should handle DenyAsk command", () => {
it("should handle DenyAsk command", async () => {
const command = {
type: TaskBridgeCommandName.DenyAsk,
taskId,
@ -366,12 +379,12 @@ describe("TaskChannel", () => {
},
}
taskChannel.handleCommand(command)
await taskChannel.handleCommand(command)
expect(mockTask.denyAsk).toHaveBeenCalledWith(command.payload)
})
it("should log error for unknown task", () => {
it("should log error for unknown task", async () => {
const errorSpy = vi.spyOn(console, "error")
const command = {
@ -383,7 +396,7 @@ describe("TaskChannel", () => {
},
}
taskChannel.handleCommand(command)
await taskChannel.handleCommand(command)
expect(errorSpy).toHaveBeenCalledWith(`[TaskChannel] Unable to find task unknown-task`)

View file

@ -1,6 +1,6 @@
{
"name": "@roo-code/types",
"version": "1.69.0",
"version": "1.71.0",
"description": "TypeScript type definitions for Roo Code.",
"publishConfig": {
"access": "public",

View file

@ -78,6 +78,7 @@ export const staticAppPropertiesSchema = z.object({
vscodeVersion: z.string(),
platform: z.string(),
editorName: z.string(),
hostname: z.string().optional(),
})
export type StaticAppProperties = z.infer<typeof staticAppPropertiesSchema>

View file

@ -2095,7 +2095,6 @@ export class ClineProvider
public async remoteControlEnabled(enabled: boolean) {
const userInfo = CloudService.instance.getUserInfo()
const config = await CloudService.instance.cloudAPI?.bridgeConfig().catch(() => undefined)
if (!config) {

View file

@ -956,13 +956,21 @@ export const webviewMessageHandler = async (
break
case "remoteControlEnabled":
try {
await CloudService.instance.updateUserSettings({
extensionBridgeEnabled: message.bool ?? false,
})
await CloudService.instance.updateUserSettings({ extensionBridgeEnabled: message.bool ?? false })
} catch (error) {
provider.log(`Failed to update cloud settings for remote control: ${error}`)
provider.log(
`CloudService#updateUserSettings failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
await provider.remoteControlEnabled(message.bool ?? false)
try {
await provider.remoteControlEnabled(message.bool ?? false)
} catch (error) {
provider.log(
`ClineProvider#remoteControlEnabled failed: ${error instanceof Error ? error.message : String(error)}`,
)
}
await provider.postStateToWebview()
break
case "refreshAllMcpServers": {

View file

@ -131,20 +131,13 @@ export async function activate(context: vscode.ExtensionContext) {
authStateChangedHandler = async (data: { state: AuthState; previousState: AuthState }) => {
postStateListener()
// Check if user has logged out
if (data.state === "logged-out") {
try {
// Disconnect the bridge when user logs out
// When userInfo is null and remoteControlEnabled is false, BridgeOrchestrator
// will disconnect. The options parameter is not needed for disconnection.
await BridgeOrchestrator.connectOrDisconnect(null, false)
await BridgeOrchestrator.disconnect()
cloudLogger("[CloudService] BridgeOrchestrator disconnected on logout")
} catch (error) {
cloudLogger(
`[CloudService] Failed to disconnect BridgeOrchestrator on logout: ${
error instanceof Error ? error.message : String(error)
}`,
`[CloudService] Failed to disconnect BridgeOrchestrator on logout: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
@ -152,6 +145,7 @@ export async function activate(context: vscode.ExtensionContext) {
settingsUpdatedHandler = async () => {
const userInfo = CloudService.instance.getUserInfo()
if (userInfo && CloudService.instance.cloudAPI) {
try {
const config = await CloudService.instance.cloudAPI.bridgeConfig()
@ -163,8 +157,6 @@ export async function activate(context: vscode.ExtensionContext) {
? true
: (CloudService.instance.getUserSettings()?.settings?.extensionBridgeEnabled ?? false)
cloudLogger(`[CloudService] Settings updated - remoteControlEnabled = ${remoteControlEnabled}`)
await BridgeOrchestrator.connectOrDisconnect(userInfo, remoteControlEnabled, {
...config,
provider,
@ -172,7 +164,7 @@ export async function activate(context: vscode.ExtensionContext) {
})
} catch (error) {
cloudLogger(
`[CloudService] Failed to update BridgeOrchestrator on settings change: ${error instanceof Error ? error.message : String(error)}`,
`[CloudService] BridgeOrchestrator#connectOrDisconnect failed on settings change: ${error instanceof Error ? error.message : String(error)}`,
)
}
}
@ -194,8 +186,6 @@ export async function activate(context: vscode.ExtensionContext) {
const isCloudAgent =
typeof process.env.ROO_CODE_CLOUD_TOKEN === "string" && process.env.ROO_CODE_CLOUD_TOKEN.length > 0
cloudLogger(`[CloudService] isCloudAgent = ${isCloudAgent}, socketBridgeUrl = ${config.socketBridgeUrl}`)
const remoteControlEnabled = isCloudAgent
? true
: (CloudService.instance.getUserSettings()?.settings?.extensionBridgeEnabled ?? false)
@ -207,7 +197,7 @@ export async function activate(context: vscode.ExtensionContext) {
})
} catch (error) {
cloudLogger(
`[CloudService] Failed to fetch bridgeConfig: ${error instanceof Error ? error.message : String(error)}`,
`[CloudService] BridgeOrchestrator#connectOrDisconnect failed on user change: ${error instanceof Error ? error.message : String(error)}`,
)
}
}

View file

@ -1,9 +1,10 @@
import { EventEmitter } from "events"
import * as vscode from "vscode"
import fs from "fs/promises"
import * as path from "path"
import * as os from "os"
import * as vscode from "vscode"
import {
type RooCodeAPI,
type RooCodeSettings,

View file

@ -1,7 +1,3 @@
/**
* Package
*/
import { publisher, name, version } from "../package.json"
// These ENV variables can be defined by ESBuild when building the extension