mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
Refactor the extension bridge (#7515)
This commit is contained in:
parent
af00c627b7
commit
2b7e259983
20 changed files with 506 additions and 2261 deletions
|
|
@ -129,6 +129,7 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
|
|||
private changeState(newState: AuthState): void {
|
||||
const previousState = this.state
|
||||
this.state = newState
|
||||
this.log(`[auth] changeState: ${previousState} -> ${newState}`)
|
||||
this.emit("auth-state-changed", { state: newState, previousState })
|
||||
}
|
||||
|
||||
|
|
@ -162,8 +163,6 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
|
|||
this.userInfo = null
|
||||
|
||||
this.changeState("logged-out")
|
||||
|
||||
this.log("[auth] Transitioned to logged-out state")
|
||||
}
|
||||
|
||||
private transitionToAttemptingSession(credentials: AuthCredentials): void {
|
||||
|
|
@ -176,8 +175,6 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
|
|||
this.changeState("attempting-session")
|
||||
|
||||
this.timer.start()
|
||||
|
||||
this.log("[auth] Transitioned to attempting-session state")
|
||||
}
|
||||
|
||||
private transitionToInactiveSession(): void {
|
||||
|
|
@ -185,8 +182,6 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
|
|||
this.userInfo = null
|
||||
|
||||
this.changeState("inactive-session")
|
||||
|
||||
this.log("[auth] Transitioned to inactive-session state")
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -422,7 +417,6 @@ export class WebAuthService extends EventEmitter<AuthServiceEvents> implements A
|
|||
|
||||
if (previousState !== "active-session") {
|
||||
this.changeState("active-session")
|
||||
this.log("[auth] Transitioned to active-session state")
|
||||
this.fetchUserInfo()
|
||||
} else {
|
||||
this.state = "active-session"
|
||||
|
|
|
|||
|
|
@ -1,13 +1,4 @@
|
|||
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.
|
||||
|
|
@ -20,13 +11,9 @@ export interface BaseChannelOptions {
|
|||
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(options: BaseChannelOptions) {
|
||||
this.instanceId = options.instanceId
|
||||
this.appProperties = options.appProperties
|
||||
this.gitProperties = options.gitProperties
|
||||
constructor(instanceId: string) {
|
||||
this.instanceId = instanceId
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -94,26 +81,9 @@ export abstract class BaseChannel<TCommand = unknown, TEventName extends string
|
|||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Handle incoming commands - must be implemented by subclasses.
|
||||
*/
|
||||
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>
|
||||
public abstract handleCommand(command: TCommand): void
|
||||
|
||||
/**
|
||||
* Handle connection-specific logic.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import crypto from "crypto"
|
||||
import os from "os"
|
||||
|
||||
import {
|
||||
type TaskProviderLike,
|
||||
|
|
@ -7,8 +6,6 @@ import {
|
|||
type CloudUserInfo,
|
||||
type ExtensionBridgeCommand,
|
||||
type TaskBridgeCommand,
|
||||
type StaticAppProperties,
|
||||
type GitProperties,
|
||||
ConnectionState,
|
||||
ExtensionSocketEvents,
|
||||
TaskSocketEvents,
|
||||
|
|
@ -34,16 +31,12 @@ export interface BridgeOrchestratorOptions {
|
|||
export class BridgeOrchestrator {
|
||||
private static instance: BridgeOrchestrator | null = null
|
||||
|
||||
private static pendingTask: TaskLike | null = null
|
||||
|
||||
// Core
|
||||
private readonly userId: string
|
||||
private readonly socketBridgeUrl: string
|
||||
private readonly token: string
|
||||
private readonly provider: TaskProviderLike
|
||||
private readonly instanceId: string
|
||||
private readonly appProperties: StaticAppProperties
|
||||
private readonly gitProperties?: GitProperties
|
||||
|
||||
// Components
|
||||
private socketTransport: SocketTransport
|
||||
|
|
@ -68,86 +61,58 @@ export class BridgeOrchestrator {
|
|||
remoteControlEnabled: boolean | undefined,
|
||||
options: BridgeOrchestratorOptions,
|
||||
): Promise<void> {
|
||||
if (BridgeOrchestrator.isEnabled(userInfo, remoteControlEnabled)) {
|
||||
await BridgeOrchestrator.connect(options)
|
||||
} else {
|
||||
await BridgeOrchestrator.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
public static async connect(options: BridgeOrchestratorOptions) {
|
||||
const isEnabled = BridgeOrchestrator.isEnabled(userInfo, remoteControlEnabled)
|
||||
const instance = BridgeOrchestrator.instance
|
||||
|
||||
if (!instance) {
|
||||
try {
|
||||
console.log(`[BridgeOrchestrator#connectOrDisconnect] Connecting...`)
|
||||
// Populate telemetry properties before registering the instance.
|
||||
await options.provider.getTelemetryProperties()
|
||||
|
||||
BridgeOrchestrator.instance = new BridgeOrchestrator(options)
|
||||
await BridgeOrchestrator.instance.connect()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[BridgeOrchestrator#connectOrDisconnect] connect() failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
instance.connectionState === ConnectionState.FAILED ||
|
||||
instance.connectionState === ConnectionState.DISCONNECTED
|
||||
) {
|
||||
console.log(
|
||||
`[BridgeOrchestrator#connectOrDisconnect] Re-connecting... (state: ${instance.connectionState})`,
|
||||
)
|
||||
|
||||
instance.reconnect().catch((error) => {
|
||||
if (isEnabled) {
|
||||
if (!instance) {
|
||||
try {
|
||||
console.log(`[BridgeOrchestrator#connectOrDisconnect] Connecting...`)
|
||||
BridgeOrchestrator.instance = new BridgeOrchestrator(options)
|
||||
await BridgeOrchestrator.instance.connect()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[BridgeOrchestrator#connectOrDisconnect] reconnect() failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`[BridgeOrchestrator#connectOrDisconnect] connect() failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[BridgeOrchestrator#connectOrDisconnect] Already connected or connecting (state: ${instance.connectionState})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
instance.connectionState === ConnectionState.FAILED ||
|
||||
instance.connectionState === ConnectionState.DISCONNECTED
|
||||
) {
|
||||
console.log(
|
||||
`[BridgeOrchestrator#connectOrDisconnect] Re-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
|
||||
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})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`[BridgeOrchestrator#connectOrDisconnect] Already disconnected`)
|
||||
}
|
||||
}
|
||||
if (instance) {
|
||||
try {
|
||||
console.log(
|
||||
`[BridgeOrchestrator#connectOrDisconnect] Disconnecting... (state: ${instance.connectionState})`,
|
||||
)
|
||||
|
||||
/**
|
||||
* @TODO: What if subtasks also get spawned? We'd probably want deferred
|
||||
* subscriptions for those too.
|
||||
*/
|
||||
public static async subscribeToTask(task: TaskLike): Promise<void> {
|
||||
const instance = BridgeOrchestrator.instance
|
||||
|
||||
if (instance && instance.socketTransport.isConnected()) {
|
||||
console.log(`[BridgeOrchestrator#subscribeToTask] Subscribing to task ${task.taskId}`)
|
||||
await instance.subscribeToTask(task)
|
||||
} else {
|
||||
console.log(`[BridgeOrchestrator#subscribeToTask] Deferring subscription for task ${task.taskId}`)
|
||||
BridgeOrchestrator.pendingTask = task
|
||||
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`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,8 +122,6 @@ 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,
|
||||
|
|
@ -179,19 +142,8 @@ export class BridgeOrchestrator {
|
|||
onReconnect: () => this.handleReconnect(),
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
this.extensionChannel = new ExtensionChannel(this.instanceId, this.userId, this.provider)
|
||||
this.taskChannel = new TaskChannel(this.instanceId)
|
||||
}
|
||||
|
||||
private setupSocketListeners() {
|
||||
|
|
@ -228,27 +180,12 @@ export class BridgeOrchestrator {
|
|||
const socket = this.socketTransport.getSocket()
|
||||
|
||||
if (!socket) {
|
||||
console.error("[BridgeOrchestrator#handleConnect] Socket not available")
|
||||
console.error("[BridgeOrchestrator] Socket not available after connect")
|
||||
return
|
||||
}
|
||||
|
||||
await this.extensionChannel.onConnect(socket)
|
||||
await this.taskChannel.onConnect(socket)
|
||||
|
||||
if (BridgeOrchestrator.pendingTask) {
|
||||
console.log(
|
||||
`[BridgeOrchestrator#handleConnect] Subscribing to task ${BridgeOrchestrator.pendingTask.taskId}`,
|
||||
)
|
||||
|
||||
try {
|
||||
await this.subscribeToTask(BridgeOrchestrator.pendingTask)
|
||||
BridgeOrchestrator.pendingTask = null
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[BridgeOrchestrator#handleConnect] subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDisconnect() {
|
||||
|
|
@ -312,6 +249,9 @@ 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()
|
||||
}
|
||||
|
|
@ -321,7 +261,6 @@ export class BridgeOrchestrator {
|
|||
await this.taskChannel.cleanup(this.socketTransport.getSocket())
|
||||
await this.socketTransport.disconnect()
|
||||
BridgeOrchestrator.instance = null
|
||||
BridgeOrchestrator.pendingTask = null
|
||||
}
|
||||
|
||||
public async reconnect(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -1,290 +0,0 @@
|
|||
import crypto from "crypto"
|
||||
|
||||
import {
|
||||
type TaskProviderLike,
|
||||
type TaskLike,
|
||||
type CloudUserInfo,
|
||||
type ExtensionBridgeCommand,
|
||||
type TaskBridgeCommand,
|
||||
ConnectionState,
|
||||
ExtensionSocketEvents,
|
||||
TaskSocketEvents,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { SocketConnectionManager } from "./SocketConnectionManager.js"
|
||||
import { ExtensionManager } from "./ExtensionManager.js"
|
||||
import { TaskManager } from "./TaskManager.js"
|
||||
|
||||
export interface ExtensionBridgeServiceOptions {
|
||||
userId: string
|
||||
socketBridgeUrl: string
|
||||
token: string
|
||||
provider: TaskProviderLike
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
export class ExtensionBridgeService {
|
||||
private static instance: ExtensionBridgeService | null = null
|
||||
|
||||
// Core
|
||||
private readonly userId: string
|
||||
private readonly socketBridgeUrl: string
|
||||
private readonly token: string
|
||||
private readonly provider: TaskProviderLike
|
||||
private readonly instanceId: string
|
||||
|
||||
// Managers
|
||||
private connectionManager: SocketConnectionManager
|
||||
private extensionManager: ExtensionManager
|
||||
private taskManager: TaskManager
|
||||
|
||||
// Reconnection
|
||||
private readonly MAX_RECONNECT_ATTEMPTS = Infinity
|
||||
private readonly RECONNECT_DELAY = 1_000
|
||||
private readonly RECONNECT_DELAY_MAX = 30_000
|
||||
|
||||
public static getInstance(): ExtensionBridgeService | null {
|
||||
return ExtensionBridgeService.instance
|
||||
}
|
||||
|
||||
public static async createInstance(options: ExtensionBridgeServiceOptions) {
|
||||
console.log("[ExtensionBridgeService] createInstance")
|
||||
ExtensionBridgeService.instance = new ExtensionBridgeService(options)
|
||||
await ExtensionBridgeService.instance.initialize()
|
||||
return ExtensionBridgeService.instance
|
||||
}
|
||||
|
||||
public static resetInstance() {
|
||||
if (ExtensionBridgeService.instance) {
|
||||
console.log("[ExtensionBridgeService] resetInstance")
|
||||
ExtensionBridgeService.instance.disconnect().catch(() => {})
|
||||
ExtensionBridgeService.instance = null
|
||||
}
|
||||
}
|
||||
|
||||
public static async handleRemoteControlState(
|
||||
userInfo: CloudUserInfo | null,
|
||||
remoteControlEnabled: boolean | undefined,
|
||||
options: ExtensionBridgeServiceOptions,
|
||||
logger?: (message: string) => void,
|
||||
) {
|
||||
if (userInfo?.extensionBridgeEnabled && remoteControlEnabled) {
|
||||
const existingService = ExtensionBridgeService.getInstance()
|
||||
|
||||
if (!existingService) {
|
||||
try {
|
||||
const service = await ExtensionBridgeService.createInstance(options)
|
||||
const state = service.getConnectionState()
|
||||
|
||||
logger?.(`[ExtensionBridgeService#handleRemoteControlState] Instance created (state: ${state})`)
|
||||
|
||||
if (state !== ConnectionState.CONNECTED) {
|
||||
logger?.(
|
||||
`[ExtensionBridgeService#handleRemoteControlState] Service is not connected yet, will retry in background`,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = `[ExtensionBridgeService#handleRemoteControlState] Failed to create instance: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
|
||||
logger?.(message)
|
||||
console.error(message)
|
||||
}
|
||||
} else {
|
||||
const state = existingService.getConnectionState()
|
||||
|
||||
if (state === ConnectionState.FAILED || state === ConnectionState.DISCONNECTED) {
|
||||
logger?.(
|
||||
`[ExtensionBridgeService#handleRemoteControlState] Existing service is ${state}, attempting reconnection`,
|
||||
)
|
||||
|
||||
existingService.reconnect().catch((error) => {
|
||||
const message = `[ExtensionBridgeService#handleRemoteControlState] Reconnection failed: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
|
||||
logger?.(message)
|
||||
console.error(message)
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const existingService = ExtensionBridgeService.getInstance()
|
||||
|
||||
if (existingService) {
|
||||
try {
|
||||
await existingService.disconnect()
|
||||
ExtensionBridgeService.resetInstance()
|
||||
|
||||
logger?.(`[ExtensionBridgeService#handleRemoteControlState] Service disconnected and reset`)
|
||||
} catch (error) {
|
||||
const message = `[ExtensionBridgeService#handleRemoteControlState] Failed to disconnect and reset instance: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
|
||||
logger?.(message)
|
||||
console.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private constructor(options: ExtensionBridgeServiceOptions) {
|
||||
this.userId = options.userId
|
||||
this.socketBridgeUrl = options.socketBridgeUrl
|
||||
this.token = options.token
|
||||
this.provider = options.provider
|
||||
this.instanceId = options.sessionId || crypto.randomUUID()
|
||||
|
||||
this.connectionManager = new SocketConnectionManager({
|
||||
url: this.socketBridgeUrl,
|
||||
socketOptions: {
|
||||
query: {
|
||||
token: this.token,
|
||||
clientType: "extension",
|
||||
instanceId: this.instanceId,
|
||||
},
|
||||
transports: ["websocket", "polling"],
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.MAX_RECONNECT_ATTEMPTS,
|
||||
reconnectionDelay: this.RECONNECT_DELAY,
|
||||
reconnectionDelayMax: this.RECONNECT_DELAY_MAX,
|
||||
},
|
||||
onConnect: () => this.handleConnect(),
|
||||
onDisconnect: () => this.handleDisconnect(),
|
||||
onReconnect: () => this.handleReconnect(),
|
||||
})
|
||||
|
||||
this.extensionManager = new ExtensionManager(this.instanceId, this.userId, this.provider)
|
||||
|
||||
this.taskManager = new TaskManager()
|
||||
}
|
||||
|
||||
private async initialize() {
|
||||
// Populate the app and git properties before registering the instance.
|
||||
await this.provider.getTelemetryProperties()
|
||||
|
||||
await this.connectionManager.connect()
|
||||
this.setupSocketListeners()
|
||||
}
|
||||
|
||||
private setupSocketListeners() {
|
||||
const socket = this.connectionManager.getSocket()
|
||||
|
||||
if (!socket) {
|
||||
console.error("[ExtensionBridgeService] Socket not available")
|
||||
return
|
||||
}
|
||||
|
||||
// Remove any existing listeners first to prevent duplicates.
|
||||
socket.off(ExtensionSocketEvents.RELAYED_COMMAND)
|
||||
socket.off(TaskSocketEvents.RELAYED_COMMAND)
|
||||
socket.off("connected")
|
||||
|
||||
socket.on(ExtensionSocketEvents.RELAYED_COMMAND, (message: ExtensionBridgeCommand) => {
|
||||
console.log(
|
||||
`[ExtensionBridgeService] on(${ExtensionSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.instanceId}`,
|
||||
)
|
||||
|
||||
this.extensionManager?.handleExtensionCommand(message)
|
||||
})
|
||||
|
||||
socket.on(TaskSocketEvents.RELAYED_COMMAND, (message: TaskBridgeCommand) => {
|
||||
console.log(
|
||||
`[ExtensionBridgeService] on(${TaskSocketEvents.RELAYED_COMMAND}) -> ${message.type} for ${message.taskId}`,
|
||||
)
|
||||
|
||||
this.taskManager.handleTaskCommand(message)
|
||||
})
|
||||
}
|
||||
|
||||
private async handleConnect() {
|
||||
const socket = this.connectionManager.getSocket()
|
||||
|
||||
if (!socket) {
|
||||
console.error("[ExtensionBridgeService] Socket not available after connect")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await this.extensionManager.onConnect(socket)
|
||||
await this.taskManager.onConnect(socket)
|
||||
}
|
||||
|
||||
private handleDisconnect() {
|
||||
this.extensionManager.onDisconnect()
|
||||
this.taskManager.onDisconnect()
|
||||
}
|
||||
|
||||
private async handleReconnect() {
|
||||
const socket = this.connectionManager.getSocket()
|
||||
|
||||
if (!socket) {
|
||||
console.error("[ExtensionBridgeService] Socket not available after reconnect")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Re-setup socket listeners to ensure they're properly configured
|
||||
// after automatic reconnection (Socket.IO's built-in reconnection)
|
||||
// The socket.off() calls in setupSocketListeners prevent duplicates
|
||||
this.setupSocketListeners()
|
||||
|
||||
await this.extensionManager.onReconnect(socket)
|
||||
await this.taskManager.onReconnect(socket)
|
||||
}
|
||||
|
||||
// Task API
|
||||
|
||||
public async subscribeToTask(task: TaskLike): Promise<void> {
|
||||
const socket = this.connectionManager.getSocket()
|
||||
|
||||
if (!socket || !this.connectionManager.isConnected()) {
|
||||
console.warn("[ExtensionBridgeService] Cannot subscribe to task: not connected. Will retry when connected.")
|
||||
|
||||
this.taskManager.addPendingTask(task)
|
||||
|
||||
const state = this.connectionManager.getConnectionState()
|
||||
|
||||
if (state === ConnectionState.DISCONNECTED || state === ConnectionState.FAILED) {
|
||||
this.initialize()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await this.taskManager.subscribeToTask(task, socket)
|
||||
}
|
||||
|
||||
public async unsubscribeFromTask(taskId: string): Promise<void> {
|
||||
const socket = this.connectionManager.getSocket()
|
||||
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.taskManager.unsubscribeFromTask(taskId, socket)
|
||||
}
|
||||
|
||||
// Shared API
|
||||
|
||||
public getConnectionState(): ConnectionState {
|
||||
return this.connectionManager.getConnectionState()
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
await this.extensionManager.cleanup(this.connectionManager.getSocket())
|
||||
await this.taskManager.cleanup(this.connectionManager.getSocket())
|
||||
await this.connectionManager.disconnect()
|
||||
ExtensionBridgeService.instance = null
|
||||
}
|
||||
|
||||
public async reconnect(): Promise<void> {
|
||||
await this.connectionManager.reconnect()
|
||||
|
||||
// After a manual reconnect, we have a new socket instance
|
||||
// so we need to set up listeners again.
|
||||
this.setupSocketListeners()
|
||||
}
|
||||
}
|
||||
|
|
@ -14,12 +14,7 @@ import {
|
|||
HEARTBEAT_INTERVAL_MS,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js"
|
||||
|
||||
interface ExtensionChannelOptions extends BaseChannelOptions {
|
||||
userId: string
|
||||
provider: TaskProviderLike
|
||||
}
|
||||
import { BaseChannel } from "./BaseChannel.js"
|
||||
|
||||
/**
|
||||
* Manages the extension-level communication channel.
|
||||
|
|
@ -36,36 +31,36 @@ export class ExtensionChannel extends BaseChannel<
|
|||
private heartbeatInterval: NodeJS.Timeout | null = null
|
||||
private eventListeners: Map<RooCodeEventName, (...args: unknown[]) => void> = new Map()
|
||||
|
||||
constructor(options: ExtensionChannelOptions) {
|
||||
super({
|
||||
instanceId: options.instanceId,
|
||||
appProperties: options.appProperties,
|
||||
gitProperties: options.gitProperties,
|
||||
})
|
||||
|
||||
this.userId = options.userId
|
||||
this.provider = options.provider
|
||||
constructor(instanceId: string, userId: string, provider: TaskProviderLike) {
|
||||
super(instanceId)
|
||||
this.userId = userId
|
||||
this.provider = provider
|
||||
|
||||
this.extensionInstance = {
|
||||
instanceId: this.instanceId,
|
||||
userId: this.userId,
|
||||
workspacePath: this.provider.cwd,
|
||||
appProperties: this.appProperties,
|
||||
gitProperties: this.gitProperties,
|
||||
appProperties: this.provider.appProperties,
|
||||
gitProperties: this.provider.gitProperties,
|
||||
lastHeartbeat: Date.now(),
|
||||
task: { taskId: "", taskStatus: TaskStatus.None },
|
||||
task: {
|
||||
taskId: "",
|
||||
taskStatus: TaskStatus.None,
|
||||
},
|
||||
taskHistory: [],
|
||||
}
|
||||
|
||||
this.setupListeners()
|
||||
}
|
||||
|
||||
protected async handleCommandImplementation(command: ExtensionBridgeCommand): Promise<void> {
|
||||
/**
|
||||
* Handle extension-specific commands from the web app
|
||||
*/
|
||||
public handleCommand(command: ExtensionBridgeCommand): void {
|
||||
if (command.instanceId !== this.instanceId) {
|
||||
console.log(`[ExtensionChannel] command -> instance id mismatch | ${this.instanceId}`, {
|
||||
messageInstanceId: command.instanceId,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -74,22 +69,13 @@ export class ExtensionChannel extends BaseChannel<
|
|||
console.log(`[ExtensionChannel] command -> createTask() | ${command.instanceId}`, {
|
||||
text: command.payload.text?.substring(0, 100) + "...",
|
||||
hasImages: !!command.payload.images,
|
||||
mode: command.payload.mode,
|
||||
providerProfile: command.payload.providerProfile,
|
||||
})
|
||||
|
||||
this.provider.createTask(
|
||||
command.payload.text,
|
||||
command.payload.images,
|
||||
undefined, // parentTask
|
||||
undefined, // options
|
||||
{ mode: command.payload.mode, currentApiConfigName: command.payload.providerProfile },
|
||||
)
|
||||
|
||||
this.provider.createTask(command.payload.text, command.payload.images)
|
||||
break
|
||||
}
|
||||
case ExtensionBridgeCommandName.StopTask: {
|
||||
const instance = await this.updateInstance()
|
||||
const instance = this.updateInstance()
|
||||
|
||||
if (instance.task.taskStatus === TaskStatus.Running) {
|
||||
console.log(`[ExtensionChannel] command -> cancelTask() | ${command.instanceId}`)
|
||||
|
|
@ -100,7 +86,6 @@ export class ExtensionChannel extends BaseChannel<
|
|||
this.provider.clearTask()
|
||||
this.provider.postStateToWebview()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case ExtensionBridgeCommandName.ResumeTask: {
|
||||
|
|
@ -108,6 +93,7 @@ export class ExtensionChannel extends BaseChannel<
|
|||
taskId: command.payload.taskId,
|
||||
})
|
||||
|
||||
// Resume the task from history by taskId
|
||||
this.provider.resumeTask(command.payload.taskId)
|
||||
this.provider.postStateToWebview()
|
||||
break
|
||||
|
|
@ -136,12 +122,12 @@ export class ExtensionChannel extends BaseChannel<
|
|||
}
|
||||
|
||||
private async registerInstance(_socket: Socket): Promise<void> {
|
||||
const instance = await this.updateInstance()
|
||||
const instance = this.updateInstance()
|
||||
await this.publish(ExtensionSocketEvents.REGISTER, instance)
|
||||
}
|
||||
|
||||
private async unregisterInstance(_socket: Socket): Promise<void> {
|
||||
const instance = await this.updateInstance()
|
||||
const instance = this.updateInstance()
|
||||
await this.publish(ExtensionSocketEvents.UNREGISTER, instance)
|
||||
}
|
||||
|
||||
|
|
@ -149,7 +135,7 @@ export class ExtensionChannel extends BaseChannel<
|
|||
this.stopHeartbeat()
|
||||
|
||||
this.heartbeatInterval = setInterval(async () => {
|
||||
const instance = await this.updateInstance()
|
||||
const instance = this.updateInstance()
|
||||
|
||||
try {
|
||||
socket.emit(ExtensionSocketEvents.HEARTBEAT, instance)
|
||||
|
|
@ -183,19 +169,14 @@ export class ExtensionChannel extends BaseChannel<
|
|||
{ from: RooCodeEventName.TaskInteractive, to: ExtensionBridgeEventName.TaskInteractive },
|
||||
{ from: RooCodeEventName.TaskResumable, to: ExtensionBridgeEventName.TaskResumable },
|
||||
{ from: RooCodeEventName.TaskIdle, to: ExtensionBridgeEventName.TaskIdle },
|
||||
{ from: RooCodeEventName.TaskPaused, to: ExtensionBridgeEventName.TaskPaused },
|
||||
{ from: RooCodeEventName.TaskUnpaused, to: ExtensionBridgeEventName.TaskUnpaused },
|
||||
{ from: RooCodeEventName.TaskSpawned, to: ExtensionBridgeEventName.TaskSpawned },
|
||||
{ from: RooCodeEventName.TaskUserMessage, to: ExtensionBridgeEventName.TaskUserMessage },
|
||||
{ from: RooCodeEventName.TaskTokenUsageUpdated, to: ExtensionBridgeEventName.TaskTokenUsageUpdated },
|
||||
] as const
|
||||
|
||||
eventMapping.forEach(({ from, to }) => {
|
||||
// Create and store the listener function for cleanup.
|
||||
const listener = async (..._args: unknown[]) => {
|
||||
// Create and store the listener function for cleanup/
|
||||
const listener = (..._args: unknown[]) => {
|
||||
this.publish(ExtensionSocketEvents.EVENT, {
|
||||
type: to,
|
||||
instance: await this.updateInstance(),
|
||||
instance: this.updateInstance(),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
|
@ -214,37 +195,24 @@ export class ExtensionChannel extends BaseChannel<
|
|||
this.eventListeners.clear()
|
||||
}
|
||||
|
||||
private async updateInstance(): Promise<ExtensionInstance> {
|
||||
private updateInstance(): ExtensionInstance {
|
||||
const task = this.provider?.getCurrentTask()
|
||||
const taskHistory = this.provider?.getRecentTasks() ?? []
|
||||
|
||||
const mode = await this.provider?.getMode()
|
||||
const modes = (await this.provider?.getModes()) ?? []
|
||||
|
||||
const providerProfile = await this.provider?.getProviderProfile()
|
||||
const providerProfiles = (await this.provider?.getProviderProfiles()) ?? []
|
||||
|
||||
this.extensionInstance = {
|
||||
...this.extensionInstance,
|
||||
appProperties: this.extensionInstance.appProperties ?? this.provider.appProperties,
|
||||
gitProperties: this.extensionInstance.gitProperties ?? this.provider.gitProperties,
|
||||
lastHeartbeat: Date.now(),
|
||||
task: task
|
||||
? {
|
||||
taskId: task.taskId,
|
||||
parentTaskId: task.parentTaskId,
|
||||
childTaskId: task.childTaskId,
|
||||
taskStatus: task.taskStatus,
|
||||
taskAsk: task?.taskAsk,
|
||||
queuedMessages: task.queuedMessages,
|
||||
tokenUsage: task.tokenUsage,
|
||||
...task.metadata,
|
||||
}
|
||||
: { taskId: "", taskStatus: TaskStatus.None },
|
||||
taskAsk: task?.taskAsk,
|
||||
taskHistory,
|
||||
mode,
|
||||
providerProfile,
|
||||
modes,
|
||||
providerProfiles,
|
||||
}
|
||||
|
||||
return this.extensionInstance
|
||||
|
|
|
|||
|
|
@ -1,297 +0,0 @@
|
|||
import type { Socket } from "socket.io-client"
|
||||
|
||||
import {
|
||||
type TaskProviderLike,
|
||||
type ExtensionInstance,
|
||||
type ExtensionBridgeCommand,
|
||||
type ExtensionBridgeEvent,
|
||||
RooCodeEventName,
|
||||
TaskStatus,
|
||||
ExtensionBridgeCommandName,
|
||||
ExtensionBridgeEventName,
|
||||
ExtensionSocketEvents,
|
||||
HEARTBEAT_INTERVAL_MS,
|
||||
} from "@roo-code/types"
|
||||
|
||||
export class ExtensionManager {
|
||||
private instanceId: string
|
||||
private userId: string
|
||||
private provider: TaskProviderLike
|
||||
private extensionInstance: ExtensionInstance
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null
|
||||
private socket: Socket | null = null
|
||||
|
||||
constructor(instanceId: string, userId: string, provider: TaskProviderLike) {
|
||||
this.instanceId = instanceId
|
||||
this.userId = userId
|
||||
this.provider = provider
|
||||
|
||||
this.extensionInstance = {
|
||||
instanceId: this.instanceId,
|
||||
userId: this.userId,
|
||||
workspacePath: this.provider.cwd,
|
||||
appProperties: this.provider.appProperties,
|
||||
gitProperties: this.provider.gitProperties,
|
||||
lastHeartbeat: Date.now(),
|
||||
task: {
|
||||
taskId: "",
|
||||
taskStatus: TaskStatus.None,
|
||||
},
|
||||
taskHistory: [],
|
||||
}
|
||||
|
||||
this.setupListeners()
|
||||
}
|
||||
|
||||
public async onConnect(socket: Socket): Promise<void> {
|
||||
this.socket = socket
|
||||
await this.registerInstance(socket)
|
||||
this.startHeartbeat(socket)
|
||||
}
|
||||
|
||||
public onDisconnect(): void {
|
||||
this.stopHeartbeat()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
public async onReconnect(socket: Socket): Promise<void> {
|
||||
this.socket = socket
|
||||
await this.registerInstance(socket)
|
||||
this.startHeartbeat(socket)
|
||||
}
|
||||
|
||||
public async cleanup(socket: Socket | null): Promise<void> {
|
||||
this.stopHeartbeat()
|
||||
|
||||
if (socket) {
|
||||
await this.unregisterInstance(socket)
|
||||
}
|
||||
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
public handleExtensionCommand(message: ExtensionBridgeCommand): void {
|
||||
if (message.instanceId !== this.instanceId) {
|
||||
console.log(`[ExtensionManager] command -> instance id mismatch | ${this.instanceId}`, {
|
||||
messageInstanceId: message.instanceId,
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case ExtensionBridgeCommandName.StartTask: {
|
||||
console.log(`[ExtensionManager] command -> createTask() | ${message.instanceId}`, {
|
||||
text: message.payload.text?.substring(0, 100) + "...",
|
||||
hasImages: !!message.payload.images,
|
||||
})
|
||||
|
||||
this.provider.createTask(message.payload.text, message.payload.images)
|
||||
|
||||
break
|
||||
}
|
||||
case ExtensionBridgeCommandName.StopTask: {
|
||||
const instance = this.updateInstance()
|
||||
|
||||
if (instance.task.taskStatus === TaskStatus.Running) {
|
||||
console.log(`[ExtensionManager] command -> cancelTask() | ${message.instanceId}`)
|
||||
|
||||
this.provider.cancelTask()
|
||||
this.provider.postStateToWebview()
|
||||
} else if (instance.task.taskId) {
|
||||
console.log(`[ExtensionManager] command -> clearTask() | ${message.instanceId}`)
|
||||
|
||||
this.provider.clearTask()
|
||||
this.provider.postStateToWebview()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case ExtensionBridgeCommandName.ResumeTask: {
|
||||
console.log(`[ExtensionManager] command -> resumeTask() | ${message.instanceId}`, {
|
||||
taskId: message.payload.taskId,
|
||||
})
|
||||
|
||||
// Resume the task from history by taskId
|
||||
this.provider.resumeTask(message.payload.taskId)
|
||||
|
||||
this.provider.postStateToWebview()
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async registerInstance(socket: Socket): Promise<void> {
|
||||
const instance = this.updateInstance()
|
||||
|
||||
try {
|
||||
socket.emit(ExtensionSocketEvents.REGISTER, instance)
|
||||
|
||||
console.log(
|
||||
`[ExtensionManager] emit() -> ${ExtensionSocketEvents.REGISTER}`,
|
||||
// instance,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.REGISTER}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private async unregisterInstance(socket: Socket): Promise<void> {
|
||||
const instance = this.updateInstance()
|
||||
|
||||
try {
|
||||
socket.emit(ExtensionSocketEvents.UNREGISTER, instance)
|
||||
|
||||
console.log(
|
||||
`[ExtensionManager] emit() -> ${ExtensionSocketEvents.UNREGISTER}`,
|
||||
// instance,
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.UNREGISTER}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(socket: Socket): void {
|
||||
this.stopHeartbeat()
|
||||
|
||||
this.heartbeatInterval = setInterval(async () => {
|
||||
const instance = this.updateInstance()
|
||||
|
||||
try {
|
||||
socket.emit(ExtensionSocketEvents.HEARTBEAT, instance)
|
||||
|
||||
// console.log(
|
||||
// `[ExtensionManager] emit() -> ${ExtensionSocketEvents.HEARTBEAT}`,
|
||||
// instance,
|
||||
// );
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.HEARTBEAT}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}, HEARTBEAT_INTERVAL_MS)
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval)
|
||||
this.heartbeatInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
private setupListeners(): void {
|
||||
const eventMapping = [
|
||||
{
|
||||
from: RooCodeEventName.TaskCreated,
|
||||
to: ExtensionBridgeEventName.TaskCreated,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskStarted,
|
||||
to: ExtensionBridgeEventName.TaskStarted,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskCompleted,
|
||||
to: ExtensionBridgeEventName.TaskCompleted,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskAborted,
|
||||
to: ExtensionBridgeEventName.TaskAborted,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskFocused,
|
||||
to: ExtensionBridgeEventName.TaskFocused,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskUnfocused,
|
||||
to: ExtensionBridgeEventName.TaskUnfocused,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskActive,
|
||||
to: ExtensionBridgeEventName.TaskActive,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskInteractive,
|
||||
to: ExtensionBridgeEventName.TaskInteractive,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskResumable,
|
||||
to: ExtensionBridgeEventName.TaskResumable,
|
||||
},
|
||||
{
|
||||
from: RooCodeEventName.TaskIdle,
|
||||
to: ExtensionBridgeEventName.TaskIdle,
|
||||
},
|
||||
] as const
|
||||
|
||||
const addListener =
|
||||
(type: ExtensionBridgeEventName) =>
|
||||
async (..._args: unknown[]) => {
|
||||
this.publishEvent({
|
||||
type,
|
||||
instance: this.updateInstance(),
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
eventMapping.forEach(({ from, to }) => this.provider.on(from, addListener(to)))
|
||||
}
|
||||
|
||||
private async publishEvent(message: ExtensionBridgeEvent): Promise<boolean> {
|
||||
if (!this.socket) {
|
||||
console.error("[ExtensionManager] publishEvent -> socket not available")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit(ExtensionSocketEvents.EVENT, message)
|
||||
|
||||
console.log(`[ExtensionManager] emit() -> ${ExtensionSocketEvents.EVENT} ${message.type}`, message)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[ExtensionManager] emit() failed -> ${ExtensionSocketEvents.EVENT}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private updateInstance(): ExtensionInstance {
|
||||
const task = this.provider?.getCurrentTask()
|
||||
const taskHistory = this.provider?.getRecentTasks() ?? []
|
||||
|
||||
this.extensionInstance = {
|
||||
...this.extensionInstance,
|
||||
appProperties: this.extensionInstance.appProperties ?? this.provider.appProperties,
|
||||
gitProperties: this.extensionInstance.gitProperties ?? this.provider.gitProperties,
|
||||
lastHeartbeat: Date.now(),
|
||||
task: task
|
||||
? {
|
||||
taskId: task.taskId,
|
||||
taskStatus: task.taskStatus,
|
||||
...task.metadata,
|
||||
}
|
||||
: { taskId: "", taskStatus: TaskStatus.None },
|
||||
taskAsk: task?.taskAsk,
|
||||
taskHistory,
|
||||
}
|
||||
|
||||
return this.extensionInstance
|
||||
}
|
||||
}
|
||||
|
|
@ -1,289 +0,0 @@
|
|||
import { io, type Socket } from "socket.io-client"
|
||||
|
||||
import { ConnectionState, type RetryConfig } from "@roo-code/types"
|
||||
|
||||
export interface SocketConnectionOptions {
|
||||
url: string
|
||||
socketOptions: Record<string, unknown>
|
||||
onConnect?: () => void | Promise<void>
|
||||
onDisconnect?: (reason: string) => void
|
||||
onReconnect?: (attemptNumber: number) => void | Promise<void>
|
||||
onError?: (error: Error) => void
|
||||
logger?: {
|
||||
log: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
warn: (message: string, ...args: unknown[]) => void
|
||||
}
|
||||
}
|
||||
|
||||
export class SocketConnectionManager {
|
||||
private socket: Socket | null = null
|
||||
private connectionState: ConnectionState = ConnectionState.DISCONNECTED
|
||||
private retryAttempt: number = 0
|
||||
private retryTimeout: NodeJS.Timeout | null = null
|
||||
private hasConnectedOnce: boolean = false
|
||||
|
||||
private readonly retryConfig: RetryConfig = {
|
||||
maxInitialAttempts: 10,
|
||||
initialDelay: 1_000,
|
||||
maxDelay: 15_000,
|
||||
backoffMultiplier: 2,
|
||||
}
|
||||
|
||||
private readonly CONNECTION_TIMEOUT = 2_000
|
||||
private readonly options: SocketConnectionOptions
|
||||
|
||||
constructor(options: SocketConnectionOptions, retryConfig?: Partial<RetryConfig>) {
|
||||
this.options = options
|
||||
|
||||
if (retryConfig) {
|
||||
this.retryConfig = { ...this.retryConfig, ...retryConfig }
|
||||
}
|
||||
}
|
||||
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connectionState === ConnectionState.CONNECTED) {
|
||||
console.log(`[SocketConnectionManager] Already connected`)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.connectionState === ConnectionState.CONNECTING || this.connectionState === ConnectionState.RETRYING) {
|
||||
console.log(`[SocketConnectionManager] Connection attempt already in progress`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Start connection attempt without blocking.
|
||||
this.startConnectionAttempt()
|
||||
}
|
||||
|
||||
private async startConnectionAttempt() {
|
||||
this.retryAttempt = 0
|
||||
|
||||
try {
|
||||
await this.connectWithRetry()
|
||||
} catch (error) {
|
||||
console.error(`[SocketConnectionManager] Initial connection attempts failed:`, error)
|
||||
|
||||
// If we've never connected successfully, we've exhausted our retry attempts
|
||||
// The user will need to manually retry or fix the issue
|
||||
this.connectionState = ConnectionState.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
private async connectWithRetry(): Promise<void> {
|
||||
let delay = this.retryConfig.initialDelay
|
||||
|
||||
while (this.retryAttempt < this.retryConfig.maxInitialAttempts) {
|
||||
try {
|
||||
this.connectionState = this.retryAttempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING
|
||||
|
||||
console.log(
|
||||
`[SocketConnectionManager] Connection attempt ${this.retryAttempt + 1} / ${this.retryConfig.maxInitialAttempts}`,
|
||||
)
|
||||
|
||||
await this.connectSocket()
|
||||
|
||||
console.log(`[SocketConnectionManager] Connected to ${this.options.url}`)
|
||||
|
||||
this.connectionState = ConnectionState.CONNECTED
|
||||
this.retryAttempt = 0
|
||||
|
||||
this.clearRetryTimeouts()
|
||||
|
||||
if (this.options.onConnect) {
|
||||
await this.options.onConnect()
|
||||
}
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
this.retryAttempt++
|
||||
|
||||
console.error(`[SocketConnectionManager] Connection attempt ${this.retryAttempt} failed:`, error)
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.disconnect()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
if (this.retryAttempt >= this.retryConfig.maxInitialAttempts) {
|
||||
this.connectionState = ConnectionState.FAILED
|
||||
|
||||
throw new Error(`Failed to connect after ${this.retryConfig.maxInitialAttempts} attempts`)
|
||||
}
|
||||
|
||||
console.log(`[SocketConnectionManager] Waiting ${delay}ms before retry...`)
|
||||
|
||||
await this.delay(delay)
|
||||
|
||||
delay = Math.min(delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async connectSocket(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = io(this.options.url, this.options.socketOptions)
|
||||
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
console.error(`[SocketConnectionManager] Connection timeout`)
|
||||
|
||||
if (this.connectionState !== ConnectionState.CONNECTED) {
|
||||
this.socket?.disconnect()
|
||||
reject(new Error("Connection timeout"))
|
||||
}
|
||||
}, this.CONNECTION_TIMEOUT)
|
||||
|
||||
this.socket.on("connect", async () => {
|
||||
clearTimeout(connectionTimeout)
|
||||
|
||||
const isReconnection = this.hasConnectedOnce
|
||||
|
||||
// If this is a reconnection (not the first connect), treat it as a
|
||||
// reconnect.
|
||||
// This handles server restarts where 'reconnect' event might not fire.
|
||||
if (isReconnection) {
|
||||
console.log(
|
||||
`[SocketConnectionManager] Treating connect as reconnection (server may have restarted)`,
|
||||
)
|
||||
|
||||
this.connectionState = ConnectionState.CONNECTED
|
||||
|
||||
if (this.options.onReconnect) {
|
||||
// Call onReconnect to re-register instance.
|
||||
await this.options.onReconnect(0)
|
||||
}
|
||||
}
|
||||
|
||||
this.hasConnectedOnce = true
|
||||
resolve()
|
||||
})
|
||||
|
||||
this.socket.on("disconnect", (reason: string) => {
|
||||
console.log(`[SocketConnectionManager] Disconnected (reason: ${reason})`)
|
||||
|
||||
this.connectionState = ConnectionState.DISCONNECTED
|
||||
|
||||
if (this.options.onDisconnect) {
|
||||
this.options.onDisconnect(reason)
|
||||
}
|
||||
|
||||
// Don't attempt to reconnect if we're manually disconnecting.
|
||||
const isManualDisconnect = reason === "io client disconnect"
|
||||
|
||||
if (!isManualDisconnect && this.hasConnectedOnce) {
|
||||
// After successful initial connection, rely entirely on Socket.IO's
|
||||
// reconnection.
|
||||
console.log(`[SocketConnectionManager] Socket.IO will handle reconnection (reason: ${reason})`)
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for reconnection attempts.
|
||||
this.socket.on("reconnect_attempt", (attemptNumber: number) => {
|
||||
console.log(`[SocketConnectionManager] Socket.IO reconnect attempt:`, {
|
||||
attemptNumber,
|
||||
})
|
||||
})
|
||||
|
||||
this.socket.on("reconnect", (attemptNumber: number) => {
|
||||
console.log(`[SocketConnectionManager] Socket reconnected (attempt: ${attemptNumber})`)
|
||||
|
||||
this.connectionState = ConnectionState.CONNECTED
|
||||
|
||||
if (this.options.onReconnect) {
|
||||
this.options.onReconnect(attemptNumber)
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on("reconnect_error", (error: Error) => {
|
||||
console.error(`[SocketConnectionManager] Socket.IO reconnect error:`, error)
|
||||
})
|
||||
|
||||
this.socket.on("reconnect_failed", () => {
|
||||
console.error(`[SocketConnectionManager] Socket.IO reconnection failed after all attempts`)
|
||||
|
||||
this.connectionState = ConnectionState.FAILED
|
||||
|
||||
// Socket.IO has exhausted its reconnection attempts
|
||||
// The connection is now permanently failed until manual intervention
|
||||
})
|
||||
|
||||
this.socket.on("error", (error) => {
|
||||
console.error(`[SocketConnectionManager] Socket error:`, error)
|
||||
|
||||
if (this.connectionState !== ConnectionState.CONNECTED) {
|
||||
clearTimeout(connectionTimeout)
|
||||
reject(error)
|
||||
}
|
||||
|
||||
if (this.options.onError) {
|
||||
this.options.onError(error)
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on("auth_error", (error) => {
|
||||
console.error(`[SocketConnectionManager] Authentication error:`, error)
|
||||
clearTimeout(connectionTimeout)
|
||||
reject(new Error(error.message || "Authentication failed"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.retryTimeout = setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
// 1. Custom retry for initial connection attempts.
|
||||
// 2. Socket.IO's built-in reconnection after successful initial connection.
|
||||
|
||||
private clearRetryTimeouts() {
|
||||
if (this.retryTimeout) {
|
||||
clearTimeout(this.retryTimeout)
|
||||
this.retryTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
console.log(`[SocketConnectionManager] Disconnecting...`)
|
||||
|
||||
this.clearRetryTimeouts()
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners()
|
||||
this.socket.disconnect()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
this.connectionState = ConnectionState.DISCONNECTED
|
||||
|
||||
console.log(`[SocketConnectionManager] Disconnected`)
|
||||
}
|
||||
|
||||
public getSocket(): Socket | null {
|
||||
return this.socket
|
||||
}
|
||||
|
||||
public getConnectionState(): ConnectionState {
|
||||
return this.connectionState
|
||||
}
|
||||
|
||||
public isConnected(): boolean {
|
||||
return this.connectionState === ConnectionState.CONNECTED && this.socket?.connected === true
|
||||
}
|
||||
|
||||
public async reconnect(): Promise<void> {
|
||||
if (this.connectionState === ConnectionState.CONNECTED) {
|
||||
console.log(`[SocketConnectionManager] Already connected`)
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[SocketConnectionManager] Manual reconnection requested`)
|
||||
|
||||
this.hasConnectedOnce = false
|
||||
|
||||
await this.disconnect()
|
||||
await this.connect()
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,8 @@ export interface SocketTransportOptions {
|
|||
socketOptions: Partial<ManagerOptions & SocketOptions>
|
||||
onConnect?: () => void | Promise<void>
|
||||
onDisconnect?: (reason: string) => void
|
||||
onReconnect?: () => void | Promise<void>
|
||||
onReconnect?: (attemptNumber: number) => void | Promise<void>
|
||||
onError?: (error: Error) => void
|
||||
logger?: {
|
||||
log: (message: string, ...args: unknown[]) => void
|
||||
error: (message: string, ...args: unknown[]) => void
|
||||
|
|
@ -22,11 +23,12 @@ export interface SocketTransportOptions {
|
|||
export class SocketTransport {
|
||||
private socket: Socket | null = null
|
||||
private connectionState: ConnectionState = ConnectionState.DISCONNECTED
|
||||
private retryAttempt: number = 0
|
||||
private retryTimeout: NodeJS.Timeout | null = null
|
||||
private isPreviouslyConnected: boolean = false
|
||||
private hasConnectedOnce: boolean = false
|
||||
|
||||
private readonly retryConfig: RetryConfig = {
|
||||
maxInitialAttempts: Infinity,
|
||||
maxInitialAttempts: 10,
|
||||
initialDelay: 1_000,
|
||||
maxDelay: 15_000,
|
||||
backoffMultiplier: 2,
|
||||
|
|
@ -43,68 +45,93 @@ export class SocketTransport {
|
|||
}
|
||||
}
|
||||
|
||||
// This is the initial connnect attempt. We need to implement our own
|
||||
// infinite retry mechanism since Socket.io's automatic reconnection only
|
||||
// kicks in after a successful initial connection.
|
||||
public async connect(): Promise<void> {
|
||||
if (this.connectionState === ConnectionState.CONNECTED) {
|
||||
console.log(`[SocketTransport#connect] Already connected`)
|
||||
console.log(`[SocketTransport] Already connected`)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.connectionState === ConnectionState.CONNECTING || this.connectionState === ConnectionState.RETRYING) {
|
||||
console.log(`[SocketTransport#connect] Already in progress`)
|
||||
console.log(`[SocketTransport] Connection attempt already in progress`)
|
||||
return
|
||||
}
|
||||
|
||||
let attempt = 0
|
||||
// Start connection attempt without blocking.
|
||||
this.startConnectionAttempt()
|
||||
}
|
||||
|
||||
private async startConnectionAttempt() {
|
||||
this.retryAttempt = 0
|
||||
|
||||
try {
|
||||
await this.connectWithRetry()
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[SocketTransport] Initial connection attempts failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
|
||||
// If we've never connected successfully, we've exhausted our retry attempts
|
||||
// The user will need to manually retry or fix the issue
|
||||
this.connectionState = ConnectionState.FAILED
|
||||
}
|
||||
}
|
||||
|
||||
private async connectWithRetry(): Promise<void> {
|
||||
let delay = this.retryConfig.initialDelay
|
||||
|
||||
while (attempt < this.retryConfig.maxInitialAttempts) {
|
||||
console.log(`[SocketTransport#connect] attempt = ${attempt + 1}, delay = ${delay}ms`)
|
||||
this.connectionState = attempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING
|
||||
|
||||
while (this.retryAttempt < this.retryConfig.maxInitialAttempts) {
|
||||
try {
|
||||
await this._connect()
|
||||
break
|
||||
} catch (_error) {
|
||||
attempt++
|
||||
this.connectionState = this.retryAttempt === 0 ? ConnectionState.CONNECTING : ConnectionState.RETRYING
|
||||
|
||||
console.log(
|
||||
`[SocketTransport] Connection attempt ${this.retryAttempt + 1} / ${this.retryConfig.maxInitialAttempts}`,
|
||||
)
|
||||
|
||||
await this.connectSocket()
|
||||
|
||||
console.log(`[SocketTransport] Connected to ${this.options.url}`)
|
||||
|
||||
this.connectionState = ConnectionState.CONNECTED
|
||||
this.retryAttempt = 0
|
||||
|
||||
this.clearRetryTimeouts()
|
||||
|
||||
if (this.options.onConnect) {
|
||||
await this.options.onConnect()
|
||||
}
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
this.retryAttempt++
|
||||
|
||||
console.error(`[SocketTransport] Connection attempt ${this.retryAttempt} failed:`, error)
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.disconnect()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
const promise = new Promise((resolve) => {
|
||||
this.retryTimeout = setTimeout(resolve, delay)
|
||||
})
|
||||
if (this.retryAttempt >= this.retryConfig.maxInitialAttempts) {
|
||||
this.connectionState = ConnectionState.FAILED
|
||||
|
||||
await promise
|
||||
throw new Error(`Failed to connect after ${this.retryConfig.maxInitialAttempts} attempts`)
|
||||
}
|
||||
|
||||
console.log(`[SocketTransport] Waiting ${delay}ms before retry...`)
|
||||
|
||||
await this.delay(delay)
|
||||
|
||||
delay = Math.min(delay * this.retryConfig.backoffMultiplier, this.retryConfig.maxDelay)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.retryTimeout) {
|
||||
clearTimeout(this.retryTimeout)
|
||||
this.retryTimeout = null
|
||||
}
|
||||
|
||||
if (this.socket?.connected) {
|
||||
console.log(`[SocketTransport#connect] connected - ${this.options.url}`)
|
||||
} else {
|
||||
// Since we have infinite retries this should never happen.
|
||||
this.connectionState = ConnectionState.FAILED
|
||||
console.error(`[SocketTransport#connect] Giving up`)
|
||||
}
|
||||
}
|
||||
|
||||
private async _connect(): Promise<void> {
|
||||
private async connectSocket(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = io(this.options.url, this.options.socketOptions)
|
||||
|
||||
let connectionTimeout: NodeJS.Timeout | null = setTimeout(() => {
|
||||
console.error(`[SocketTransport#_connect] failed to connect after ${this.CONNECTION_TIMEOUT}ms`)
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
console.error(`[SocketTransport] Connection timeout`)
|
||||
|
||||
if (this.connectionState !== ConnectionState.CONNECTED) {
|
||||
this.socket?.disconnect()
|
||||
|
|
@ -112,48 +139,31 @@ export class SocketTransport {
|
|||
}
|
||||
}, this.CONNECTION_TIMEOUT)
|
||||
|
||||
// https://socket.io/docs/v4/client-api/#event-connect
|
||||
this.socket.on("connect", async () => {
|
||||
console.log(
|
||||
`[SocketTransport#_connect] on(connect): isPreviouslyConnected = ${this.isPreviouslyConnected}`,
|
||||
)
|
||||
clearTimeout(connectionTimeout)
|
||||
|
||||
if (connectionTimeout) {
|
||||
clearTimeout(connectionTimeout)
|
||||
connectionTimeout = null
|
||||
}
|
||||
const isReconnection = this.hasConnectedOnce
|
||||
|
||||
this.connectionState = ConnectionState.CONNECTED
|
||||
// If this is a reconnection (not the first connect), treat it as a
|
||||
// reconnect. This handles server restarts where 'reconnect' event might not fire.
|
||||
if (isReconnection) {
|
||||
console.log(`[SocketTransport] Treating connect as reconnection (server may have restarted)`)
|
||||
|
||||
this.connectionState = ConnectionState.CONNECTED
|
||||
|
||||
if (this.isPreviouslyConnected) {
|
||||
if (this.options.onReconnect) {
|
||||
await this.options.onReconnect()
|
||||
}
|
||||
} else {
|
||||
if (this.options.onConnect) {
|
||||
await this.options.onConnect()
|
||||
// Call onReconnect to re-register instance.
|
||||
await this.options.onReconnect(0)
|
||||
}
|
||||
}
|
||||
|
||||
this.isPreviouslyConnected = true
|
||||
this.hasConnectedOnce = true
|
||||
resolve()
|
||||
})
|
||||
|
||||
// https://socket.io/docs/v4/client-api/#event-connect_error
|
||||
this.socket.on("connect_error", (error) => {
|
||||
if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) {
|
||||
console.error(`[SocketTransport] on(connect_error): ${error.message}`)
|
||||
clearTimeout(connectionTimeout)
|
||||
connectionTimeout = null
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
this.socket.on("disconnect", (reason: string) => {
|
||||
console.log(`[SocketTransport] Disconnected (reason: ${reason})`)
|
||||
|
||||
// https://socket.io/docs/v4/client-api/#event-disconnect
|
||||
this.socket.on("disconnect", (reason, details) => {
|
||||
console.log(
|
||||
`[SocketTransport#_connect] on(disconnect) (reason: ${reason}, details: ${JSON.stringify(details)})`,
|
||||
)
|
||||
this.connectionState = ConnectionState.DISCONNECTED
|
||||
|
||||
if (this.options.onDisconnect) {
|
||||
|
|
@ -163,95 +173,91 @@ export class SocketTransport {
|
|||
// Don't attempt to reconnect if we're manually disconnecting.
|
||||
const isManualDisconnect = reason === "io client disconnect"
|
||||
|
||||
if (!isManualDisconnect && this.isPreviouslyConnected) {
|
||||
// After successful initial connection, rely entirely on
|
||||
// Socket.IO's reconnection logic.
|
||||
console.log("[SocketTransport#_connect] will attempt to reconnect")
|
||||
} else {
|
||||
console.log("[SocketTransport#_connect] will *NOT* attempt to reconnect")
|
||||
if (!isManualDisconnect && this.hasConnectedOnce) {
|
||||
// After successful initial connection, rely entirely on Socket.IO's
|
||||
// reconnection.
|
||||
console.log(`[SocketTransport] Socket.IO will handle reconnection (reason: ${reason})`)
|
||||
}
|
||||
})
|
||||
|
||||
// https://socket.io/docs/v4/client-api/#event-error
|
||||
// Fired upon a connection error.
|
||||
this.socket.io.on("error", (error) => {
|
||||
// Connection error.
|
||||
if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) {
|
||||
console.error(`[SocketTransport#_connect] on(error): ${error.message}`)
|
||||
clearTimeout(connectionTimeout)
|
||||
connectionTimeout = null
|
||||
reject(error)
|
||||
}
|
||||
|
||||
// Post-connection error.
|
||||
if (this.connectionState === ConnectionState.CONNECTED) {
|
||||
console.error(`[SocketTransport#_connect] on(error): ${error.message}`)
|
||||
}
|
||||
// Listen for reconnection attempts.
|
||||
this.socket.on("reconnect_attempt", (attemptNumber: number) => {
|
||||
console.log(`[SocketTransport] Socket.IO reconnect attempt:`, {
|
||||
attemptNumber,
|
||||
})
|
||||
})
|
||||
|
||||
// https://socket.io/docs/v4/client-api/#event-reconnect
|
||||
// Fired upon a successful reconnection.
|
||||
this.socket.io.on("reconnect", (attempt) => {
|
||||
console.log(`[SocketTransport#_connect] on(reconnect) - ${attempt}`)
|
||||
this.socket.on("reconnect", (attemptNumber: number) => {
|
||||
console.log(`[SocketTransport] Socket reconnected (attempt: ${attemptNumber})`)
|
||||
|
||||
this.connectionState = ConnectionState.CONNECTED
|
||||
|
||||
if (this.options.onReconnect) {
|
||||
this.options.onReconnect()
|
||||
this.options.onReconnect(attemptNumber)
|
||||
}
|
||||
})
|
||||
|
||||
// https://socket.io/docs/v4/client-api/#event-reconnect_attempt
|
||||
// Fired upon an attempt to reconnect.
|
||||
this.socket.io.on("reconnect_attempt", (attempt) => {
|
||||
console.log(`[SocketTransport#_connect] on(reconnect_attempt) - ${attempt}`)
|
||||
this.socket.on("reconnect_error", (error: Error) => {
|
||||
console.error(`[SocketTransport] Socket.IO reconnect error:`, error)
|
||||
})
|
||||
|
||||
// https://socket.io/docs/v4/client-api/#event-reconnect_error
|
||||
// Fired upon a reconnection attempt error.
|
||||
this.socket.io.on("reconnect_error", (error) => {
|
||||
console.error(`[SocketTransport#_connect] on(reconnect_error): ${error.message}`)
|
||||
})
|
||||
this.socket.on("reconnect_failed", () => {
|
||||
console.error(`[SocketTransport] Socket.IO reconnection failed after all attempts`)
|
||||
|
||||
// https://socket.io/docs/v4/client-api/#event-reconnect_failed
|
||||
// Fired when couldn't reconnect within `reconnectionAttempts`.
|
||||
// Since we use infinite retries, this should never fire.
|
||||
this.socket.io.on("reconnect_failed", () => {
|
||||
console.error(`[SocketTransport#_connect] on(reconnect_failed) - giving up`)
|
||||
this.connectionState = ConnectionState.FAILED
|
||||
|
||||
// Socket.IO has exhausted its reconnection attempts
|
||||
// The connection is now permanently failed until manual intervention
|
||||
})
|
||||
|
||||
// This is a custom event fired by the server.
|
||||
this.socket.on("auth_error", (error) => {
|
||||
console.error(
|
||||
`[SocketTransport#_connect] on(auth_error): ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
this.socket.on("error", (error) => {
|
||||
console.error(`[SocketTransport] Socket error:`, error)
|
||||
|
||||
if (connectionTimeout && this.connectionState !== ConnectionState.CONNECTED) {
|
||||
if (this.connectionState !== ConnectionState.CONNECTED) {
|
||||
clearTimeout(connectionTimeout)
|
||||
connectionTimeout = null
|
||||
reject(new Error(error.message || "Authentication failed"))
|
||||
reject(error)
|
||||
}
|
||||
|
||||
if (this.options.onError) {
|
||||
this.options.onError(error)
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on("auth_error", (error) => {
|
||||
console.error(`[SocketTransport] Authentication error:`, error)
|
||||
clearTimeout(connectionTimeout)
|
||||
reject(new Error(error.message || "Authentication failed"))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
console.log(`[SocketTransport#disconnect] Disconnecting...`)
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
this.retryTimeout = setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
private clearRetryTimeouts() {
|
||||
if (this.retryTimeout) {
|
||||
clearTimeout(this.retryTimeout)
|
||||
this.retryTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
public async disconnect(): Promise<void> {
|
||||
console.log(`[SocketTransport] Disconnecting...`)
|
||||
|
||||
this.clearRetryTimeouts()
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners()
|
||||
this.socket.io.removeAllListeners()
|
||||
this.socket.disconnect()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
this.connectionState = ConnectionState.DISCONNECTED
|
||||
console.log(`[SocketTransport#disconnect] Disconnected`)
|
||||
|
||||
console.log(`[SocketTransport] Disconnected`)
|
||||
}
|
||||
|
||||
public getSocket(): Socket | null {
|
||||
|
|
@ -267,14 +273,15 @@ export class SocketTransport {
|
|||
}
|
||||
|
||||
public async reconnect(): Promise<void> {
|
||||
console.log(`[SocketTransport#reconnect] Manually reconnecting...`)
|
||||
|
||||
if (this.connectionState === ConnectionState.CONNECTED) {
|
||||
console.log(`[SocketTransport#reconnect] Already connected`)
|
||||
console.log(`[SocketTransport] Already connected`)
|
||||
return
|
||||
}
|
||||
|
||||
this.isPreviouslyConnected = false
|
||||
console.log(`[SocketTransport] Manual reconnection requested`)
|
||||
|
||||
this.hasConnectedOnce = false
|
||||
|
||||
await this.disconnect()
|
||||
await this.connect()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
TaskSocketEvents,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { type BaseChannelOptions, BaseChannel } from "./BaseChannel.js"
|
||||
import { BaseChannel } from "./BaseChannel.js"
|
||||
|
||||
type TaskEventListener = {
|
||||
[K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise<void>
|
||||
|
|
@ -26,9 +26,6 @@ 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.
|
||||
|
|
@ -72,11 +69,11 @@ export class TaskChannel extends BaseChannel<
|
|||
},
|
||||
] as const
|
||||
|
||||
constructor(options: TaskChannelOptions) {
|
||||
super(options)
|
||||
constructor(instanceId: string) {
|
||||
super(instanceId)
|
||||
}
|
||||
|
||||
protected async handleCommandImplementation(command: TaskBridgeCommand): Promise<void> {
|
||||
public handleCommand(command: TaskBridgeCommand): void {
|
||||
const task = this.subscribedTasks.get(command.taskId)
|
||||
|
||||
if (!task) {
|
||||
|
|
@ -90,14 +87,7 @@ export class TaskChannel extends BaseChannel<
|
|||
`[TaskChannel] ${TaskBridgeCommandName.Message} ${command.taskId} -> submitUserMessage()`,
|
||||
command,
|
||||
)
|
||||
|
||||
await task.submitUserMessage(
|
||||
command.payload.text,
|
||||
command.payload.images,
|
||||
command.payload.mode,
|
||||
command.payload.providerProfile,
|
||||
)
|
||||
|
||||
task.submitUserMessage(command.payload.text, command.payload.images)
|
||||
break
|
||||
|
||||
case TaskBridgeCommandName.ApproveAsk:
|
||||
|
|
@ -105,7 +95,6 @@ export class TaskChannel extends BaseChannel<
|
|||
`[TaskChannel] ${TaskBridgeCommandName.ApproveAsk} ${command.taskId} -> approveAsk()`,
|
||||
command,
|
||||
)
|
||||
|
||||
task.approveAsk(command.payload)
|
||||
break
|
||||
|
||||
|
|
@ -174,27 +163,25 @@ export class TaskChannel extends BaseChannel<
|
|||
public async unsubscribeFromTask(taskId: string, _socket: Socket): Promise<void> {
|
||||
const task = this.subscribedTasks.get(taskId)
|
||||
|
||||
if (!task) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.publish(TaskSocketEvents.LEAVE, { taskId }, (response: LeaveResponse) => {
|
||||
if (response.success) {
|
||||
console.log(`[TaskChannel#unsubscribeFromTask] unsubscribed from ${taskId}`)
|
||||
console.log(`[TaskChannel#unsubscribeFromTask] unsubscribed from ${taskId}`, response)
|
||||
} else {
|
||||
console.error(`[TaskChannel#unsubscribeFromTask] failed to unsubscribe from ${taskId}`)
|
||||
}
|
||||
|
||||
// If we failed to unsubscribe then something is probably wrong and
|
||||
// we should still discard this task from `subscribedTasks`.
|
||||
this.removeTaskListeners(task)
|
||||
this.subscribedTasks.delete(taskId)
|
||||
if (task) {
|
||||
this.removeTaskListeners(task)
|
||||
this.subscribedTasks.delete(taskId)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private setupTaskListeners(task: TaskLike): void {
|
||||
if (this.taskListeners.has(task.taskId)) {
|
||||
console.warn(`[TaskChannel] Listeners already exist for task, removing old listeners for ${task.taskId}`)
|
||||
console.warn("[TaskChannel] Listeners already exist for task, removing old listeners:", task.taskId)
|
||||
this.removeTaskListeners(task)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,279 +0,0 @@
|
|||
import type { Socket } from "socket.io-client"
|
||||
|
||||
import {
|
||||
type ClineMessage,
|
||||
type TaskEvents,
|
||||
type TaskLike,
|
||||
type TaskBridgeCommand,
|
||||
type TaskBridgeEvent,
|
||||
RooCodeEventName,
|
||||
TaskBridgeEventName,
|
||||
TaskBridgeCommandName,
|
||||
TaskSocketEvents,
|
||||
} from "@roo-code/types"
|
||||
|
||||
type TaskEventListener = {
|
||||
[K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise<void>
|
||||
}[keyof TaskEvents]
|
||||
|
||||
const TASK_EVENT_MAPPING: Record<TaskBridgeEventName, keyof TaskEvents> = {
|
||||
[TaskBridgeEventName.Message]: RooCodeEventName.Message,
|
||||
[TaskBridgeEventName.TaskModeSwitched]: RooCodeEventName.TaskModeSwitched,
|
||||
[TaskBridgeEventName.TaskInteractive]: RooCodeEventName.TaskInteractive,
|
||||
}
|
||||
|
||||
export class TaskManager {
|
||||
private subscribedTasks: Map<string, TaskLike> = new Map()
|
||||
private pendingTasks: Map<string, TaskLike> = new Map()
|
||||
private socket: Socket | null = null
|
||||
|
||||
private taskListeners: Map<string, Map<TaskBridgeEventName, TaskEventListener>> = new Map()
|
||||
|
||||
constructor() {}
|
||||
|
||||
public async onConnect(socket: Socket): Promise<void> {
|
||||
this.socket = socket
|
||||
|
||||
// Rejoin all subscribed tasks.
|
||||
for (const taskId of this.subscribedTasks.keys()) {
|
||||
try {
|
||||
socket.emit(TaskSocketEvents.JOIN, { taskId })
|
||||
|
||||
console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to any pending tasks.
|
||||
for (const task of this.pendingTasks.values()) {
|
||||
await this.subscribeToTask(task, socket)
|
||||
}
|
||||
|
||||
this.pendingTasks.clear()
|
||||
}
|
||||
|
||||
public onDisconnect(): void {
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
public async onReconnect(socket: Socket): Promise<void> {
|
||||
this.socket = socket
|
||||
|
||||
// Rejoin all subscribed tasks.
|
||||
for (const taskId of this.subscribedTasks.keys()) {
|
||||
try {
|
||||
socket.emit(TaskSocketEvents.JOIN, { taskId })
|
||||
|
||||
console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async cleanup(socket: Socket | null): Promise<void> {
|
||||
if (!socket) {
|
||||
return
|
||||
}
|
||||
|
||||
const unsubscribePromises = []
|
||||
|
||||
for (const taskId of this.subscribedTasks.keys()) {
|
||||
unsubscribePromises.push(this.unsubscribeFromTask(taskId, socket))
|
||||
}
|
||||
|
||||
await Promise.allSettled(unsubscribePromises)
|
||||
this.subscribedTasks.clear()
|
||||
this.taskListeners.clear()
|
||||
this.pendingTasks.clear()
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
public addPendingTask(task: TaskLike): void {
|
||||
this.pendingTasks.set(task.taskId, task)
|
||||
}
|
||||
|
||||
public async subscribeToTask(task: TaskLike, socket: Socket): Promise<void> {
|
||||
const taskId = task.taskId
|
||||
this.subscribedTasks.set(taskId, task)
|
||||
this.setupListeners(task)
|
||||
|
||||
try {
|
||||
socket.emit(TaskSocketEvents.JOIN, { taskId })
|
||||
console.log(`[TaskManager] emit() -> ${TaskSocketEvents.JOIN} ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] emit() failed -> ${TaskSocketEvents.JOIN}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public async unsubscribeFromTask(taskId: string, socket: Socket): Promise<void> {
|
||||
const task = this.subscribedTasks.get(taskId)
|
||||
|
||||
if (task) {
|
||||
this.removeListeners(task)
|
||||
this.subscribedTasks.delete(taskId)
|
||||
}
|
||||
|
||||
try {
|
||||
socket.emit(TaskSocketEvents.LEAVE, { taskId })
|
||||
|
||||
console.log(`[TaskManager] emit() -> ${TaskSocketEvents.LEAVE} ${taskId}`)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] emit() failed -> ${TaskSocketEvents.LEAVE}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public handleTaskCommand(message: TaskBridgeCommand): void {
|
||||
const task = this.subscribedTasks.get(message.taskId)
|
||||
|
||||
if (!task) {
|
||||
console.error(`[TaskManager#handleTaskCommand] Unable to find task ${message.taskId}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
switch (message.type) {
|
||||
case TaskBridgeCommandName.Message:
|
||||
console.log(
|
||||
`[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.Message} ${message.taskId} -> submitUserMessage()`,
|
||||
message,
|
||||
)
|
||||
|
||||
task.submitUserMessage(message.payload.text, message.payload.images)
|
||||
break
|
||||
case TaskBridgeCommandName.ApproveAsk:
|
||||
console.log(
|
||||
`[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.ApproveAsk} ${message.taskId} -> approveAsk()`,
|
||||
message,
|
||||
)
|
||||
|
||||
task.approveAsk(message.payload)
|
||||
break
|
||||
case TaskBridgeCommandName.DenyAsk:
|
||||
console.log(
|
||||
`[TaskManager#handleTaskCommand] ${TaskBridgeCommandName.DenyAsk} ${message.taskId} -> denyAsk()`,
|
||||
message,
|
||||
)
|
||||
|
||||
task.denyAsk(message.payload)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private setupListeners(task: TaskLike): void {
|
||||
if (this.taskListeners.has(task.taskId)) {
|
||||
console.warn("[TaskManager] Listeners already exist for task, removing old listeners:", task.taskId)
|
||||
|
||||
this.removeListeners(task)
|
||||
}
|
||||
|
||||
const listeners = new Map<TaskBridgeEventName, TaskEventListener>()
|
||||
|
||||
const onMessage = ({ action, message }: { action: string; message: ClineMessage }) => {
|
||||
this.publishEvent({
|
||||
type: TaskBridgeEventName.Message,
|
||||
taskId: task.taskId,
|
||||
action,
|
||||
message,
|
||||
})
|
||||
}
|
||||
|
||||
task.on(RooCodeEventName.Message, onMessage)
|
||||
listeners.set(TaskBridgeEventName.Message, onMessage)
|
||||
|
||||
const onTaskModeSwitched = (mode: string) => {
|
||||
this.publishEvent({
|
||||
type: TaskBridgeEventName.TaskModeSwitched,
|
||||
taskId: task.taskId,
|
||||
mode,
|
||||
})
|
||||
}
|
||||
|
||||
task.on(RooCodeEventName.TaskModeSwitched, onTaskModeSwitched)
|
||||
listeners.set(TaskBridgeEventName.TaskModeSwitched, onTaskModeSwitched)
|
||||
|
||||
const onTaskInteractive = (_taskId: string) => {
|
||||
this.publishEvent({
|
||||
type: TaskBridgeEventName.TaskInteractive,
|
||||
taskId: task.taskId,
|
||||
})
|
||||
}
|
||||
|
||||
task.on(RooCodeEventName.TaskInteractive, onTaskInteractive)
|
||||
|
||||
listeners.set(TaskBridgeEventName.TaskInteractive, onTaskInteractive)
|
||||
|
||||
this.taskListeners.set(task.taskId, listeners)
|
||||
|
||||
console.log("[TaskManager] Task listeners setup complete for:", task.taskId)
|
||||
}
|
||||
|
||||
private removeListeners(task: TaskLike): void {
|
||||
const listeners = this.taskListeners.get(task.taskId)
|
||||
|
||||
if (!listeners) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("[TaskManager] Removing task listeners for:", task.taskId)
|
||||
|
||||
listeners.forEach((listener, eventName) => {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
task.off(TASK_EVENT_MAPPING[eventName], listener as any)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] Error removing listener for ${String(eventName)} on task ${task.taskId}:`,
|
||||
error,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
this.taskListeners.delete(task.taskId)
|
||||
}
|
||||
|
||||
private async publishEvent(message: TaskBridgeEvent): Promise<boolean> {
|
||||
if (!this.socket) {
|
||||
console.error("[TaskManager] publishEvent -> socket not available")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.emit(TaskSocketEvents.EVENT, message)
|
||||
|
||||
if (message.type !== TaskBridgeEventName.Message) {
|
||||
console.log(
|
||||
`[TaskManager] emit() -> ${TaskSocketEvents.EVENT} ${message.taskId} ${message.type}`,
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[TaskManager] emit() failed -> ${TaskSocketEvents.EVENT}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ import type { Socket } from "socket.io-client"
|
|||
import {
|
||||
type TaskProviderLike,
|
||||
type TaskProviderEvents,
|
||||
type StaticAppProperties,
|
||||
RooCodeEventName,
|
||||
ExtensionBridgeEventName,
|
||||
ExtensionSocketEvents,
|
||||
|
|
@ -20,15 +19,6 @@ 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>>()
|
||||
|
||||
|
|
@ -63,13 +53,6 @@ describe("ExtensionChannel", () => {
|
|||
postStateToWebview: vi.fn(),
|
||||
postMessageToWebview: vi.fn(),
|
||||
getTelemetryProperties: vi.fn(),
|
||||
getMode: vi.fn().mockResolvedValue("code"),
|
||||
getModes: vi.fn().mockResolvedValue([
|
||||
{ slug: "code", name: "Code", description: "Code mode" },
|
||||
{ slug: "architect", name: "Architect", description: "Architect mode" },
|
||||
]),
|
||||
getProviderProfile: vi.fn().mockResolvedValue("default"),
|
||||
getProviderProfiles: vi.fn().mockResolvedValue([{ name: "default", description: "Default profile" }]),
|
||||
on: vi.fn((event: keyof TaskProviderEvents, listener: (...args: unknown[]) => unknown) => {
|
||||
if (!eventListeners.has(event)) {
|
||||
eventListeners.set(event, new Set())
|
||||
|
|
@ -90,12 +73,7 @@ describe("ExtensionChannel", () => {
|
|||
} as unknown as TaskProviderLike
|
||||
|
||||
// Create extension channel instance
|
||||
extensionChannel = new ExtensionChannel({
|
||||
instanceId,
|
||||
appProperties,
|
||||
userId,
|
||||
provider: mockProvider,
|
||||
})
|
||||
extensionChannel = new ExtensionChannel(instanceId, userId, mockProvider)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -116,11 +94,6 @@ describe("ExtensionChannel", () => {
|
|||
RooCodeEventName.TaskInteractive,
|
||||
RooCodeEventName.TaskResumable,
|
||||
RooCodeEventName.TaskIdle,
|
||||
RooCodeEventName.TaskPaused,
|
||||
RooCodeEventName.TaskUnpaused,
|
||||
RooCodeEventName.TaskSpawned,
|
||||
RooCodeEventName.TaskUserMessage,
|
||||
RooCodeEventName.TaskTokenUsageUpdated,
|
||||
]
|
||||
|
||||
// Check that on() was called for each event
|
||||
|
|
@ -171,12 +144,7 @@ describe("ExtensionChannel", () => {
|
|||
|
||||
it("should not have duplicate listeners after multiple channel creations", () => {
|
||||
// Create a second channel with the same provider
|
||||
const secondChannel = new ExtensionChannel({
|
||||
instanceId: "instance-2",
|
||||
appProperties,
|
||||
userId,
|
||||
provider: mockProvider,
|
||||
})
|
||||
const secondChannel = new ExtensionChannel("instance-2", userId, mockProvider)
|
||||
|
||||
// Each event should have exactly 2 listeners (one from each channel)
|
||||
eventListeners.forEach((listeners) => {
|
||||
|
|
@ -216,9 +184,6 @@ describe("ExtensionChannel", () => {
|
|||
// Connect the socket to enable publishing
|
||||
await extensionChannel.onConnect(mockSocket)
|
||||
|
||||
// Clear the mock calls from the connection (which emits a register event)
|
||||
;(mockSocket.emit as any).mockClear()
|
||||
|
||||
// Get a listener that was registered for TaskStarted
|
||||
const taskStartedListeners = eventListeners.get(RooCodeEventName.TaskStarted)
|
||||
expect(taskStartedListeners).toBeDefined()
|
||||
|
|
@ -227,7 +192,7 @@ describe("ExtensionChannel", () => {
|
|||
// Trigger the listener
|
||||
const listener = Array.from(taskStartedListeners!)[0]
|
||||
if (listener) {
|
||||
await listener("test-task-id")
|
||||
listener("test-task-id")
|
||||
}
|
||||
|
||||
// Verify the event was published to the socket
|
||||
|
|
@ -255,7 +220,8 @@ describe("ExtensionChannel", () => {
|
|||
}
|
||||
|
||||
// Listeners should still be the same count (not accumulated)
|
||||
expect(eventListeners.size).toBe(15)
|
||||
const expectedEventCount = 10 // Number of events we listen to
|
||||
expect(eventListeners.size).toBe(expectedEventCount)
|
||||
|
||||
// Each event should have exactly 1 listener
|
||||
eventListeners.forEach((listeners) => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import type { Socket } from "socket.io-client"
|
|||
import {
|
||||
type TaskLike,
|
||||
type ClineMessage,
|
||||
type StaticAppProperties,
|
||||
RooCodeEventName,
|
||||
TaskBridgeEventName,
|
||||
TaskBridgeCommandName,
|
||||
|
|
@ -23,15 +22,6 @@ 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 = {
|
||||
|
|
@ -85,10 +75,7 @@ describe("TaskChannel", () => {
|
|||
}
|
||||
|
||||
// Create task channel instance
|
||||
taskChannel = new TaskChannel({
|
||||
instanceId,
|
||||
appProperties,
|
||||
})
|
||||
taskChannel = new TaskChannel(instanceId)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -312,7 +299,8 @@ describe("TaskChannel", () => {
|
|||
|
||||
// Verify warning was logged
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
`[TaskChannel] Listeners already exist for task, removing old listeners for ${taskId}`,
|
||||
"[TaskChannel] Listeners already exist for task, removing old listeners:",
|
||||
taskId,
|
||||
)
|
||||
|
||||
// Verify only one set of listeners exists
|
||||
|
|
@ -333,7 +321,7 @@ describe("TaskChannel", () => {
|
|||
channel.subscribedTasks.set(taskId, mockTask)
|
||||
})
|
||||
|
||||
it("should handle Message command", async () => {
|
||||
it("should handle Message command", () => {
|
||||
const command = {
|
||||
type: TaskBridgeCommandName.Message,
|
||||
taskId,
|
||||
|
|
@ -344,17 +332,12 @@ describe("TaskChannel", () => {
|
|||
},
|
||||
}
|
||||
|
||||
await taskChannel.handleCommand(command)
|
||||
taskChannel.handleCommand(command)
|
||||
|
||||
expect(mockTask.submitUserMessage).toHaveBeenCalledWith(
|
||||
command.payload.text,
|
||||
command.payload.images,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(mockTask.submitUserMessage).toHaveBeenCalledWith(command.payload.text, command.payload.images)
|
||||
})
|
||||
|
||||
it("should handle ApproveAsk command", async () => {
|
||||
it("should handle ApproveAsk command", () => {
|
||||
const command = {
|
||||
type: TaskBridgeCommandName.ApproveAsk,
|
||||
taskId,
|
||||
|
|
@ -364,12 +347,12 @@ describe("TaskChannel", () => {
|
|||
},
|
||||
}
|
||||
|
||||
await taskChannel.handleCommand(command)
|
||||
taskChannel.handleCommand(command)
|
||||
|
||||
expect(mockTask.approveAsk).toHaveBeenCalledWith(command.payload)
|
||||
})
|
||||
|
||||
it("should handle DenyAsk command", async () => {
|
||||
it("should handle DenyAsk command", () => {
|
||||
const command = {
|
||||
type: TaskBridgeCommandName.DenyAsk,
|
||||
taskId,
|
||||
|
|
@ -379,12 +362,12 @@ describe("TaskChannel", () => {
|
|||
},
|
||||
}
|
||||
|
||||
await taskChannel.handleCommand(command)
|
||||
taskChannel.handleCommand(command)
|
||||
|
||||
expect(mockTask.denyAsk).toHaveBeenCalledWith(command.payload)
|
||||
})
|
||||
|
||||
it("should log error for unknown task", async () => {
|
||||
it("should log error for unknown task", () => {
|
||||
const errorSpy = vi.spyOn(console, "error")
|
||||
|
||||
const command = {
|
||||
|
|
@ -396,7 +379,7 @@ describe("TaskChannel", () => {
|
|||
},
|
||||
}
|
||||
|
||||
await taskChannel.handleCommand(command)
|
||||
taskChannel.handleCommand(command)
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith(`[TaskChannel] Unable to find task unknown-task`)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,43 +7,38 @@
|
|||
let vscodeModule: typeof import("vscode") | undefined
|
||||
|
||||
/**
|
||||
* Attempts to dynamically import the VS Code module.
|
||||
* Returns undefined if not running in a VS Code/Cursor extension context.
|
||||
* Attempts to dynamically import the `vscode` module.
|
||||
* Returns undefined if not running in a VSCode extension context.
|
||||
*/
|
||||
export async function importVscode(): Promise<typeof import("vscode") | undefined> {
|
||||
// Check if already loaded
|
||||
if (vscodeModule) {
|
||||
return vscodeModule
|
||||
}
|
||||
|
||||
try {
|
||||
// Method 1: Check if vscode is available in global scope (common in extension hosts).
|
||||
if (typeof globalThis !== "undefined" && "acquireVsCodeApi" in globalThis) {
|
||||
// We're in a webview context, vscode module won't be available.
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Method 2: Try to require the module (works in most extension contexts).
|
||||
if (typeof require !== "undefined") {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
vscodeModule = require("vscode")
|
||||
|
||||
if (vscodeModule) {
|
||||
console.log("VS Code module loaded from require")
|
||||
return vscodeModule
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error loading VS Code module:", error)
|
||||
console.error(`Error loading VS Code module: ${error instanceof Error ? error.message : String(error)}`)
|
||||
// Fall through to dynamic import.
|
||||
}
|
||||
}
|
||||
|
||||
// Method 3: Dynamic import (original approach, works in VSCode).
|
||||
vscodeModule = await import("vscode")
|
||||
console.log("VS Code module loaded from dynamic import")
|
||||
return vscodeModule
|
||||
} catch (error) {
|
||||
// Log the original error for debugging.
|
||||
console.warn("VS Code module not available in this environment:", error)
|
||||
console.warn(
|
||||
`VS Code module not available in this environment: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
export * from "./config.js"
|
||||
|
||||
export * from "./CloudAPI.js"
|
||||
export * from "./CloudService.js"
|
||||
export * from "./bridge/ExtensionBridgeService.js"
|
||||
export { CloudService } from "./CloudService.js"
|
||||
|
||||
export { BridgeOrchestrator } from "./bridge/index.js"
|
||||
|
|
|
|||
|
|
@ -587,32 +587,49 @@ export type TaskBridgeCommand = z.infer<typeof taskBridgeCommandSchema>
|
|||
* ExtensionSocketEvents
|
||||
*/
|
||||
|
||||
export const ExtensionSocketEvents = {
|
||||
CONNECTED: "extension:connected",
|
||||
export enum ExtensionSocketEvents {
|
||||
CONNECTED = "extension:connected",
|
||||
|
||||
REGISTER: "extension:register",
|
||||
UNREGISTER: "extension:unregister",
|
||||
REGISTER = "extension:register",
|
||||
UNREGISTER = "extension:unregister",
|
||||
|
||||
HEARTBEAT: "extension:heartbeat",
|
||||
HEARTBEAT = "extension:heartbeat",
|
||||
|
||||
EVENT: "extension:event", // event from extension instance
|
||||
RELAYED_EVENT: "extension:relayed_event", // relay from server
|
||||
EVENT = "extension:event", // event from extension instance
|
||||
RELAYED_EVENT = "extension:relayed_event", // relay from server
|
||||
|
||||
COMMAND: "extension:command", // command from user
|
||||
RELAYED_COMMAND: "extension:relayed_command", // relay from server
|
||||
} as const
|
||||
COMMAND = "extension:command", // command from user
|
||||
RELAYED_COMMAND = "extension:relayed_command", // relay from server
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskSocketEvents
|
||||
*/
|
||||
|
||||
export const TaskSocketEvents = {
|
||||
JOIN: "task:join",
|
||||
LEAVE: "task:leave",
|
||||
export enum TaskSocketEvents {
|
||||
JOIN = "task:join",
|
||||
LEAVE = "task:leave",
|
||||
|
||||
EVENT: "task:event", // event from extension task
|
||||
RELAYED_EVENT: "task:relayed_event", // relay from server
|
||||
EVENT = "task:event", // event from extension task
|
||||
RELAYED_EVENT = "task:relayed_event", // relay from server
|
||||
|
||||
COMMAND: "task:command", // command from user
|
||||
RELAYED_COMMAND: "task:relayed_command", // relay from server
|
||||
} as const
|
||||
COMMAND = "task:command", // command from user
|
||||
RELAYED_COMMAND = "task:relayed_command", // relay from server
|
||||
}
|
||||
|
||||
/**
|
||||
* `emit()` Response Types
|
||||
*/
|
||||
|
||||
export type JoinResponse = {
|
||||
success: boolean
|
||||
error?: string
|
||||
taskId?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
||||
export type LeaveResponse = {
|
||||
success: boolean
|
||||
taskId?: string
|
||||
timestamp?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import {
|
|||
type ClineAsk,
|
||||
type ToolProgressStatus,
|
||||
type HistoryItem,
|
||||
type CreateTaskOptions,
|
||||
RooCodeEventName,
|
||||
TelemetryEventName,
|
||||
TaskStatus,
|
||||
|
|
@ -34,15 +33,13 @@ import {
|
|||
isIdleAsk,
|
||||
isInteractiveAsk,
|
||||
isResumableAsk,
|
||||
QueuedMessage,
|
||||
} from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { CloudService, BridgeOrchestrator } from "@roo-code/cloud"
|
||||
|
||||
// api
|
||||
import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api"
|
||||
import { ApiStream, GroundingSource } from "../../api/transform/stream"
|
||||
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
|
||||
import { ApiStream } from "../../api/transform/stream"
|
||||
|
||||
// shared
|
||||
import { findLastIndex } from "../../shared/array"
|
||||
|
|
@ -50,7 +47,7 @@ import { combineApiRequests } from "../../shared/combineApiRequests"
|
|||
import { combineCommandSequences } from "../../shared/combineCommandSequences"
|
||||
import { t } from "../../i18n"
|
||||
import { ClineApiReqCancelReason, ClineApiReqInfo } from "../../shared/ExtensionMessage"
|
||||
import { getApiMetrics, hasTokenUsageChanged } from "../../shared/getApiMetrics"
|
||||
import { getApiMetrics } from "../../shared/getApiMetrics"
|
||||
import { ClineAskResponse } from "../../shared/WebviewMessage"
|
||||
import { defaultModeSlug } from "../../shared/modes"
|
||||
import { DiffStrategy } from "../../shared/tools"
|
||||
|
|
@ -81,7 +78,6 @@ import { SYSTEM_PROMPT } from "../prompts/system"
|
|||
|
||||
// core modules
|
||||
import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector"
|
||||
import { restoreTodoListForTask } from "../tools/updateTodoListTool"
|
||||
import { FileContextTracker } from "../context-tracking/FileContextTracker"
|
||||
import { RooIgnoreController } from "../ignore/RooIgnoreController"
|
||||
import { RooProtectedController } from "../protect/RooProtectedController"
|
||||
|
|
@ -91,14 +87,7 @@ import { truncateConversationIfNeeded } from "../sliding-window"
|
|||
import { ClineProvider } from "../webview/ClineProvider"
|
||||
import { MultiSearchReplaceDiffStrategy } from "../diff/strategies/multi-search-replace"
|
||||
import { MultiFileSearchReplaceDiffStrategy } from "../diff/strategies/multi-file-search-replace"
|
||||
import {
|
||||
type ApiMessage,
|
||||
readApiMessages,
|
||||
saveApiMessages,
|
||||
readTaskMessages,
|
||||
saveTaskMessages,
|
||||
taskMetadata,
|
||||
} from "../task-persistence"
|
||||
import { readApiMessages, saveApiMessages, readTaskMessages, saveTaskMessages, taskMetadata } from "../task-persistence"
|
||||
import { getEnvironmentDetails } from "../environment/getEnvironmentDetails"
|
||||
import { checkContextWindowExceededError } from "../context/context-management/context-error-handling"
|
||||
import {
|
||||
|
|
@ -110,18 +99,19 @@ import {
|
|||
checkpointDiff,
|
||||
} from "../checkpoints"
|
||||
import { processUserContentMentions } from "../mentions/processUserContentMentions"
|
||||
import { ApiMessage } from "../task-persistence/apiMessages"
|
||||
import { getMessagesSinceLastSummary, summarizeConversation } from "../condense"
|
||||
import { Gpt5Metadata, ClineMessageWithMetadata } from "./types"
|
||||
import { MessageQueueService } from "../message-queue/MessageQueueService"
|
||||
|
||||
import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning"
|
||||
import { restoreTodoListForTask } from "../tools/updateTodoListTool"
|
||||
import { AutoApprovalHandler } from "./AutoApprovalHandler"
|
||||
import { Gpt5Metadata, ClineMessageWithMetadata } from "./types"
|
||||
|
||||
const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes
|
||||
const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds
|
||||
const FORCED_CONTEXT_REDUCTION_PERCENT = 75 // Keep 75% of context (remove 25%) on context window errors
|
||||
const MAX_CONTEXT_WINDOW_RETRIES = 3 // Maximum retries for context window errors
|
||||
|
||||
export interface TaskOptions extends CreateTaskOptions {
|
||||
export type TaskOptions = {
|
||||
provider: ClineProvider
|
||||
apiConfiguration: ProviderSettings
|
||||
enableDiff?: boolean
|
||||
|
|
@ -139,15 +129,10 @@ export interface TaskOptions extends CreateTaskOptions {
|
|||
taskNumber?: number
|
||||
onCreated?: (task: Task) => void
|
||||
initialTodos?: TodoItem[]
|
||||
workspacePath?: string
|
||||
}
|
||||
|
||||
export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
||||
readonly taskId: string
|
||||
readonly rootTaskId?: string
|
||||
readonly parentTaskId?: string
|
||||
childTaskId?: string
|
||||
|
||||
readonly instanceId: string
|
||||
readonly metadata: TaskMetadata
|
||||
|
||||
|
|
@ -273,10 +258,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
// Task Bridge
|
||||
enableBridge: boolean
|
||||
|
||||
// Message Queue Service
|
||||
public readonly messageQueueService: MessageQueueService
|
||||
private messageQueueStateChangedHandler: (() => void) | undefined
|
||||
bridge: BridgeOrchestrator | null = null
|
||||
|
||||
// Streaming
|
||||
isWaitingForFirstChunk = false
|
||||
|
|
@ -295,10 +277,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
private lastUsedInstructions?: string
|
||||
private skipPrevResponseIdOnce: boolean = false
|
||||
|
||||
// Token Usage Cache
|
||||
private tokenUsageSnapshot?: TokenUsage
|
||||
private tokenUsageSnapshotAt?: number
|
||||
|
||||
constructor({
|
||||
provider,
|
||||
apiConfiguration,
|
||||
|
|
@ -316,7 +294,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
taskNumber = -1,
|
||||
onCreated,
|
||||
initialTodos,
|
||||
workspacePath,
|
||||
}: TaskOptions) {
|
||||
super()
|
||||
|
||||
|
|
@ -325,9 +302,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
|
||||
this.taskId = historyItem ? historyItem.id : crypto.randomUUID()
|
||||
this.rootTaskId = historyItem ? historyItem.rootTaskId : rootTask?.taskId
|
||||
this.parentTaskId = historyItem ? historyItem.parentTaskId : parentTask?.taskId
|
||||
this.childTaskId = undefined
|
||||
|
||||
this.metadata = {
|
||||
task: historyItem ? historyItem.task : task,
|
||||
|
|
@ -337,7 +311,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// Normal use-case is usually retry similar history task with new workspace.
|
||||
this.workspacePath = parentTask
|
||||
? parentTask.workspacePath
|
||||
: (workspacePath ?? getWorkspacePath(path.join(os.homedir(), "Desktop")))
|
||||
: getWorkspacePath(path.join(os.homedir(), "Desktop"))
|
||||
|
||||
this.instanceId = crypto.randomUUID().slice(0, 8)
|
||||
this.taskNumber = -1
|
||||
|
|
@ -365,6 +339,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.enableCheckpoints = enableCheckpoints
|
||||
this.enableBridge = enableBridge
|
||||
|
||||
this.rootTask = rootTask
|
||||
this.parentTask = parentTask
|
||||
this.taskNumber = taskNumber
|
||||
|
||||
|
|
@ -382,18 +357,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
TelemetryService.instance.captureTaskCreated(this.taskId)
|
||||
}
|
||||
|
||||
// Initialize the assistant message parser.
|
||||
// Initialize the assistant message parser
|
||||
this.assistantMessageParser = new AssistantMessageParser()
|
||||
|
||||
this.messageQueueService = new MessageQueueService()
|
||||
|
||||
this.messageQueueStateChangedHandler = () => {
|
||||
this.emit(RooCodeEventName.TaskUserMessage, this.taskId)
|
||||
this.providerRef.deref()?.postStateToWebview()
|
||||
}
|
||||
|
||||
this.messageQueueService.on("stateChanged", this.messageQueueStateChangedHandler)
|
||||
|
||||
// Only set up diff strategy if diff is enabled.
|
||||
if (this.diffEnabled) {
|
||||
// Default to old strategy, will be updated if experiment is enabled.
|
||||
|
|
@ -667,21 +633,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
})
|
||||
|
||||
const { historyItem, tokenUsage } = await taskMetadata({
|
||||
taskId: this.taskId,
|
||||
rootTaskId: this.rootTaskId,
|
||||
parentTaskId: this.parentTaskId,
|
||||
taskNumber: this.taskNumber,
|
||||
messages: this.clineMessages,
|
||||
taskId: this.taskId,
|
||||
taskNumber: this.taskNumber,
|
||||
globalStoragePath: this.globalStoragePath,
|
||||
workspace: this.cwd,
|
||||
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode.
|
||||
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode
|
||||
})
|
||||
|
||||
if (hasTokenUsageChanged(tokenUsage, this.tokenUsageSnapshot)) {
|
||||
this.emit(RooCodeEventName.TaskTokenUsageUpdated, this.taskId, tokenUsage)
|
||||
this.tokenUsageSnapshot = undefined
|
||||
this.tokenUsageSnapshotAt = undefined
|
||||
}
|
||||
this.emit(RooCodeEventName.TaskTokenUsageUpdated, this.taskId, tokenUsage)
|
||||
|
||||
await this.providerRef.deref()?.updateTaskHistory(historyItem)
|
||||
} catch (error) {
|
||||
|
|
@ -800,13 +760,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
// The state is mutable if the message is complete and the task will
|
||||
// block (via the `pWaitFor`).
|
||||
const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs)
|
||||
const isMessageQueued = !this.messageQueueService.isEmpty()
|
||||
const isStatusMutable = !partial && isBlocking && !isMessageQueued
|
||||
const isStatusMutable = !partial && isBlocking
|
||||
let statusMutationTimeouts: NodeJS.Timeout[] = []
|
||||
|
||||
if (isStatusMutable) {
|
||||
console.log(`Task#ask will block -> type: ${type}`)
|
||||
|
||||
if (isInteractiveAsk(type)) {
|
||||
statusMutationTimeouts.push(
|
||||
setTimeout(() => {
|
||||
|
|
@ -841,19 +798,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}, 1_000),
|
||||
)
|
||||
}
|
||||
} else if (isMessageQueued) {
|
||||
console.log("Task#ask will process message queue")
|
||||
|
||||
const message = this.messageQueueService.dequeueMessage()
|
||||
|
||||
if (message) {
|
||||
setTimeout(async () => {
|
||||
await this.submitUserMessage(message.text, message.images)
|
||||
}, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for askResponse to be set.
|
||||
// Wait for askResponse to be set
|
||||
await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 })
|
||||
|
||||
if (this.lastMessageTs !== askTs) {
|
||||
|
|
@ -891,31 +838,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.askResponse = askResponse
|
||||
this.askResponseText = text
|
||||
this.askResponseImages = images
|
||||
|
||||
// Create a checkpoint whenever the user sends a message.
|
||||
// Use allowEmpty=true to ensure a checkpoint is recorded even if there are no file changes.
|
||||
// Suppress the checkpoint_saved chat row for this particular checkpoint to keep the timeline clean.
|
||||
if (askResponse === "messageResponse") {
|
||||
void this.checkpointSave(false, true)
|
||||
}
|
||||
|
||||
// Mark the last follow-up question as answered
|
||||
if (askResponse === "messageResponse" || askResponse === "yesButtonClicked") {
|
||||
// Find the last unanswered follow-up message using findLastIndex
|
||||
const lastFollowUpIndex = findLastIndex(
|
||||
this.clineMessages,
|
||||
(msg) => msg.type === "ask" && msg.ask === "followup" && !msg.isAnswered,
|
||||
)
|
||||
|
||||
if (lastFollowUpIndex !== -1) {
|
||||
// Mark this follow-up as answered
|
||||
this.clineMessages[lastFollowUpIndex].isAnswered = true
|
||||
// Save the updated messages
|
||||
this.saveClineMessages().catch((error) => {
|
||||
console.error("Failed to save answered follow-up state:", error)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public approveAsk({ text, images }: { text?: string; images?: string[] } = {}) {
|
||||
|
|
@ -926,12 +848,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.handleWebviewAskResponse("noButtonClicked", text, images)
|
||||
}
|
||||
|
||||
public async submitUserMessage(
|
||||
text: string,
|
||||
images?: string[],
|
||||
mode?: string,
|
||||
providerProfile?: string,
|
||||
): Promise<void> {
|
||||
public submitUserMessage(text: string, images?: string[]): void {
|
||||
try {
|
||||
text = (text ?? "").trim()
|
||||
images = images ?? []
|
||||
|
|
@ -943,16 +860,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
const provider = this.providerRef.deref()
|
||||
|
||||
if (provider) {
|
||||
if (mode) {
|
||||
await provider.setMode(mode)
|
||||
}
|
||||
|
||||
if (providerProfile) {
|
||||
await provider.setProviderProfile(providerProfile)
|
||||
}
|
||||
|
||||
this.emit(RooCodeEventName.TaskUserMessage, this.taskId)
|
||||
|
||||
provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images })
|
||||
} else {
|
||||
console.error("[Task#submitUserMessage] Provider reference lost")
|
||||
|
|
@ -997,7 +904,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
|
||||
const { contextTokens: prevContextTokens } = this.getTokenUsage()
|
||||
|
||||
const {
|
||||
messages,
|
||||
summary,
|
||||
|
|
@ -1175,16 +1081,19 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
return formatResponse.toolError(formatResponse.missingToolParameterError(paramName))
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
// Start / Resume / Abort / Dispose
|
||||
// Start / Abort / Resume
|
||||
|
||||
private async startTask(task?: string, images?: string[]): Promise<void> {
|
||||
if (this.enableBridge) {
|
||||
try {
|
||||
await BridgeOrchestrator.subscribeToTask(this)
|
||||
this.bridge = this.bridge || BridgeOrchestrator.getInstance()
|
||||
|
||||
if (this.bridge) {
|
||||
await this.bridge.subscribeToTask(this)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Task#startTask] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`[Task#startTask] subscribeToTask failed - ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1219,34 +1128,63 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
])
|
||||
}
|
||||
|
||||
public async resumePausedTask(lastMessage: string) {
|
||||
this.isPaused = false
|
||||
this.emit(RooCodeEventName.TaskUnpaused)
|
||||
|
||||
// Fake an answer from the subtask that it has completed running and
|
||||
// this is the result of what it has done add the message to the chat
|
||||
// history and to the webview ui.
|
||||
try {
|
||||
await this.say("subtask_result", lastMessage)
|
||||
|
||||
await this.addToApiConversationHistory({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `[new_task completed] Result: ${lastMessage}` }],
|
||||
})
|
||||
|
||||
// Set skipPrevResponseIdOnce to ensure the next API call sends the full conversation
|
||||
// including the subtask result, not just from before the subtask was created
|
||||
this.skipPrevResponseIdOnce = true
|
||||
} catch (error) {
|
||||
this.providerRef
|
||||
.deref()
|
||||
?.log(`Error failed to add reply from subtask into conversation of parent task, error: ${error}`)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async resumeTaskFromHistory() {
|
||||
if (this.enableBridge) {
|
||||
try {
|
||||
await BridgeOrchestrator.subscribeToTask(this)
|
||||
this.bridge = this.bridge || BridgeOrchestrator.getInstance()
|
||||
|
||||
if (this.bridge) {
|
||||
await this.bridge.subscribeToTask(this)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Task#resumeTaskFromHistory] BridgeOrchestrator.subscribeToTask() failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
`[Task#resumeTaskFromHistory] subscribeToTask failed - ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const modifiedClineMessages = await this.getSavedClineMessages()
|
||||
|
||||
// Check for any stored GPT-5 response IDs in the message history.
|
||||
// Check for any stored GPT-5 response IDs in the message history
|
||||
const gpt5Messages = modifiedClineMessages.filter(
|
||||
(m): m is ClineMessage & ClineMessageWithMetadata =>
|
||||
m.type === "say" &&
|
||||
m.say === "text" &&
|
||||
!!(m as ClineMessageWithMetadata).metadata?.gpt5?.previous_response_id,
|
||||
)
|
||||
|
||||
if (gpt5Messages.length > 0) {
|
||||
const lastGpt5Message = gpt5Messages[gpt5Messages.length - 1]
|
||||
// The lastGpt5Message contains the previous_response_id that can be
|
||||
// used for continuity.
|
||||
// The lastGpt5Message contains the previous_response_id that can be used for continuity
|
||||
}
|
||||
|
||||
// Remove any resume messages that may have been added before.
|
||||
// Remove any resume messages that may have been added before
|
||||
const lastRelevantMessageIndex = findLastIndex(
|
||||
modifiedClineMessages,
|
||||
(m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task"),
|
||||
|
|
@ -1464,8 +1402,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
newUserContent.push(...formatResponse.imageBlocks(responseImages))
|
||||
}
|
||||
|
||||
// Ensure we have at least some content to send to the API.
|
||||
// If newUserContent is empty, add a minimal resumption message.
|
||||
// Ensure we have at least some content to send to the API
|
||||
// If newUserContent is empty, add a minimal resumption message
|
||||
if (newUserContent.length === 0) {
|
||||
newUserContent.push({
|
||||
type: "text",
|
||||
|
|
@ -1475,52 +1413,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
await this.overwriteApiConversationHistory(modifiedApiConversationHistory)
|
||||
|
||||
// Task resuming from history item.
|
||||
// Task resuming from history item
|
||||
|
||||
await this.initiateTaskLoop(newUserContent)
|
||||
}
|
||||
|
||||
public async abortTask(isAbandoned = false) {
|
||||
// Aborting task
|
||||
|
||||
// Will stop any autonomously running promises.
|
||||
if (isAbandoned) {
|
||||
this.abandoned = true
|
||||
}
|
||||
|
||||
this.abort = true
|
||||
this.emit(RooCodeEventName.TaskAborted)
|
||||
|
||||
try {
|
||||
this.dispose() // Call the centralized dispose method
|
||||
} catch (error) {
|
||||
console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error)
|
||||
// Don't rethrow - we want abort to always succeed
|
||||
}
|
||||
// Save the countdown message in the automatic retry or other content.
|
||||
try {
|
||||
// Save the countdown message in the automatic retry or other content.
|
||||
await this.saveClineMessages()
|
||||
} catch (error) {
|
||||
console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
console.log(`[Task#dispose] disposing task ${this.taskId}.${this.instanceId}`)
|
||||
// Disposing task
|
||||
console.log(`[Task] disposing task ${this.taskId}.${this.instanceId}`)
|
||||
|
||||
// Dispose message queue and remove event listeners.
|
||||
try {
|
||||
if (this.messageQueueStateChangedHandler) {
|
||||
this.messageQueueService.removeListener("stateChanged", this.messageQueueStateChangedHandler)
|
||||
this.messageQueueStateChangedHandler = undefined
|
||||
}
|
||||
|
||||
this.messageQueueService.dispose()
|
||||
} catch (error) {
|
||||
console.error("Error disposing message queue:", error)
|
||||
}
|
||||
|
||||
// Remove all event listeners to prevent memory leaks.
|
||||
// Remove all event listeners to prevent memory leaks
|
||||
try {
|
||||
this.removeAllListeners()
|
||||
} catch (error) {
|
||||
|
|
@ -1540,14 +1442,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.pauseInterval = undefined
|
||||
}
|
||||
|
||||
if (this.enableBridge) {
|
||||
BridgeOrchestrator.getInstance()
|
||||
?.unsubscribeFromTask(this.taskId)
|
||||
.catch((error) =>
|
||||
console.error(
|
||||
`[Task#dispose] BridgeOrchestrator#unsubscribeFromTask() failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
),
|
||||
)
|
||||
// Unsubscribe from TaskBridge service.
|
||||
if (this.bridge) {
|
||||
this.bridge
|
||||
.unsubscribeFromTask(this.taskId)
|
||||
.catch((error: unknown) => console.error("Error unsubscribing from task bridge:", error))
|
||||
|
||||
this.bridge = null
|
||||
}
|
||||
|
||||
// Release any terminals associated with this task.
|
||||
|
|
@ -1596,36 +1497,37 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
}
|
||||
}
|
||||
|
||||
// Subtasks
|
||||
// Spawn / Wait / Complete
|
||||
public async abortTask(isAbandoned = false) {
|
||||
// Aborting task
|
||||
|
||||
public async startSubtask(message: string, initialTodos: TodoItem[], mode: string) {
|
||||
const provider = this.providerRef.deref()
|
||||
|
||||
if (!provider) {
|
||||
throw new Error("Provider not available")
|
||||
// Will stop any autonomously running promises.
|
||||
if (isAbandoned) {
|
||||
this.abandoned = true
|
||||
}
|
||||
|
||||
const newTask = await provider.createTask(message, undefined, this, { initialTodos })
|
||||
this.abort = true
|
||||
this.emit(RooCodeEventName.TaskAborted)
|
||||
|
||||
if (newTask) {
|
||||
this.isPaused = true // Pause parent.
|
||||
this.childTaskId = newTask.taskId
|
||||
|
||||
await provider.handleModeSwitch(mode) // Set child's mode.
|
||||
await delay(500) // Allow mode change to take effect.
|
||||
|
||||
this.emit(RooCodeEventName.TaskPaused, this.taskId)
|
||||
this.emit(RooCodeEventName.TaskSpawned, newTask.taskId)
|
||||
try {
|
||||
this.dispose() // Call the centralized dispose method
|
||||
} catch (error) {
|
||||
console.error(`Error during task ${this.taskId}.${this.instanceId} disposal:`, error)
|
||||
// Don't rethrow - we want abort to always succeed
|
||||
}
|
||||
// Save the countdown message in the automatic retry or other content.
|
||||
try {
|
||||
// Save the countdown message in the automatic retry or other content.
|
||||
await this.saveClineMessages()
|
||||
} catch (error) {
|
||||
console.error(`Error saving messages during abort for task ${this.taskId}.${this.instanceId}:`, error)
|
||||
}
|
||||
|
||||
return newTask
|
||||
}
|
||||
|
||||
// Used when a sub-task is launched and the parent task is waiting for it to
|
||||
// finish.
|
||||
// TBD: Add a timeout to prevent infinite waiting.
|
||||
public async waitForSubtask() {
|
||||
// TBD: The 1s should be added to the settings, also should add a timeout to
|
||||
// prevent infinite waiting.
|
||||
public async waitForResume() {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.pauseInterval = setInterval(() => {
|
||||
if (!this.isPaused) {
|
||||
|
|
@ -1637,35 +1539,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
})
|
||||
}
|
||||
|
||||
public async completeSubtask(lastMessage: string) {
|
||||
this.isPaused = false
|
||||
this.childTaskId = undefined
|
||||
|
||||
this.emit(RooCodeEventName.TaskUnpaused, this.taskId)
|
||||
|
||||
// Fake an answer from the subtask that it has completed running and
|
||||
// this is the result of what it has done add the message to the chat
|
||||
// history and to the webview ui.
|
||||
try {
|
||||
await this.say("subtask_result", lastMessage)
|
||||
|
||||
await this.addToApiConversationHistory({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: `[new_task completed] Result: ${lastMessage}` }],
|
||||
})
|
||||
|
||||
// Set skipPrevResponseIdOnce to ensure the next API call sends the full conversation
|
||||
// including the subtask result, not just from before the subtask was created
|
||||
this.skipPrevResponseIdOnce = true
|
||||
} catch (error) {
|
||||
this.providerRef
|
||||
.deref()
|
||||
?.log(`Error failed to add reply from subtask into conversation of parent task, error: ${error}`)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// Task Loop
|
||||
|
||||
private async initiateTaskLoop(userContent: Anthropic.Messages.ContentBlockParam[]): Promise<void> {
|
||||
|
|
@ -1752,7 +1625,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
if (this.isPaused && provider) {
|
||||
provider.log(`[subtasks] paused ${this.taskId}.${this.instanceId}`)
|
||||
await this.waitForSubtask()
|
||||
await this.waitForResume()
|
||||
provider.log(`[subtasks] resumed ${this.taskId}.${this.instanceId}`)
|
||||
const currentMode = (await provider.getState())?.mode ?? defaultModeSlug
|
||||
|
||||
|
|
@ -1913,7 +1786,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
this.didFinishAbortingStream = true
|
||||
}
|
||||
|
||||
// Reset streaming state for each new API request
|
||||
// Reset streaming state.
|
||||
this.currentStreamingContentIndex = 0
|
||||
this.currentStreamingDidCheckpoint = false
|
||||
this.assistantMessageContent = []
|
||||
|
|
@ -1934,7 +1807,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
const stream = this.attemptApiRequest()
|
||||
let assistantMessage = ""
|
||||
let reasoningMessage = ""
|
||||
let pendingGroundingSources: GroundingSource[] = []
|
||||
this.isStreaming = true
|
||||
|
||||
try {
|
||||
|
|
@ -1961,13 +1833,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
cacheReadTokens += chunk.cacheReadTokens ?? 0
|
||||
totalCost = chunk.totalCost
|
||||
break
|
||||
case "grounding":
|
||||
// Handle grounding sources separately from regular content
|
||||
// to prevent state persistence issues - store them separately
|
||||
if (chunk.sources && chunk.sources.length > 0) {
|
||||
pendingGroundingSources.push(...chunk.sources)
|
||||
}
|
||||
break
|
||||
case "text": {
|
||||
assistantMessage += chunk.text
|
||||
|
||||
|
|
@ -2261,16 +2126,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
let didEndLoop = false
|
||||
|
||||
if (assistantMessage.length > 0) {
|
||||
// Display grounding sources to the user if they exist
|
||||
if (pendingGroundingSources.length > 0) {
|
||||
const citationLinks = pendingGroundingSources.map((source, i) => `[${i + 1}](${source.url})`)
|
||||
const sourcesText = `${t("common:gemini.sources")} ${citationLinks.join(", ")}`
|
||||
|
||||
await this.say("text", sourcesText, undefined, false, undefined, undefined, {
|
||||
isNonInteractive: true,
|
||||
})
|
||||
}
|
||||
|
||||
await this.addToApiConversationHistory({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: assistantMessage }],
|
||||
|
|
@ -2441,13 +2296,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
const { contextTokens } = this.getTokenUsage()
|
||||
const modelInfo = this.api.getModel().info
|
||||
|
||||
const maxTokens = getModelMaxOutputTokens({
|
||||
modelId: this.api.getModel().id,
|
||||
model: modelInfo,
|
||||
settings: this.apiConfiguration,
|
||||
})
|
||||
|
||||
const contextWindow = modelInfo.contextWindow
|
||||
|
||||
// Get the current profile ID using the helper method
|
||||
|
|
@ -2790,8 +2643,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
// Checkpoints
|
||||
|
||||
public async checkpointSave(force: boolean = false, suppressMessage: boolean = false) {
|
||||
return checkpointSave(this, force, undefined, suppressMessage)
|
||||
public async checkpointSave(force: boolean = false) {
|
||||
return checkpointSave(this, force)
|
||||
}
|
||||
|
||||
public async checkpointRestore(options: CheckpointRestoreOptions) {
|
||||
|
|
@ -2869,6 +2722,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
|
||||
// Getters
|
||||
|
||||
public get cwd() {
|
||||
return this.workspacePath
|
||||
}
|
||||
|
||||
public get taskStatus(): TaskStatus {
|
||||
if (this.interactiveAsk) {
|
||||
return TaskStatus.Interactive
|
||||
|
|
@ -2888,23 +2745,4 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
|
|||
public get taskAsk(): ClineMessage | undefined {
|
||||
return this.idleAsk || this.resumableAsk || this.interactiveAsk
|
||||
}
|
||||
|
||||
public get queuedMessages(): QueuedMessage[] {
|
||||
return this.messageQueueService.messages
|
||||
}
|
||||
|
||||
public get tokenUsage(): TokenUsage | undefined {
|
||||
if (this.tokenUsageSnapshot && this.tokenUsageSnapshotAt) {
|
||||
return this.tokenUsageSnapshot
|
||||
}
|
||||
|
||||
this.tokenUsageSnapshot = this.getTokenUsage()
|
||||
this.tokenUsageSnapshotAt = this.clineMessages.at(-1)?.ts
|
||||
|
||||
return this.tokenUsageSnapshot
|
||||
}
|
||||
|
||||
public get cwd() {
|
||||
return this.workspacePath
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ import {
|
|||
ORGANIZATION_ALLOW_ALL,
|
||||
} from "@roo-code/types"
|
||||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
import { CloudService, getRooCodeApiUrl } from "@roo-code/cloud"
|
||||
import { CloudService, BridgeOrchestrator, getRooCodeApiUrl } from "@roo-code/cloud"
|
||||
|
||||
import { Package } from "../../shared/package"
|
||||
import { findLast } from "../../shared/array"
|
||||
|
|
@ -70,7 +70,6 @@ import { fileExistsAtPath } from "../../utils/fs"
|
|||
import { setTtsEnabled, setTtsSpeed } from "../../utils/tts"
|
||||
import { getWorkspaceGitInfo } from "../../utils/git"
|
||||
import { getWorkspacePath } from "../../utils/path"
|
||||
import { isRemoteControlEnabled } from "../../utils/remoteControl"
|
||||
|
||||
import { setPanel } from "../../activate/registerCommands"
|
||||
|
||||
|
|
@ -136,7 +135,6 @@ export class ClineProvider
|
|||
) {
|
||||
super()
|
||||
|
||||
this.log("ClineProvider instantiated")
|
||||
ClineProvider.activeInstances.add(this)
|
||||
|
||||
this.mdmService = mdmService
|
||||
|
|
@ -302,11 +300,11 @@ export class ClineProvider
|
|||
|
||||
// Adds a new Task instance to clineStack, marking the start of a new task.
|
||||
// The instance is pushed to the top of the stack (LIFO order).
|
||||
// When the task is completed, the top instance is removed, reactivating the previous task.
|
||||
// When the task is completed, the top instance is removed, reactivating the
|
||||
// previous task.
|
||||
async addClineToStack(task: Task) {
|
||||
console.log(`[subtasks] adding task ${task.taskId}.${task.instanceId} to stack`)
|
||||
|
||||
// Add this cline instance into the stack that represents the order of all the called tasks.
|
||||
// Add this cline instance into the stack that represents the order of
|
||||
// all the called tasks.
|
||||
this.clineStack.push(task)
|
||||
task.emit(RooCodeEventName.TaskFocused)
|
||||
|
||||
|
|
@ -350,15 +348,13 @@ export class ClineProvider
|
|||
let task = this.clineStack.pop()
|
||||
|
||||
if (task) {
|
||||
console.log(`[subtasks] removing task ${task.taskId}.${task.instanceId} from stack`)
|
||||
|
||||
try {
|
||||
// Abort the running task and set isAbandoned to true so
|
||||
// all running promises will exit as well.
|
||||
await task.abortTask(true)
|
||||
} catch (e) {
|
||||
this.log(
|
||||
`[subtasks] encountered error while aborting task ${task.taskId}.${task.instanceId}: ${e.message}`,
|
||||
`[removeClineFromStack] encountered error while aborting task ${task.taskId}.${task.instanceId}: ${e.message}`,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -384,6 +380,7 @@ export class ClineProvider
|
|||
if (this.clineStack.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return this.clineStack[this.clineStack.length - 1]
|
||||
}
|
||||
|
||||
|
|
@ -396,19 +393,22 @@ export class ClineProvider
|
|||
return this.clineStack.map((cline) => cline.taskId)
|
||||
}
|
||||
|
||||
// remove the current task/cline instance (at the top of the stack), so this task is finished
|
||||
// and resume the previous task/cline instance (if it exists)
|
||||
// this is used when a sub task is finished and the parent task needs to be resumed
|
||||
// Remove the current task/cline instance (at the top of the stack), so this
|
||||
// task is finished and resume the previous task/cline instance (if it
|
||||
// exists).
|
||||
// This is used when a subtask is finished and the parent task needs to be
|
||||
// resumed.
|
||||
async finishSubTask(lastMessage: string) {
|
||||
console.log(`[subtasks] finishing subtask ${lastMessage}`)
|
||||
// remove the last cline instance from the stack (this is the finished sub task)
|
||||
// Remove the last cline instance from the stack (this is the finished
|
||||
// subtask).
|
||||
await this.removeClineFromStack()
|
||||
// resume the last cline instance in the stack (if it exists - this is the 'parent' calling task)
|
||||
// Resume the last cline instance in the stack (if it exists - this is
|
||||
// the 'parent' calling task).
|
||||
await this.getCurrentTask()?.resumePausedTask(lastMessage)
|
||||
}
|
||||
|
||||
// Clear the current task without treating it as a subtask
|
||||
// This is used when the user cancels a task that is not a subtask
|
||||
// Clear the current task without treating it as a subtask.
|
||||
// This is used when the user cancels a task that is not a subtask.
|
||||
async clearTask() {
|
||||
await this.removeClineFromStack()
|
||||
}
|
||||
|
|
@ -625,8 +625,6 @@ export class ClineProvider
|
|||
}
|
||||
|
||||
async resolveWebviewView(webviewView: vscode.WebviewView | vscode.WebviewPanel) {
|
||||
this.log("Resolving webview view")
|
||||
|
||||
this.view = webviewView
|
||||
const inTabMode = "onDidChangeViewState" in webviewView
|
||||
|
||||
|
|
@ -745,8 +743,6 @@ export class ClineProvider
|
|||
|
||||
// If the extension is starting a new session, clear previous task state.
|
||||
await this.removeClineFromStack()
|
||||
|
||||
this.log("Webview view resolved")
|
||||
}
|
||||
|
||||
// When initializing a new task, (not from history but from a tool command
|
||||
|
|
@ -800,7 +796,7 @@ export class ClineProvider
|
|||
parentTask,
|
||||
taskNumber: this.clineStack.length + 1,
|
||||
onCreated: this.taskCreationCallback,
|
||||
enableTaskBridge: isRemoteControlEnabled(cloudUserInfo, remoteControlEnabled),
|
||||
enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled),
|
||||
initialTodos: options.initialTodos,
|
||||
...options,
|
||||
})
|
||||
|
|
@ -808,7 +804,7 @@ export class ClineProvider
|
|||
await this.addClineToStack(task)
|
||||
|
||||
this.log(
|
||||
`[subtasks] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
|
||||
`[createTask] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
|
||||
)
|
||||
|
||||
return task
|
||||
|
|
@ -872,9 +868,6 @@ export class ClineProvider
|
|||
remoteControlEnabled,
|
||||
} = await this.getState()
|
||||
|
||||
// Determine if TaskBridge should be enabled
|
||||
const enableTaskBridge = isRemoteControlEnabled(cloudUserInfo, remoteControlEnabled)
|
||||
|
||||
const task = new Task({
|
||||
provider: this,
|
||||
apiConfiguration,
|
||||
|
|
@ -888,13 +881,13 @@ export class ClineProvider
|
|||
parentTask: historyItem.parentTask,
|
||||
taskNumber: historyItem.number,
|
||||
onCreated: this.taskCreationCallback,
|
||||
enableTaskBridge,
|
||||
enableBridge: BridgeOrchestrator.isEnabled(cloudUserInfo, remoteControlEnabled),
|
||||
})
|
||||
|
||||
await this.addClineToStack(task)
|
||||
|
||||
this.log(
|
||||
`[subtasks] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
|
||||
`[createTaskWithHistoryItem] ${task.parentTask ? "child" : "parent"} task ${task.taskId}.${task.instanceId} instantiated`,
|
||||
)
|
||||
|
||||
// Restore preserved FCO state if provided (from task abort/cancel)
|
||||
|
|
@ -1324,7 +1317,7 @@ export class ClineProvider
|
|||
return
|
||||
}
|
||||
|
||||
console.log(`[subtasks] cancelling task ${cline.taskId}.${cline.instanceId}`)
|
||||
console.log(`[cancelTask] cancelling task ${cline.taskId}.${cline.instanceId}`)
|
||||
|
||||
const { historyItem } = await this.getTaskWithId(cline.taskId)
|
||||
// Preserve parent and root task information for history item.
|
||||
|
|
@ -2258,56 +2251,50 @@ export class ClineProvider
|
|||
return true
|
||||
}
|
||||
|
||||
public async handleRemoteControlToggle(enabled: boolean) {
|
||||
const { CloudService: CloudServiceImport, ExtensionBridgeService } = await import("@roo-code/cloud")
|
||||
public async remoteControlEnabled(enabled: boolean) {
|
||||
const userInfo = CloudService.instance.getUserInfo()
|
||||
|
||||
const userInfo = CloudServiceImport.instance.getUserInfo()
|
||||
const config = await CloudService.instance.cloudAPI?.bridgeConfig().catch(() => undefined)
|
||||
|
||||
const bridgeConfig = await CloudServiceImport.instance.cloudAPI?.bridgeConfig().catch(() => undefined)
|
||||
|
||||
if (!bridgeConfig) {
|
||||
this.log("[ClineProvider#handleRemoteControlToggle] Failed to get bridge config")
|
||||
if (!config) {
|
||||
this.log("[ClineProvider#remoteControlEnabled] Failed to get bridge config")
|
||||
return
|
||||
}
|
||||
|
||||
await ExtensionBridgeService.handleRemoteControlState(
|
||||
userInfo,
|
||||
enabled,
|
||||
{ ...bridgeConfig, provider: this, sessionId: vscode.env.sessionId },
|
||||
(message: string) => this.log(message),
|
||||
)
|
||||
await BridgeOrchestrator.connectOrDisconnect(userInfo, enabled, {
|
||||
...config,
|
||||
provider: this,
|
||||
sessionId: vscode.env.sessionId,
|
||||
})
|
||||
|
||||
if (isRemoteControlEnabled(userInfo, enabled)) {
|
||||
const bridge = BridgeOrchestrator.getInstance()
|
||||
|
||||
if (bridge) {
|
||||
const currentTask = this.getCurrentTask()
|
||||
|
||||
if (currentTask && !currentTask.bridgeService) {
|
||||
if (currentTask && !currentTask.bridge) {
|
||||
try {
|
||||
currentTask.bridgeService = ExtensionBridgeService.getInstance()
|
||||
|
||||
if (currentTask.bridgeService) {
|
||||
await currentTask.bridgeService.subscribeToTask(currentTask)
|
||||
}
|
||||
currentTask.bridge = bridge
|
||||
await currentTask.bridge.subscribeToTask(currentTask)
|
||||
} catch (error) {
|
||||
const message = `[ClineProvider#handleRemoteControlToggle] subscribeToTask failed - ${error instanceof Error ? error.message : String(error)}`
|
||||
const message = `[ClineProvider#remoteControlEnabled] subscribeToTask failed - ${error instanceof Error ? error.message : String(error)}`
|
||||
this.log(message)
|
||||
console.error(message)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const task of this.clineStack) {
|
||||
if (task.bridgeService) {
|
||||
if (task.bridge) {
|
||||
try {
|
||||
await task.bridgeService.unsubscribeFromTask(task.taskId)
|
||||
task.bridgeService = null
|
||||
await task.bridge.unsubscribeFromTask(task.taskId)
|
||||
task.bridge = null
|
||||
} catch (error) {
|
||||
const message = `[ClineProvider#handleRemoteControlToggle] unsubscribeFromTask failed - ${error instanceof Error ? error.message : String(error)}`
|
||||
const message = `[ClineProvider#remoteControlEnabled] unsubscribeFromTask failed - ${error instanceof Error ? error.message : String(error)}`
|
||||
this.log(message)
|
||||
console.error(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ExtensionBridgeService.resetInstance()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,21 +16,14 @@ import { CloudService } from "@roo-code/cloud"
|
|||
import { TelemetryService } from "@roo-code/telemetry"
|
||||
|
||||
import { type ApiMessage } from "../task-persistence/apiMessages"
|
||||
import { saveTaskMessages } from "../task-persistence"
|
||||
|
||||
import { ClineProvider } from "./ClineProvider"
|
||||
import { handleCheckpointRestoreOperation } from "./checkpointRestoreHandler"
|
||||
import { changeLanguage, t } from "../../i18n"
|
||||
import { Package } from "../../shared/package"
|
||||
import { RouterName, toRouterName, ModelRecord } from "../../shared/api"
|
||||
import { MessageEnhancer } from "./messageEnhancer"
|
||||
|
||||
import {
|
||||
type WebviewMessage,
|
||||
type EditQueuedMessagePayload,
|
||||
checkoutDiffPayloadSchema,
|
||||
checkoutRestorePayloadSchema,
|
||||
} from "../../shared/WebviewMessage"
|
||||
import { checkoutDiffPayloadSchema, checkoutRestorePayloadSchema, WebviewMessage } from "../../shared/WebviewMessage"
|
||||
import { checkExistKey } from "../../shared/checkExistApiConfig"
|
||||
import { experimentDefault } from "../../shared/experiments"
|
||||
import { Terminal } from "../../integrations/terminal/Terminal"
|
||||
|
|
@ -69,17 +62,14 @@ export const webviewMessageHandler = async (
|
|||
const updateGlobalState = async <K extends keyof GlobalState>(key: K, value: GlobalState[K]) =>
|
||||
await provider.contextProxy.setValue(key, value)
|
||||
|
||||
const getCurrentCwd = () => {
|
||||
return provider.getCurrentTask()?.cwd || provider.cwd
|
||||
}
|
||||
/**
|
||||
* Shared utility to find message indices based on timestamp
|
||||
*/
|
||||
const findMessageIndices = (messageTs: number, currentCline: any) => {
|
||||
// Find the exact message by timestamp, not the first one after a cutoff
|
||||
const messageIndex = currentCline.clineMessages.findIndex((msg: ClineMessage) => msg.ts === messageTs)
|
||||
const timeCutoff = messageTs - 1000 // 1 second buffer before the message
|
||||
const messageIndex = currentCline.clineMessages.findIndex((msg: ClineMessage) => msg.ts && msg.ts >= timeCutoff)
|
||||
const apiConversationHistoryIndex = currentCline.apiConversationHistory.findIndex(
|
||||
(msg: ApiMessage) => msg.ts === messageTs,
|
||||
(msg: ApiMessage) => msg.ts && msg.ts >= timeCutoff,
|
||||
)
|
||||
return { messageIndex, apiConversationHistoryIndex }
|
||||
}
|
||||
|
|
@ -106,110 +96,38 @@ export const webviewMessageHandler = async (
|
|||
* Handles message deletion operations with user confirmation
|
||||
*/
|
||||
const handleDeleteOperation = async (messageTs: number): Promise<void> => {
|
||||
// Check if there's a checkpoint before this message
|
||||
const currentCline = provider.getCurrentTask()
|
||||
let hasCheckpoint = false
|
||||
if (currentCline) {
|
||||
const { messageIndex } = findMessageIndices(messageTs, currentCline)
|
||||
if (messageIndex !== -1) {
|
||||
// Find the last checkpoint before this message
|
||||
const checkpoints = currentCline.clineMessages.filter(
|
||||
(msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs,
|
||||
)
|
||||
|
||||
hasCheckpoint = checkpoints.length > 0
|
||||
} else {
|
||||
console.log("[webviewMessageHandler] Message not found! Looking for ts:", messageTs)
|
||||
}
|
||||
}
|
||||
|
||||
// Send message to webview to show delete confirmation dialog
|
||||
await provider.postMessageToWebview({
|
||||
type: "showDeleteMessageDialog",
|
||||
messageTs,
|
||||
hasCheckpoint,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles confirmed message deletion from webview dialog
|
||||
*/
|
||||
const handleDeleteMessageConfirm = async (messageTs: number, restoreCheckpoint?: boolean): Promise<void> => {
|
||||
const currentCline = provider.getCurrentTask()
|
||||
if (!currentCline) {
|
||||
console.error("[handleDeleteMessageConfirm] No current cline available")
|
||||
return
|
||||
}
|
||||
const handleDeleteMessageConfirm = async (messageTs: number): Promise<void> => {
|
||||
// Only proceed if we have a current task.
|
||||
if (provider.getCurrentTask()) {
|
||||
const currentCline = provider.getCurrentTask()!
|
||||
const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline)
|
||||
|
||||
const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline)
|
||||
if (messageIndex !== -1) {
|
||||
try {
|
||||
const { historyItem } = await provider.getTaskWithId(currentCline.taskId)
|
||||
|
||||
if (messageIndex === -1) {
|
||||
const errorMessage = `Message with timestamp ${messageTs} not found`
|
||||
console.error("[handleDeleteMessageConfirm]", errorMessage)
|
||||
await vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
// Delete this message and all subsequent messages
|
||||
await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex)
|
||||
|
||||
try {
|
||||
const targetMessage = currentCline.clineMessages[messageIndex]
|
||||
|
||||
// If checkpoint restoration is requested, find and restore to the last checkpoint before this message
|
||||
if (restoreCheckpoint) {
|
||||
// Find the last checkpoint before this message
|
||||
const checkpoints = currentCline.clineMessages.filter(
|
||||
(msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs,
|
||||
)
|
||||
|
||||
const nextCheckpoint = checkpoints[0]
|
||||
|
||||
if (nextCheckpoint && nextCheckpoint.text) {
|
||||
await handleCheckpointRestoreOperation({
|
||||
provider,
|
||||
currentCline,
|
||||
messageTs: targetMessage.ts!,
|
||||
messageIndex,
|
||||
checkpoint: { hash: nextCheckpoint.text },
|
||||
operation: "delete",
|
||||
})
|
||||
} else {
|
||||
// No checkpoint found before this message
|
||||
console.log("[handleDeleteMessageConfirm] No checkpoint found before message")
|
||||
vscode.window.showWarningMessage("No checkpoint found before this message")
|
||||
// Initialize with history item after deletion
|
||||
await provider.createTaskWithHistoryItem(historyItem)
|
||||
} catch (error) {
|
||||
console.error("Error in delete message:", error)
|
||||
vscode.window.showErrorMessage(
|
||||
`Error deleting message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// For non-checkpoint deletes, preserve checkpoint associations for remaining messages
|
||||
// Store checkpoints from messages that will be preserved
|
||||
const preservedCheckpoints = new Map<number, any>()
|
||||
for (let i = 0; i < messageIndex; i++) {
|
||||
const msg = currentCline.clineMessages[i]
|
||||
if (msg?.checkpoint && msg.ts) {
|
||||
preservedCheckpoints.set(msg.ts, msg.checkpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete this message and all subsequent messages
|
||||
await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex)
|
||||
|
||||
// Restore checkpoint associations for preserved messages
|
||||
for (const [ts, checkpoint] of preservedCheckpoints) {
|
||||
const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts)
|
||||
if (msgIndex !== -1) {
|
||||
currentCline.clineMessages[msgIndex].checkpoint = checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
// Save the updated messages with restored checkpoints
|
||||
await saveTaskMessages({
|
||||
messages: currentCline.clineMessages,
|
||||
taskId: currentCline.taskId,
|
||||
globalStoragePath: provider.contextProxy.globalStorageUri.fsPath,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in delete message:", error)
|
||||
vscode.window.showErrorMessage(
|
||||
`Error deleting message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -217,31 +135,11 @@ export const webviewMessageHandler = async (
|
|||
* Handles message editing operations with user confirmation
|
||||
*/
|
||||
const handleEditOperation = async (messageTs: number, editedContent: string, images?: string[]): Promise<void> => {
|
||||
// Check if there's a checkpoint before this message
|
||||
const currentCline = provider.getCurrentTask()
|
||||
let hasCheckpoint = false
|
||||
if (currentCline) {
|
||||
const { messageIndex } = findMessageIndices(messageTs, currentCline)
|
||||
if (messageIndex !== -1) {
|
||||
// Find the last checkpoint before this message
|
||||
const checkpoints = currentCline.clineMessages.filter(
|
||||
(msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs,
|
||||
)
|
||||
|
||||
hasCheckpoint = checkpoints.length > 0
|
||||
} else {
|
||||
console.log("[webviewMessageHandler] Edit - Message not found in clineMessages!")
|
||||
}
|
||||
} else {
|
||||
console.log("[webviewMessageHandler] Edit - No currentCline available!")
|
||||
}
|
||||
|
||||
// Send message to webview to show edit confirmation dialog
|
||||
await provider.postMessageToWebview({
|
||||
type: "showEditMessageDialog",
|
||||
messageTs,
|
||||
text: editedContent,
|
||||
hasCheckpoint,
|
||||
images,
|
||||
})
|
||||
}
|
||||
|
|
@ -252,105 +150,38 @@ export const webviewMessageHandler = async (
|
|||
const handleEditMessageConfirm = async (
|
||||
messageTs: number,
|
||||
editedContent: string,
|
||||
restoreCheckpoint?: boolean,
|
||||
images?: string[],
|
||||
): Promise<void> => {
|
||||
const currentCline = provider.getCurrentTask()
|
||||
if (!currentCline) {
|
||||
console.error("[handleEditMessageConfirm] No current cline available")
|
||||
return
|
||||
}
|
||||
// Only proceed if we have a current task.
|
||||
if (provider.getCurrentTask()) {
|
||||
const currentCline = provider.getCurrentTask()!
|
||||
|
||||
// Use findMessageIndices to find messages based on timestamp
|
||||
const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline)
|
||||
// Use findMessageIndices to find messages based on timestamp
|
||||
const { messageIndex, apiConversationHistoryIndex } = findMessageIndices(messageTs, currentCline)
|
||||
|
||||
if (messageIndex === -1) {
|
||||
const errorMessage = `Message with timestamp ${messageTs} not found`
|
||||
console.error("[handleEditMessageConfirm]", errorMessage)
|
||||
await vscode.window.showErrorMessage(errorMessage)
|
||||
return
|
||||
}
|
||||
if (messageIndex !== -1) {
|
||||
try {
|
||||
// Edit this message and delete subsequent
|
||||
await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex)
|
||||
|
||||
try {
|
||||
const targetMessage = currentCline.clineMessages[messageIndex]
|
||||
|
||||
// If checkpoint restoration is requested, find and restore to the last checkpoint before this message
|
||||
if (restoreCheckpoint) {
|
||||
// Find the last checkpoint before this message
|
||||
const checkpoints = currentCline.clineMessages.filter(
|
||||
(msg) => msg.say === "checkpoint_saved" && msg.ts > messageTs,
|
||||
)
|
||||
|
||||
const nextCheckpoint = checkpoints[0]
|
||||
|
||||
if (nextCheckpoint && nextCheckpoint.text) {
|
||||
await handleCheckpointRestoreOperation({
|
||||
provider,
|
||||
currentCline,
|
||||
messageTs: targetMessage.ts!,
|
||||
messageIndex,
|
||||
checkpoint: { hash: nextCheckpoint.text },
|
||||
operation: "edit",
|
||||
editData: {
|
||||
editedContent,
|
||||
images,
|
||||
apiConversationHistoryIndex,
|
||||
},
|
||||
// Process the edited message as a regular user message
|
||||
// This will add it to the conversation and trigger an AI response
|
||||
webviewMessageHandler(provider, {
|
||||
type: "askResponse",
|
||||
askResponse: "messageResponse",
|
||||
text: editedContent,
|
||||
images,
|
||||
})
|
||||
// The task will be cancelled and reinitialized by checkpointRestore
|
||||
// The pending edit will be processed in the reinitialized task
|
||||
return
|
||||
} else {
|
||||
// No checkpoint found before this message
|
||||
console.log("[handleEditMessageConfirm] No checkpoint found before message")
|
||||
vscode.window.showWarningMessage("No checkpoint found before this message")
|
||||
// Continue with non-checkpoint edit
|
||||
|
||||
// Don't initialize with history item for edit operations
|
||||
// The webviewMessageHandler will handle the conversation state
|
||||
} catch (error) {
|
||||
console.error("Error in edit message:", error)
|
||||
vscode.window.showErrorMessage(
|
||||
`Error editing message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// For non-checkpoint edits, preserve checkpoint associations for remaining messages
|
||||
// Store checkpoints from messages that will be preserved
|
||||
const preservedCheckpoints = new Map<number, any>()
|
||||
for (let i = 0; i < messageIndex; i++) {
|
||||
const msg = currentCline.clineMessages[i]
|
||||
if (msg?.checkpoint && msg.ts) {
|
||||
preservedCheckpoints.set(msg.ts, msg.checkpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// Edit this message and delete subsequent
|
||||
await removeMessagesThisAndSubsequent(currentCline, messageIndex, apiConversationHistoryIndex)
|
||||
|
||||
// Restore checkpoint associations for preserved messages
|
||||
for (const [ts, checkpoint] of preservedCheckpoints) {
|
||||
const msgIndex = currentCline.clineMessages.findIndex((msg) => msg.ts === ts)
|
||||
if (msgIndex !== -1) {
|
||||
currentCline.clineMessages[msgIndex].checkpoint = checkpoint
|
||||
}
|
||||
}
|
||||
|
||||
// Save the updated messages with restored checkpoints
|
||||
await saveTaskMessages({
|
||||
messages: currentCline.clineMessages,
|
||||
taskId: currentCline.taskId,
|
||||
globalStoragePath: provider.contextProxy.globalStorageUri.fsPath,
|
||||
})
|
||||
|
||||
// Process the edited message as a regular user message
|
||||
webviewMessageHandler(provider, {
|
||||
type: "askResponse",
|
||||
askResponse: "messageResponse",
|
||||
text: editedContent,
|
||||
images,
|
||||
})
|
||||
|
||||
// Don't initialize with history item for edit operations
|
||||
// The webviewMessageHandler will handle the conversation state
|
||||
} catch (error) {
|
||||
console.error("Error in edit message:", error)
|
||||
vscode.window.showErrorMessage(
|
||||
`Error editing message: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -714,7 +545,6 @@ export const webviewMessageHandler = async (
|
|||
litellm: {},
|
||||
ollama: {},
|
||||
lmstudio: {},
|
||||
deepinfra: {},
|
||||
}
|
||||
|
||||
const safeGetModels = async (options: GetModelsOptions): Promise<ModelRecord> => {
|
||||
|
|
@ -742,14 +572,6 @@ export const webviewMessageHandler = async (
|
|||
{ key: "glama", options: { provider: "glama" } },
|
||||
{ key: "unbound", options: { provider: "unbound", apiKey: apiConfiguration.unboundApiKey } },
|
||||
{ key: "vercel-ai-gateway", options: { provider: "vercel-ai-gateway" } },
|
||||
{
|
||||
key: "deepinfra",
|
||||
options: {
|
||||
provider: "deepinfra",
|
||||
apiKey: apiConfiguration.deepInfraApiKey,
|
||||
baseUrl: apiConfiguration.deepInfraBaseUrl,
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Add IO Intelligence if API key is provided
|
||||
|
|
@ -915,14 +737,10 @@ export const webviewMessageHandler = async (
|
|||
saveImage(message.dataUri!)
|
||||
break
|
||||
case "openFile":
|
||||
let filePath: string = message.text!
|
||||
if (!path.isAbsolute(filePath)) {
|
||||
filePath = path.join(getCurrentCwd(), filePath)
|
||||
}
|
||||
openFile(filePath, message.values as { create?: boolean; content?: string; line?: number })
|
||||
openFile(message.text!, message.values as { create?: boolean; content?: string; line?: number })
|
||||
break
|
||||
case "openMention":
|
||||
openMention(getCurrentCwd(), message.text)
|
||||
openMention(message.text)
|
||||
break
|
||||
case "openExternal":
|
||||
if (message.url) {
|
||||
|
|
@ -1017,8 +835,8 @@ export const webviewMessageHandler = async (
|
|||
return
|
||||
}
|
||||
|
||||
const workspaceFolder = getCurrentCwd()
|
||||
const rooDir = path.join(workspaceFolder, ".roo")
|
||||
const workspaceFolder = vscode.workspace.workspaceFolders[0]
|
||||
const rooDir = path.join(workspaceFolder.uri.fsPath, ".roo")
|
||||
const mcpPath = path.join(rooDir, "mcp.json")
|
||||
|
||||
try {
|
||||
|
|
@ -1132,22 +950,8 @@ export const webviewMessageHandler = async (
|
|||
await provider.postStateToWebview()
|
||||
break
|
||||
case "remoteControlEnabled":
|
||||
try {
|
||||
await CloudService.instance.updateUserSettings({ extensionBridgeEnabled: message.bool ?? false })
|
||||
} catch (error) {
|
||||
provider.log(
|
||||
`CloudService#updateUserSettings failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
await provider.remoteControlEnabled(message.bool ?? false)
|
||||
} catch (error) {
|
||||
provider.log(
|
||||
`ClineProvider#remoteControlEnabled failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
|
||||
await updateGlobalState("remoteControlEnabled", message.bool ?? false)
|
||||
await provider.remoteControlEnabled(message.bool ?? false)
|
||||
await provider.postStateToWebview()
|
||||
break
|
||||
case "refreshAllMcpServers": {
|
||||
|
|
@ -1504,14 +1308,6 @@ export const webviewMessageHandler = async (
|
|||
await updateGlobalState("language", message.text as Language)
|
||||
await provider.postStateToWebview()
|
||||
break
|
||||
case "openRouterImageApiKey":
|
||||
await provider.contextProxy.setValue("openRouterImageApiKey", message.text)
|
||||
await provider.postStateToWebview()
|
||||
break
|
||||
case "openRouterImageGenerationSelectedModel":
|
||||
await provider.contextProxy.setValue("openRouterImageGenerationSelectedModel", message.text)
|
||||
await provider.postStateToWebview()
|
||||
break
|
||||
case "showRooIgnoredFiles":
|
||||
await updateGlobalState("showRooIgnoredFiles", message.bool ?? false)
|
||||
await provider.postStateToWebview()
|
||||
|
|
@ -1603,7 +1399,7 @@ export const webviewMessageHandler = async (
|
|||
const {
|
||||
apiConfiguration,
|
||||
customSupportPrompts,
|
||||
listApiConfigMeta = [],
|
||||
listApiConfigMeta,
|
||||
enhancementApiConfigId,
|
||||
includeTaskHistoryInEnhance,
|
||||
} = state
|
||||
|
|
@ -1668,7 +1464,7 @@ export const webviewMessageHandler = async (
|
|||
}
|
||||
break
|
||||
case "searchCommits": {
|
||||
const cwd = getCurrentCwd()
|
||||
const cwd = provider.cwd
|
||||
if (cwd) {
|
||||
try {
|
||||
const commits = await searchCommits(message.query || "", cwd)
|
||||
|
|
@ -1686,7 +1482,7 @@ export const webviewMessageHandler = async (
|
|||
break
|
||||
}
|
||||
case "searchFiles": {
|
||||
const workspacePath = getCurrentCwd()
|
||||
const workspacePath = getWorkspacePath()
|
||||
|
||||
if (!workspacePath) {
|
||||
// Handle case where workspace path is not available
|
||||
|
|
@ -1848,17 +1644,12 @@ export const webviewMessageHandler = async (
|
|||
break
|
||||
case "deleteMessageConfirm":
|
||||
if (message.messageTs) {
|
||||
await handleDeleteMessageConfirm(message.messageTs, message.restoreCheckpoint)
|
||||
await handleDeleteMessageConfirm(message.messageTs)
|
||||
}
|
||||
break
|
||||
case "editMessageConfirm":
|
||||
if (message.messageTs && message.text) {
|
||||
await handleEditMessageConfirm(
|
||||
message.messageTs,
|
||||
message.text,
|
||||
message.restoreCheckpoint,
|
||||
message.images,
|
||||
)
|
||||
await handleEditMessageConfirm(message.messageTs, message.text, message.images)
|
||||
}
|
||||
break
|
||||
case "getListApiConfiguration":
|
||||
|
|
@ -2229,9 +2020,9 @@ export const webviewMessageHandler = async (
|
|||
await provider.postStateToWebview()
|
||||
break
|
||||
}
|
||||
case "cloudButtonClicked": {
|
||||
// Navigate to the cloud tab.
|
||||
provider.postMessageToWebview({ type: "action", action: "cloudButtonClicked" })
|
||||
case "accountButtonClicked": {
|
||||
// Navigate to the account tab.
|
||||
provider.postMessageToWebview({ type: "action", action: "accountButtonClicked" })
|
||||
break
|
||||
}
|
||||
case "rooCloudSignIn": {
|
||||
|
|
@ -2675,7 +2466,7 @@ export const webviewMessageHandler = async (
|
|||
case "requestCommands": {
|
||||
try {
|
||||
const { getCommands } = await import("../../services/command/commands")
|
||||
const commands = await getCommands(getCurrentCwd())
|
||||
const commands = await getCommands(provider.cwd || "")
|
||||
|
||||
// Convert to the format expected by the frontend
|
||||
const commandList = commands.map((command) => ({
|
||||
|
|
@ -2704,7 +2495,7 @@ export const webviewMessageHandler = async (
|
|||
try {
|
||||
if (message.text) {
|
||||
const { getCommand } = await import("../../services/command/commands")
|
||||
const command = await getCommand(getCurrentCwd(), message.text)
|
||||
const command = await getCommand(provider.cwd || "", message.text)
|
||||
|
||||
if (command && command.filePath) {
|
||||
openFile(command.filePath)
|
||||
|
|
@ -2724,7 +2515,7 @@ export const webviewMessageHandler = async (
|
|||
try {
|
||||
if (message.text && message.values?.source) {
|
||||
const { getCommand } = await import("../../services/command/commands")
|
||||
const command = await getCommand(getCurrentCwd(), message.text)
|
||||
const command = await getCommand(provider.cwd || "", message.text)
|
||||
|
||||
if (command && command.filePath) {
|
||||
// Delete the command file
|
||||
|
|
@ -2756,12 +2547,8 @@ export const webviewMessageHandler = async (
|
|||
const globalConfigDir = path.join(os.homedir(), ".roo")
|
||||
commandsDir = path.join(globalConfigDir, "commands")
|
||||
} else {
|
||||
if (!vscode.workspace.workspaceFolders?.length) {
|
||||
vscode.window.showErrorMessage(t("common:errors.no_workspace"))
|
||||
return
|
||||
}
|
||||
// Project commands
|
||||
const workspaceRoot = getCurrentCwd()
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
|
||||
if (!workspaceRoot) {
|
||||
vscode.window.showErrorMessage(t("common:errors.no_workspace_for_project_command"))
|
||||
break
|
||||
|
|
@ -2841,7 +2628,7 @@ export const webviewMessageHandler = async (
|
|||
|
||||
// Refresh commands list
|
||||
const { getCommands } = await import("../../services/command/commands")
|
||||
const commands = await getCommands(getCurrentCwd() || "")
|
||||
const commands = await getCommands(provider.cwd || "")
|
||||
const commandList = commands.map((command) => ({
|
||||
name: command.name,
|
||||
source: command.source,
|
||||
|
|
@ -2876,26 +2663,5 @@ export const webviewMessageHandler = async (
|
|||
vscode.window.showWarningMessage(t("common:mdm.info.organization_requires_auth"))
|
||||
break
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat Message Queue
|
||||
*/
|
||||
|
||||
case "queueMessage": {
|
||||
provider.getCurrentTask()?.messageQueueService.addMessage(message.text ?? "", message.images)
|
||||
break
|
||||
}
|
||||
case "removeQueuedMessage": {
|
||||
provider.getCurrentTask()?.messageQueueService.removeMessage(message.text ?? "")
|
||||
break
|
||||
}
|
||||
case "editQueuedMessage": {
|
||||
if (message.payload) {
|
||||
const { id, text, images } = message.payload as EditQueuedMessagePayload
|
||||
provider.getCurrentTask()?.messageQueueService.updateMessage(id, text, images)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ try {
|
|||
}
|
||||
|
||||
import type { CloudUserInfo } from "@roo-code/types"
|
||||
import { CloudService, ExtensionBridgeService } from "@roo-code/cloud"
|
||||
import { CloudService, BridgeOrchestrator } from "@roo-code/cloud"
|
||||
import { TelemetryService, PostHogTelemetryClient } from "@roo-code/telemetry"
|
||||
|
||||
import "./utils/path" // Necessary to have access to String.prototype.toPosix.
|
||||
|
|
@ -30,7 +30,6 @@ import { CodeIndexManager } from "./services/code-index/manager"
|
|||
import { MdmService } from "./services/mdm/MdmService"
|
||||
import { migrateSettings } from "./utils/migrateSettings"
|
||||
import { autoImportSettings } from "./utils/autoImportSettings"
|
||||
import { isRemoteControlEnabled } from "./utils/remoteControl"
|
||||
import { API } from "./extension/api"
|
||||
|
||||
import {
|
||||
|
|
@ -147,15 +146,10 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||
|
||||
cloudLogger(`[CloudService] isCloudAgent = ${isCloudAgent}, socketBridgeUrl = ${config.socketBridgeUrl}`)
|
||||
|
||||
ExtensionBridgeService.handleRemoteControlState(
|
||||
await BridgeOrchestrator.connectOrDisconnect(
|
||||
userInfo,
|
||||
isCloudAgent ? true : contextProxy.getValue("remoteControlEnabled"),
|
||||
{
|
||||
...config,
|
||||
provider,
|
||||
sessionId: vscode.env.sessionId,
|
||||
},
|
||||
cloudLogger,
|
||||
{ ...config, provider, sessionId: vscode.env.sessionId },
|
||||
)
|
||||
} catch (error) {
|
||||
cloudLogger(
|
||||
|
|
@ -333,10 +327,10 @@ export async function deactivate() {
|
|||
}
|
||||
}
|
||||
|
||||
const bridgeService = ExtensionBridgeService.getInstance()
|
||||
const bridge = BridgeOrchestrator.getInstance()
|
||||
|
||||
if (bridgeService) {
|
||||
await bridgeService.disconnect()
|
||||
if (bridge) {
|
||||
await bridge.disconnect()
|
||||
}
|
||||
|
||||
await McpServerManager.cleanup(extensionContext)
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
import type { CloudUserInfo } from "@roo-code/types"
|
||||
|
||||
/**
|
||||
* Determines if remote control features should be enabled
|
||||
* @param cloudUserInfo - User information from cloud service
|
||||
* @param remoteControlEnabled - User's remote control setting
|
||||
* @returns true if remote control should be enabled
|
||||
*/
|
||||
export function isRemoteControlEnabled(cloudUserInfo?: CloudUserInfo | null, remoteControlEnabled?: boolean): boolean {
|
||||
return !!(cloudUserInfo?.id && cloudUserInfo.extensionBridgeEnabled && remoteControlEnabled)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue