From 354f60fdcbaee162e0be119bd23debac98c6f51c Mon Sep 17 00:00:00 2001 From: cte Date: Wed, 30 Jul 2025 17:31:51 -0700 Subject: [PATCH 1/5] Task bridge --- pnpm-lock.yaml | 117 +++++++ src/core/task/Task.ts | 22 ++ src/package.json | 3 + src/services/task-bridge/TaskBridgeService.ts | 305 ++++++++++++++++++ .../__tests__/TaskBridgeService.test.ts | 292 +++++++++++++++++ src/services/task-bridge/index.ts | 6 + 6 files changed, 745 insertions(+) create mode 100644 src/services/task-bridge/TaskBridgeService.ts create mode 100644 src/services/task-bridge/__tests__/TaskBridgeService.test.ts create mode 100644 src/services/task-bridge/index.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 311c51d0ba..7f580d9194 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -665,6 +665,9 @@ importers: ignore: specifier: ^7.0.3 version: 7.0.4 + ioredis: + specifier: ^5.3.2 + version: 5.6.1 isbinaryfile: specifier: ^5.0.2 version: 5.0.4 @@ -801,6 +804,9 @@ importers: '@types/glob': specifier: ^8.1.0 version: 8.1.0 + '@types/ioredis-mock': + specifier: ^8.2.6 + version: 8.2.6(ioredis@5.6.1) '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -849,6 +855,9 @@ importers: glob: specifier: ^11.0.1 version: 11.0.2 + ioredis-mock: + specifier: ^8.9.0 + version: 8.9.0(@types/ioredis-mock@8.2.6(ioredis@5.6.1))(ioredis@5.6.1) mkdirp: specifier: ^3.0.1 version: 3.0.1 @@ -1957,6 +1966,12 @@ packages: cpu: [x64] os: [win32] + '@ioredis/as-callback@3.0.0': + resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} + + '@ioredis/commands@1.3.0': + resolution: {integrity: sha512-M/T6Zewn7sDaBQEqIZ8Rb+i9y8qfGmq+5SDFSf9sA2lUZTmdDLVdOiQaeDp+Q4wElZ9HG1GAX5KhDaidp6LQsQ==} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -3816,6 +3831,11 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/ioredis-mock@8.2.6': + resolution: {integrity: sha512-5heqtZMvQ4nXARY0o8rc8cjkJjct2ScM12yCJ/h731S9He93a2cv+kAhwPCNwTKDfNH9gjRfLG4VpAEYJU0/gQ==} + peerDependencies: + ioredis: '>=5' + '@types/istanbul-lib-coverage@2.0.6': resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} @@ -5102,6 +5122,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -5739,6 +5763,14 @@ packages: picomatch: optional: true + fengari-interop@0.1.3: + resolution: {integrity: sha512-EtZ+oTu3kEwVJnoymFPBVLIbQcCoy9uWCVnMA6h3M/RqHkUBsLYp29+RRHf9rKr6GwjubWREU1O7RretFIXjHw==} + peerDependencies: + fengari: ^0.1.0 + + fengari@0.1.4: + resolution: {integrity: sha512-6ujqUuiIYmcgkGz8MGAdERU57EIluGGPSUgGPTsco657EHa+srq0S3/YUl/r9kx1+D+d4rGfYObd+m8K22gB1g==} + fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -6275,6 +6307,17 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ioredis-mock@8.9.0: + resolution: {integrity: sha512-yIglcCkI1lvhwJVoMsR51fotZVsPsSk07ecTCgRTRlicG0Vq3lke6aAaHklyjmRNRsdYAgswqC2A0bPtQK4LSw==} + engines: {node: '>=12.22'} + peerDependencies: + '@types/ioredis-mock': ^8 + ioredis: ^5 + + ioredis@5.6.1: + resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==} + engines: {node: '>=12.22.0'} + ip-address@9.0.5: resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} engines: {node: '>= 12'} @@ -6943,6 +6986,9 @@ packages: lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + lodash.isboolean@3.0.3: resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} @@ -8248,6 +8294,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readline-sync@1.4.10: + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} + engines: {node: '>= 0.8.0'} + recharts-scale@0.4.5: resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} @@ -8266,6 +8316,14 @@ packages: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + redis@5.5.5: resolution: {integrity: sha512-x7vpciikEY7nptGzQrE5I+/pvwFZJDadPk/uEoyGSg/pZ2m/CX2n5EhSgUh+S5T7Gz3uKM6YzWcXEu3ioAsdFQ==} engines: {node: '>= 18'} @@ -8679,6 +8737,9 @@ packages: stacktrace-js@2.0.2: resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -11061,6 +11122,10 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true + '@ioredis/as-callback@3.0.0': {} + + '@ioredis/commands@1.3.0': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -13075,6 +13140,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/ioredis-mock@8.2.6(ioredis@5.6.1)': + dependencies: + ioredis: 5.6.1 + '@types/istanbul-lib-coverage@2.0.6': {} '@types/istanbul-lib-report@3.0.3': @@ -14537,6 +14606,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + depd@2.0.0: {} dequal@2.0.3: {} @@ -15288,6 +15359,16 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fengari-interop@0.1.3(fengari@0.1.4): + dependencies: + fengari: 0.1.4 + + fengari@0.1.4: + dependencies: + readline-sync: 1.4.10 + sprintf-js: 1.1.3 + tmp: 0.0.33 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -15916,6 +15997,30 @@ snapshots: internmap@2.0.3: {} + ioredis-mock@8.9.0(@types/ioredis-mock@8.2.6(ioredis@5.6.1))(ioredis@5.6.1): + dependencies: + '@ioredis/as-callback': 3.0.0 + '@ioredis/commands': 1.3.0 + '@types/ioredis-mock': 8.2.6(ioredis@5.6.1) + fengari: 0.1.4 + fengari-interop: 0.1.3(fengari@0.1.4) + ioredis: 5.6.1 + semver: 7.7.2 + + ioredis@5.6.1: + dependencies: + '@ioredis/commands': 1.3.0 + cluster-key-slot: 1.1.2 + debug: 4.4.1(supports-color@8.1.1) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ip-address@9.0.5: dependencies: jsbn: 1.1.0 @@ -16584,6 +16689,8 @@ snapshots: lodash.includes@4.3.0: {} + lodash.isarguments@3.1.0: {} + lodash.isboolean@3.0.3: {} lodash.isequal@4.5.0: {} @@ -18228,6 +18335,8 @@ snapshots: readdirp@4.1.2: {} + readline-sync@1.4.10: {} + recharts-scale@0.4.5: dependencies: decimal.js-light: 2.5.1 @@ -18252,6 +18361,12 @@ snapshots: indent-string: 4.0.0 strip-indent: 3.0.0 + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + redis@5.5.5: dependencies: '@redis/bloom': 5.5.5(@redis/client@5.5.5) @@ -18805,6 +18920,8 @@ snapshots: stack-generator: 2.0.10 stacktrace-gps: 3.1.2 + standard-as-callback@2.1.0: {} + statuses@2.0.1: {} std-env@3.9.0: {} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 38c67b5021..0c984ca1b7 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -52,6 +52,7 @@ import { BrowserSession } from "../../services/browser/BrowserSession" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" import { RepoPerTaskCheckpointService } from "../../services/checkpoints" +import { TaskBridgeService } from "../../services/task-bridge/TaskBridgeService" // integrations import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider" @@ -247,6 +248,9 @@ export class Task extends EventEmitter { checkpointService?: RepoPerTaskCheckpointService checkpointServiceInitializing = false + // Task Bridge + taskBridgeService?: TaskBridgeService + // Streaming isWaitingForFirstChunk = false isStreaming = false @@ -351,6 +355,12 @@ export class Task extends EventEmitter { this.toolRepetitionDetector = new ToolRepetitionDetector(this.consecutiveMistakeLimit) + // TODO: Figure out when to enable task bridge. + // eslint-disable-next-line no-constant-condition + if (true) { + this.taskBridgeService = TaskBridgeService.getInstance() + } + onCreated?.(this) if (startTask) { @@ -1210,6 +1220,13 @@ export class Task extends EventEmitter { this.pauseInterval = undefined } + // Unsubscribe from TaskBridge service. + if (this.taskBridgeService) { + this.taskBridgeService + .unsubscribeFromTask(this.taskId) + .catch((error) => console.error("Error unsubscribing from task bridge:", error)) + } + // Release any terminals associated with this task. try { // Release any terminals associated with this task. @@ -1304,6 +1321,11 @@ export class Task extends EventEmitter { // Kicks off the checkpoints initialization process in the background. getCheckpointService(this) + if (this.taskBridgeService) { + await this.taskBridgeService.initialize() + await this.taskBridgeService.subscribeToTask(this) + } + let nextUserContent = userContent let includeFileDetails = true diff --git a/src/package.json b/src/package.json index 8503d2bdc6..407977a675 100644 --- a/src/package.json +++ b/src/package.json @@ -445,6 +445,7 @@ "gray-matter": "^4.0.3", "i18next": "^25.0.0", "ignore": "^7.0.3", + "ioredis": "^5.3.2", "isbinaryfile": "^5.0.2", "lodash.debounce": "^4.0.8", "mammoth": "^1.9.1", @@ -492,6 +493,7 @@ "@types/diff": "^5.2.1", "@types/diff-match-patch": "^1.0.36", "@types/glob": "^8.1.0", + "@types/ioredis-mock": "^8.2.6", "@types/mocha": "^10.0.10", "@types/node": "20.x", "@types/node-cache": "^4.1.3", @@ -508,6 +510,7 @@ "esbuild": "^0.25.0", "execa": "^9.5.2", "glob": "^11.0.1", + "ioredis-mock": "^8.9.0", "mkdirp": "^3.0.1", "nock": "^14.0.4", "npm-run-all2": "^8.0.1", diff --git a/src/services/task-bridge/TaskBridgeService.ts b/src/services/task-bridge/TaskBridgeService.ts new file mode 100644 index 0000000000..58ffab70db --- /dev/null +++ b/src/services/task-bridge/TaskBridgeService.ts @@ -0,0 +1,305 @@ +import Redis from "ioredis" +import { z } from "zod" + +import { type TaskEvents, type TaskEventHandlers, Task } from "../../core/task/Task" + +const NAMESPACE = "bridge" + +export interface TaskBridgeConfig { + url?: string + namespace?: string + reconnectOnError?: boolean + maxReconnectAttempts?: number + reconnectDelay?: number +} + +const taskBridgeMessageTypes = ["message_queued", "task_status", "task_event"] as const + +type TaskBridgeMessageType = (typeof taskBridgeMessageTypes)[number] + +const taskBridgeMessagePayloadSchema = z.object({ + eventType: z.string(), + data: z.record(z.string(), z.unknown()), +}) + +type TaskBridgeMessagePayload = z.infer + +const taskBridgeMessageSchema = z.object({ + taskId: z.string(), + type: z.enum(taskBridgeMessageTypes), + payload: taskBridgeMessagePayloadSchema, + timestamp: z.number(), +}) + +export type TaskBridgeMessage = z.infer + +export interface QueuedMessage { + text: string + images?: string[] + timestamp: number +} + +export class TaskBridgeService { + private static instance: TaskBridgeService | null = null + + private config: TaskBridgeConfig + private publisher: Redis | null = null + private subscriber: Redis | null = null + private isConnected: boolean = false + private reconnectAttempts: number = 0 + private reconnectTimeout: NodeJS.Timeout | null = null + private subscribedTasks: Map = new Map() + private taskEventHandlers: Record> = {} + + private constructor({ + url = "redis://localhost:6379", + namespace = NAMESPACE, + reconnectOnError = true, + maxReconnectAttempts = 10, + reconnectDelay = 5000, + }: TaskBridgeConfig = {}) { + this.config = { url, namespace, reconnectOnError, maxReconnectAttempts, reconnectDelay } + } + + public static getInstance(config?: TaskBridgeConfig) { + if (!TaskBridgeService.instance) { + TaskBridgeService.instance = new TaskBridgeService(config) + } + + return TaskBridgeService.instance + } + + public async initialize() { + if (this.isConnected) { + return + } + + this.publisher = new Redis(this.config.url!, { + retryStrategy: (times: number) => { + if (times > this.config.maxReconnectAttempts!) { + return null + } + + return Math.min(times * 50, 2000) + }, + }) + + this.subscriber = new Redis(this.config.url!, { + retryStrategy: (times: number) => { + if (times > this.config.maxReconnectAttempts!) { + return null + } + + return Math.min(times * 50, 2000) + }, + }) + + this.publisher.on("error", (error: Error) => this.handleConnectionError(error)) + this.publisher.on("close", () => this.handleConnectionError()) + + this.subscriber.on("error", (error: Error) => this.handleConnectionError(error)) + this.subscriber.on("close", () => this.handleConnectionError()) + + this.subscriber.on("message", (channel: string, buffer: string) => { + try { + const message = taskBridgeMessageSchema.parse(JSON.parse(buffer)) + const parts = channel.split(":") + const taskId = parts[parts.length - 2] + const task = this.subscribedTasks.get(taskId) + + if (!task) { + console.warn(`Received message for unsubscribed task: ${taskId}`) + return + } + + switch (message.type) { + case "message_queued": + console.log(`Received message_queued event for task: ${taskId}`, message.payload) + break + case "task_status": + console.log(`Received task_status event for task: ${taskId}`, message.payload) + break + case "task_event": + console.log(`Received task_event event for task: ${taskId}`, message.payload) + break + } + } catch (error) { + console.error("Error handling incoming message:", error) + } + }) + + await Promise.all([this.waitForConnection(this.publisher), this.waitForConnection(this.subscriber)]) + + this.isConnected = true + this.reconnectAttempts = 0 + + console.log(`[TaskBridgeService] connected -> ${this.config.url}`) + } + + public async subscribeToTask(task: Task): Promise { + if (!this.isConnected || !this.subscriber) { + throw new Error("TaskBridgeService is not connected") + } + + this.subscribedTasks.set(task.taskId, task) + await this.subscriber.subscribe(this.serverChannel(task.taskId)) + this.setupTaskEventListeners(task) + } + + public async unsubscribeFromTask(taskId: string): Promise { + if (!this.subscriber) { + return + } + + const task = this.subscribedTasks.get(taskId) + + if (task) { + this.removeTaskEventListeners(task) + this.subscribedTasks.delete(taskId) + } + + await this.subscriber.unsubscribe(this.serverChannel(taskId)) + } + + public async publish(taskId: string, type: TaskBridgeMessageType, payload: TaskBridgeMessagePayload) { + if (!this.isConnected || !this.publisher) { + throw new Error("TaskBridgeService is not connected") + } + + const channel = this.clientChannel(taskId) + + const data: TaskBridgeMessage = { + taskId, + type, + payload, + timestamp: Date.now(), + } + + console.log(`[TaskBridgeService] publishing to ${channel}`, data) + await this.publisher.publish(channel, JSON.stringify(data)) + } + + public async disconnect(): Promise { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + + for (const taskId of this.subscribedTasks.keys()) { + await this.unsubscribeFromTask(taskId) + } + + if (this.publisher) { + await this.publisher.quit() + this.publisher = null + } + + if (this.subscriber) { + await this.subscriber.quit() + this.subscriber = null + } + + this.isConnected = false + TaskBridgeService.instance = null + } + + public get connected(): boolean { + return this.isConnected + } + + public get subscribedTaskCount(): number { + return this.subscribedTasks.size + } + + private waitForConnection(client: Redis): Promise { + return new Promise((resolve, reject) => { + if (client.status === "ready") { + resolve() + return + } + + const onReady = () => { + client.off("ready", onReady) + client.off("error", onError) + resolve() + } + + const onError = (error: Error) => { + client.off("ready", onReady) + client.off("error", onError) + reject(error) + } + + client.once("ready", onReady) + client.once("error", onError) + }) + } + + private handleConnectionError(_error?: Error): void { + this.isConnected = false + + if (!this.config.reconnectOnError) { + return + } + + if (this.reconnectAttempts >= this.config.maxReconnectAttempts!) { + return + } + + if (this.reconnectTimeout) { + return + } + + this.reconnectAttempts++ + + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null + this.initialize() + }, this.config.reconnectDelay) + } + + private setupTaskEventListeners(task: Task) { + const callbacks: Partial = { + message: ({ action, message }) => + this.publish(task.taskId, "task_event", { eventType: "message", data: { action, message } }), + taskStarted: () => + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "started" } }), + taskPaused: () => + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "paused" } }), + taskUnpaused: () => + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "unpaused" } }), + taskAborted: () => + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "aborted" } }), + taskCompleted: () => + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "completed" } }), + } + + this.taskEventHandlers[task.taskId] = callbacks + + for (const [eventName, handler] of Object.entries(callbacks)) { + task.on(eventName as keyof TaskEvents, handler) + } + } + + private removeTaskEventListeners(task: Task): void { + const handlers = this.taskEventHandlers[task.taskId] + + if (!handlers) { + return + } + + for (const [eventName, handler] of Object.entries(handlers)) { + task.off(eventName as keyof TaskEvents, handler) + } + + delete this.taskEventHandlers[task.taskId] + } + + private serverChannel(taskId: string): string { + return `${this.config.namespace}:${taskId}:server` + } + + private clientChannel(taskId: string): string { + return `${this.config.namespace}:${taskId}:client` + } +} diff --git a/src/services/task-bridge/__tests__/TaskBridgeService.test.ts b/src/services/task-bridge/__tests__/TaskBridgeService.test.ts new file mode 100644 index 0000000000..746e7961e5 --- /dev/null +++ b/src/services/task-bridge/__tests__/TaskBridgeService.test.ts @@ -0,0 +1,292 @@ +// npx vitest services/task-bridge/__tests__/TaskBridgeService.test.ts + +import { EventEmitter } from "events" +import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" +import type { ClineMessage } from "@roo-code/types" +import RedisMock from "ioredis-mock" + +import { TaskBridgeService } from "../TaskBridgeService" +import type { Task } from "../../../core/task/Task" + +// Mock ioredis with ioredis-mock +vi.mock("ioredis", () => ({ + default: RedisMock, +})) + +class MockTask extends EventEmitter { + taskId: string + + constructor(taskId: string) { + super() + this.taskId = taskId + } +} + +describe("TaskBridgeService", () => { + let taskBridge: TaskBridgeService + let mockTask: Task + + beforeEach(() => { + // Clear the singleton instance + ;(TaskBridgeService as any).instance = null + + taskBridge = TaskBridgeService.getInstance({ + url: "redis://localhost:6379", + namespace: "test:task-bridge", + }) + + mockTask = new MockTask("test-task-123") as unknown as Task + }) + + afterEach(async () => { + if (taskBridge.connected) { + await taskBridge.disconnect() + } + + vi.clearAllMocks() + }) + + describe("Singleton Pattern", () => { + it("should return the same instance", () => { + const instance1 = TaskBridgeService.getInstance() + const instance2 = TaskBridgeService.getInstance() + expect(instance1).toBe(instance2) + }) + }) + + describe("Connection Management", () => { + it("should initialize Redis connections", async () => { + await taskBridge.initialize() + expect(taskBridge.connected).toBe(true) + }) + + it("should not reinitialize if already connected", async () => { + await taskBridge.initialize() + const firstPublisher = (taskBridge as any).publisher + + await taskBridge.initialize() + const secondPublisher = (taskBridge as any).publisher + + expect(firstPublisher).toBe(secondPublisher) + }) + + it("should disconnect properly", async () => { + await taskBridge.initialize() + await taskBridge.disconnect() + + expect(taskBridge.connected).toBe(false) + expect((taskBridge as any).publisher).toBeNull() + expect((taskBridge as any).subscriber).toBeNull() + }) + }) + + describe("Task Subscription", () => { + beforeEach(async () => { + await taskBridge.initialize() + }) + + it("should subscribe to task channels", async () => { + await taskBridge.subscribeToTask(mockTask) + expect(taskBridge.subscribedTaskCount).toBe(1) + }) + + it("should throw error if not connected", async () => { + await taskBridge.disconnect() + + await expect(taskBridge.subscribeToTask(mockTask)).rejects.toThrow("TaskBridgeService is not connected") + }) + + it("should unsubscribe from task channels", async () => { + await taskBridge.subscribeToTask(mockTask) + await taskBridge.unsubscribeFromTask(mockTask.taskId) + + expect(taskBridge.subscribedTaskCount).toBe(0) + }) + }) + + describe("Task Event Forwarding", () => { + beforeEach(async () => { + await taskBridge.initialize() + await taskBridge.subscribeToTask(mockTask) + }) + + it("should forward message events", async () => { + const publisher = (taskBridge as any).publisher + const publishSpy = vi.spyOn(publisher, "publish") + + const message: ClineMessage = { + ts: Date.now(), + type: "say", + say: "text", + text: "Test message", + } + + // Emit the event from the mock task + mockTask.emit("message", { action: "created", message }) + + // Wait for async publish + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Check that publish was called with correct parameters + expect(publishSpy).toHaveBeenCalledWith( + "test:task-bridge:test-task-123:client", + expect.stringContaining('"type":"task_event"'), + ) + + const publishedData = JSON.parse(publishSpy.mock.calls[0][1] as string) + expect(publishedData.type).toBe("task_event") + expect(publishedData.payload.eventType).toBe("message") + }) + + it("should forward task status events", async () => { + const publisher = (taskBridge as any).publisher + const publishSpy = vi.spyOn(publisher, "publish") + + const events = ["taskStarted", "taskPaused", "taskUnpaused", "taskAborted"] + + for (const event of events) { + mockTask.emit(event as any) + } + + // Wait for async publishes + await new Promise((resolve) => setTimeout(resolve, 100)) + + expect(publishSpy).toHaveBeenCalledTimes(events.length) + + // Verify each event was forwarded correctly + const statuses = publishSpy.mock.calls.map((call) => { + const parsed = JSON.parse(call[1] as string) + return parsed.payload.data.status + }) + + expect(statuses).toContain("started") + expect(statuses).toContain("paused") + expect(statuses).toContain("unpaused") + expect(statuses).toContain("aborted") + }) + }) + + describe("Message Handling", () => { + beforeEach(async () => { + await taskBridge.initialize() + await taskBridge.subscribeToTask(mockTask) + }) + + it("should log queued messages", async () => { + const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + + const message = { + taskId: "test-task-123", + type: "message_queued", + payload: { + eventType: "test", + data: { + text: "Test queued message", + timestamp: Date.now(), + }, + }, + timestamp: Date.now(), + } + + // Simulate receiving a message on the server channel + const subscriber = (taskBridge as any).subscriber + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) + + // Wait for message to be processed + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(consoleLogSpy).toHaveBeenCalledWith( + "Received message_queued event for task: test-task-123", + expect.objectContaining({ + eventType: "test", + data: expect.objectContaining({ + text: "Test queued message", + }), + }), + ) + + consoleLogSpy.mockRestore() + }) + + it("should ignore messages for unsubscribed tasks", async () => { + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + // Manually subscribe the subscriber to the channel (simulating a lingering subscription) + const subscriber = (taskBridge as any).subscriber + await subscriber.subscribe("test:task-bridge:unsubscribed-task-123:server") + + const message = { + taskId: "unsubscribed-task-123", + type: "message_queued", + payload: { + eventType: "test", + data: {}, + }, + timestamp: Date.now(), + } + + // Simulate receiving a message for an unsubscribed task + subscriber.emit("message", "test:task-bridge:unsubscribed-task-123:server", JSON.stringify(message)) + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(consoleWarnSpy).toHaveBeenCalledWith("Received message for unsubscribed task: unsubscribed-task-123") + + // Clean up + await subscriber.unsubscribe("test:task-bridge:unsubscribed-task-123:server") + consoleWarnSpy.mockRestore() + }) + }) + + describe("Publishing Messages", () => { + beforeEach(async () => { + await taskBridge.initialize() + }) + + it("should publish messages to external app", async () => { + const publisher = (taskBridge as any).publisher + const publishSpy = vi.spyOn(publisher, "publish") + + await taskBridge.publish("test-task-123", "task_status", { + eventType: "status", + data: { status: "processing" }, + }) + + expect(publishSpy).toHaveBeenCalledWith("test:task-bridge:test-task-123:client", expect.any(String)) + + const sentMessage = JSON.parse(publishSpy.mock.calls[0][1] as string) + expect(sentMessage.type).toBe("task_status") + expect(sentMessage.payload.data.status).toBe("processing") + }) + + it("should throw error if not connected", async () => { + await taskBridge.disconnect() + + await expect( + taskBridge.publish("test-task-123", "task_status", { + eventType: "status", + data: {}, + }), + ).rejects.toThrow("TaskBridgeService is not connected") + }) + }) + + describe("Error Handling", () => { + it("should handle malformed messages gracefully", async () => { + await taskBridge.initialize() + await taskBridge.subscribeToTask(mockTask) + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // Simulate receiving invalid JSON + const subscriber = (taskBridge as any).subscriber + subscriber.emit("message", "test:task-bridge:test-task-123:server", "invalid json") + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(consoleErrorSpy).toHaveBeenCalledWith("Error handling incoming message:", expect.any(Error)) + + consoleErrorSpy.mockRestore() + }) + }) +}) diff --git a/src/services/task-bridge/index.ts b/src/services/task-bridge/index.ts new file mode 100644 index 0000000000..086873f9e6 --- /dev/null +++ b/src/services/task-bridge/index.ts @@ -0,0 +1,6 @@ +export { + type TaskBridgeConfig, + type TaskBridgeMessage, + type QueuedMessage, + TaskBridgeService, +} from "./TaskBridgeService" From a89faa5a160fd6b252b54eec187b1e841506582a Mon Sep 17 00:00:00 2001 From: cte Date: Thu, 31 Jul 2025 00:48:53 -0700 Subject: [PATCH 2/5] Add message queuing --- src/core/task/Task.ts | 12 +- src/services/task-bridge/TaskBridgeService.ts | 476 ++++++++++-- .../__tests__/TaskBridgeService.test.ts | 708 ++++++++++++++++-- 3 files changed, 1063 insertions(+), 133 deletions(-) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 0c984ca1b7..28bb291d47 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -108,6 +108,8 @@ export type TaskEvents = { taskCompleted: [taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage] taskTokenUsageUpdated: [taskId: string, tokenUsage: TokenUsage] taskToolFailed: [taskId: string, tool: ToolName, error: string] + taskBusy: [taskId: string] + taskFree: [taskId: string] } export type TaskEventHandlers = { @@ -727,7 +729,7 @@ export class Task extends EventEmitter { return result } - async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + public async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { this.askResponse = askResponse this.askResponseText = text this.askResponseImages = images @@ -1333,7 +1335,7 @@ export class Task extends EventEmitter { while (!this.abort) { const didEndLoop = await this.recursivelyMakeClineRequests(nextUserContent, includeFileDetails) - includeFileDetails = false // we only need file details the first time + includeFileDetails = false // We only need file details the first time. // The way this agentic loop works is that cline will be given a // task that he then calls tools to complete. Unless there's an @@ -1570,6 +1572,7 @@ export class Task extends EventEmitter { let assistantMessage = "" let reasoningMessage = "" this.isStreaming = true + this.emit("taskBusy", this.taskId) try { for await (const chunk of stream) { @@ -1659,13 +1662,13 @@ export class Task extends EventEmitter { // If this.abort is already true, it means the user clicked cancel, so we should // treat this as "user_cancelled" rather than "streaming_failed" const cancelReason = this.abort ? "user_cancelled" : "streaming_failed" + const streamingFailedMessage = this.abort ? undefined : (error.message ?? JSON.stringify(serializeError(error), null, 2)) - // Now call abortTask after determining the cancel reason + // Now call abortTask after determining the cancel reason. await this.abortTask() - await abortStream(cancelReason, streamingFailedMessage) const history = await provider?.getTaskWithId(this.taskId) @@ -1676,6 +1679,7 @@ export class Task extends EventEmitter { } } finally { this.isStreaming = false + this.emit("taskFree", this.taskId) } if (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0) { diff --git a/src/services/task-bridge/TaskBridgeService.ts b/src/services/task-bridge/TaskBridgeService.ts index 58ffab70db..50a7c04e4e 100644 --- a/src/services/task-bridge/TaskBridgeService.ts +++ b/src/services/task-bridge/TaskBridgeService.ts @@ -11,9 +11,11 @@ export interface TaskBridgeConfig { reconnectOnError?: boolean maxReconnectAttempts?: number reconnectDelay?: number + connectionTimeout?: number + commandTimeout?: number } -const taskBridgeMessageTypes = ["message_queued", "task_status", "task_event"] as const +const taskBridgeMessageTypes = ["message", "task_event"] as const type TaskBridgeMessageType = (typeof taskBridgeMessageTypes)[number] @@ -39,6 +41,15 @@ export interface QueuedMessage { timestamp: number } +interface InternalQueuedMessage { + id: string + taskId: string + message: QueuedMessage + timestamp: number + retryCount: number + maxRetries: number +} + export class TaskBridgeService { private static instance: TaskBridgeService | null = null @@ -51,89 +62,138 @@ export class TaskBridgeService { private subscribedTasks: Map = new Map() private taskEventHandlers: Record> = {} + private messageQueues: Map = new Map() + private taskStatuses: Map = new Map() + private processingQueues: Set = new Set() + private queueProcessingTimeouts: Map = new Map() + private processingPromises: Map> = new Map() + + private readonly RETRY_DELAY_MS = 1000 + private readonly MAX_RETRY_DELAY_MS = 30000 + private readonly DEFAULT_MAX_RETRIES = 3 + private constructor({ url = "redis://localhost:6379", namespace = NAMESPACE, reconnectOnError = true, maxReconnectAttempts = 10, reconnectDelay = 5000, + connectionTimeout = 10000, + commandTimeout = 5000, }: TaskBridgeConfig = {}) { - this.config = { url, namespace, reconnectOnError, maxReconnectAttempts, reconnectDelay } + this.config = { + url, + namespace, + reconnectOnError, + maxReconnectAttempts, + reconnectDelay, + connectionTimeout, + commandTimeout, + } + this.validateConfig() } public static getInstance(config?: TaskBridgeConfig) { if (!TaskBridgeService.instance) { TaskBridgeService.instance = new TaskBridgeService(config) + } else if (config) { + console.warn("[TaskBridgeService] Instance already exists. Configuration will be ignored.") } return TaskBridgeService.instance } + public static resetInstance(): void { + if (TaskBridgeService.instance) { + TaskBridgeService.instance.disconnect().catch(() => {}) + TaskBridgeService.instance = null + } + } + public async initialize() { if (this.isConnected) { return } - this.publisher = new Redis(this.config.url!, { - retryStrategy: (times: number) => { - if (times > this.config.maxReconnectAttempts!) { - return null - } - - return Math.min(times * 50, 2000) - }, - }) - - this.subscriber = new Redis(this.config.url!, { - retryStrategy: (times: number) => { - if (times > this.config.maxReconnectAttempts!) { - return null + try { + this.publisher = new Redis(this.config.url!, { + retryStrategy: (times: number) => { + if (times > this.config.maxReconnectAttempts!) { + return null + } + + return Math.min(times * 50, 2000) + }, + enableOfflineQueue: false, + lazyConnect: true, + connectTimeout: this.config.connectionTimeout, + commandTimeout: this.config.commandTimeout, + }) + + this.subscriber = new Redis(this.config.url!, { + retryStrategy: (times: number) => { + if (times > this.config.maxReconnectAttempts!) { + return null + } + + return Math.min(times * 50, 2000) + }, + enableOfflineQueue: false, + lazyConnect: true, + connectTimeout: this.config.connectionTimeout, + commandTimeout: this.config.commandTimeout, + }) + + this.publisher.on("error", (error: Error) => this.handleConnectionError(error)) + this.publisher.on("close", () => this.handleConnectionError()) + + this.subscriber.on("error", (error: Error) => this.handleConnectionError(error)) + this.subscriber.on("close", () => this.handleConnectionError()) + + this.subscriber.on("message", (channel: string, buffer: string) => { + try { + const message = taskBridgeMessageSchema.parse(JSON.parse(buffer)) + const parts = channel.split(":") + const taskId = parts[parts.length - 2] + const task = this.subscribedTasks.get(taskId) + + if (!task) { + console.warn(`Received message for unsubscribed task: ${taskId}`) + return + } + + switch (message.type) { + case "message": + console.log(`Received message event for task: ${taskId}`, message.payload) + this.handleQueuedMessage(taskId, message.payload) + break + } + } catch (error) { + console.error("Error handling incoming message:", error) } + }) - return Math.min(times * 50, 2000) - }, - }) - - this.publisher.on("error", (error: Error) => this.handleConnectionError(error)) - this.publisher.on("close", () => this.handleConnectionError()) - - this.subscriber.on("error", (error: Error) => this.handleConnectionError(error)) - this.subscriber.on("close", () => this.handleConnectionError()) + // Connect explicitly + await Promise.all([this.publisher.connect(), this.subscriber.connect()]) - this.subscriber.on("message", (channel: string, buffer: string) => { - try { - const message = taskBridgeMessageSchema.parse(JSON.parse(buffer)) - const parts = channel.split(":") - const taskId = parts[parts.length - 2] - const task = this.subscribedTasks.get(taskId) + await Promise.all([this.waitForConnection(this.publisher), this.waitForConnection(this.subscriber)]) - if (!task) { - console.warn(`Received message for unsubscribed task: ${taskId}`) - return - } + this.isConnected = true + this.reconnectAttempts = 0 - switch (message.type) { - case "message_queued": - console.log(`Received message_queued event for task: ${taskId}`, message.payload) - break - case "task_status": - console.log(`Received task_status event for task: ${taskId}`, message.payload) - break - case "task_event": - console.log(`Received task_event event for task: ${taskId}`, message.payload) - break - } - } catch (error) { - console.error("Error handling incoming message:", error) + console.log(`[TaskBridgeService] connected -> ${this.config.url}`) + } catch (error) { + // Clean up on failure + if (this.publisher) { + await this.publisher.quit().catch(() => {}) + this.publisher = null } - }) - - await Promise.all([this.waitForConnection(this.publisher), this.waitForConnection(this.subscriber)]) - - this.isConnected = true - this.reconnectAttempts = 0 - - console.log(`[TaskBridgeService] connected -> ${this.config.url}`) + if (this.subscriber) { + await this.subscriber.quit().catch(() => {}) + this.subscriber = null + } + throw error + } } public async subscribeToTask(task: Task): Promise { @@ -180,25 +240,52 @@ export class TaskBridgeService { } public async disconnect(): Promise { + // Clear reconnection timeout if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout) this.reconnectTimeout = null } + // Clear all queue processing timeouts + for (const timeoutId of this.queueProcessingTimeouts.values()) { + clearTimeout(timeoutId) + } + this.queueProcessingTimeouts.clear() + + // Wait for all processing to complete + const processingPromises = Array.from(this.processingPromises.values()) + if (processingPromises.length > 0) { + await Promise.allSettled(processingPromises) + } + this.processingPromises.clear() + + // Unsubscribe from all tasks + const unsubscribePromises = [] for (const taskId of this.subscribedTasks.keys()) { - await this.unsubscribeFromTask(taskId) + unsubscribePromises.push(this.unsubscribeFromTask(taskId)) } + await Promise.allSettled(unsubscribePromises) + // Remove event listeners before closing connections if (this.publisher) { + this.publisher.removeAllListeners() await this.publisher.quit() this.publisher = null } if (this.subscriber) { + this.subscriber.removeAllListeners() await this.subscriber.quit() this.subscriber = null } + // Clear all internal state + this.messageQueues.clear() + this.taskStatuses.clear() + this.processingQueues.clear() + this.subscribedTasks.clear() + this.taskEventHandlers = {} + this.isConnected = false TaskBridgeService.instance = null } @@ -235,14 +322,21 @@ export class TaskBridgeService { }) } - private handleConnectionError(_error?: Error): void { + private handleConnectionError(error?: Error): void { this.isConnected = false + // Log the error for debugging + if (error) { + console.error("[TaskBridgeService] Connection error:", error.message) + } + if (!this.config.reconnectOnError) { + console.warn("[TaskBridgeService] Reconnection disabled, service will remain disconnected") return } if (this.reconnectAttempts >= this.config.maxReconnectAttempts!) { + console.error(`[TaskBridgeService] Max reconnection attempts (${this.config.maxReconnectAttempts}) reached`) return } @@ -251,27 +345,46 @@ export class TaskBridgeService { } this.reconnectAttempts++ + console.log( + `[TaskBridgeService] Scheduling reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`, + ) this.reconnectTimeout = setTimeout(() => { this.reconnectTimeout = null - this.initialize() + this.initialize().catch((err) => { + console.error("[TaskBridgeService] Reconnection failed:", err) + this.handleConnectionError(err) + }) }, this.config.reconnectDelay) } private setupTaskEventListeners(task: Task) { const callbacks: Partial = { - message: ({ action, message }) => - this.publish(task.taskId, "task_event", { eventType: "message", data: { action, message } }), + // message: ({ action, message }) => + // this.publish(task.taskId, "task_event", { eventType: "message", data: { action, message } }), taskStarted: () => this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "started" } }), taskPaused: () => this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "paused" } }), taskUnpaused: () => this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "unpaused" } }), - taskAborted: () => - this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "aborted" } }), - taskCompleted: () => - this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "completed" } }), + taskAborted: () => { + this.cleanupTaskQueue(task.taskId) + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "aborted" } }) + }, + taskCompleted: () => { + this.cleanupTaskQueue(task.taskId) + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "completed" } }) + }, + taskBusy: (taskId: string) => { + this.taskStatuses.set(taskId, false) + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "busy" } }) + }, + taskFree: (taskId: string) => { + this.taskStatuses.set(taskId, true) + this.processQueueForTask(taskId) + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "free" } }) + }, } this.taskEventHandlers[task.taskId] = callbacks @@ -302,4 +415,237 @@ export class TaskBridgeService { private clientChannel(taskId: string): string { return `${this.config.namespace}:${taskId}:client` } + + private isTaskReady(taskId: string): boolean { + return this.taskStatuses.get(taskId) ?? false + } + + private handleQueuedMessage(taskId: string, payload: TaskBridgeMessagePayload): void { + const messageData = payload.data as { text?: string; images?: string[] } + + if (!messageData.text) { + console.warn(`Received queued message without text for task: ${taskId}`) + return + } + + const queuedMessage: QueuedMessage = { + text: messageData.text, + images: messageData.images, + timestamp: Date.now(), + } + + if (this.isTaskReady(taskId)) { + this.deliverMessage(taskId, queuedMessage) + } else { + this.enqueueMessage(taskId, queuedMessage) + } + } + + private enqueueMessage(taskId: string, message: QueuedMessage): void { + const queue = this.messageQueues.get(taskId) || [] + + queue.push({ + id: `${taskId}-${Date.now()}-${Math.random()}`, + taskId, + message, + timestamp: Date.now(), + retryCount: 0, + maxRetries: this.DEFAULT_MAX_RETRIES, + }) + + this.messageQueues.set(taskId, queue) + console.log(`Queued message for task ${taskId}. Queue size: ${queue.length}`) + } + + private async deliverMessage(taskId: string, message: QueuedMessage): Promise { + const task = this.subscribedTasks.get(taskId) + + if (!task) { + console.warn(`Cannot deliver message: task ${taskId} not found`) + return false + } + + try { + await task.handleWebviewAskResponse("messageResponse", message.text, message.images) + return true + } catch (error) { + console.error(`Failed to deliver message to task ${taskId}:`, error) + return false + } + } + + private async processQueueForTask(taskId: string): Promise { + // Check if there's already a processing promise for this task. + const existingPromise = this.processingPromises.get(taskId) + + if (existingPromise) { + // Wait for the existing processing to complete. + await existingPromise + + // After existing processing completes, check if we need to process again. + // This handles the case where new messages arrived during processing. + if (this.messageQueues.has(taskId) && this.isTaskReady(taskId)) { + return this.processQueueForTask(taskId) + } + return + } + + // Check if there's actually work to do. + const queue = this.messageQueues.get(taskId) + + if (!queue || queue.length === 0) { + return + } + + // Create and store the processing promise. + const processingPromise = this._processQueue(taskId) + this.processingPromises.set(taskId, processingPromise) + + try { + await processingPromise + } finally { + // Clean up the promise reference. + this.processingPromises.delete(taskId) + } + } + + private async _processQueue(taskId: string): Promise { + const queue = this.messageQueues.get(taskId) + + if (!queue || queue.length === 0) { + return + } + + this.processingQueues.add(taskId) + + try { + while (queue.length > 0 && this.isTaskReady(taskId)) { + const queuedMessage = queue[0] + console.log(`Processing queued message for task ${taskId}. Remaining: ${queue.length}`) + const success = await this.deliverMessage(taskId, queuedMessage.message) + + if (success) { + queue.shift() + } else { + queuedMessage.retryCount++ + + if (queuedMessage.retryCount >= queuedMessage.maxRetries) { + console.error(`Max retries reached for message in task ${taskId}. Removing from queue.`) + queue.shift() + } else { + const delay = Math.min( + this.RETRY_DELAY_MS * Math.pow(2, queuedMessage.retryCount - 1), + this.MAX_RETRY_DELAY_MS, + ) + + console.log(`Scheduling retry ${queuedMessage.retryCount} for task ${taskId} in ${delay}ms`) + + const timeoutId = setTimeout(() => { + this.queueProcessingTimeouts.delete(taskId) + this.processQueueForTask(taskId) + }, delay) + + this.queueProcessingTimeouts.set(taskId, timeoutId) + break + } + } + } + + if (queue.length === 0) { + this.messageQueues.delete(taskId) + } else { + this.messageQueues.set(taskId, queue) + } + } finally { + this.processingQueues.delete(taskId) + } + } + + private cleanupTaskQueue(taskId: string): void { + const timeoutId = this.queueProcessingTimeouts.get(taskId) + + if (timeoutId) { + clearTimeout(timeoutId) + this.queueProcessingTimeouts.delete(taskId) + } + + // Wait for any ongoing processing to complete before cleaning up.. + const processingPromise = this.processingPromises.get(taskId) + + if (processingPromise) { + // Don't await here to avoid blocking, just delete the reference. + this.processingPromises.delete(taskId) + } + + this.processingQueues.delete(taskId) + this.messageQueues.delete(taskId) + this.taskStatuses.delete(taskId) + console.log(`Cleaned up queue for task ${taskId}`) + } + + private validateConfig(): void { + if (!this.config.url) { + throw new Error("[TaskBridgeService] Redis URL is required") + } + + if (!this.config.namespace || this.config.namespace.trim() === "") { + throw new Error("[TaskBridgeService] Namespace is required") + } + + if (this.config.maxReconnectAttempts! < 0) { + throw new Error("[TaskBridgeService] maxReconnectAttempts must be non-negative") + } + + if (this.config.reconnectDelay! < 0) { + throw new Error("[TaskBridgeService] reconnectDelay must be non-negative") + } + + if (this.config.connectionTimeout! < 0) { + throw new Error("[TaskBridgeService] connectionTimeout must be non-negative") + } + + if (this.config.commandTimeout! < 0) { + throw new Error("[TaskBridgeService] commandTimeout must be non-negative") + } + } + + /** + * Get the current connection status + */ + public getStatus(): { + connected: boolean + reconnectAttempts: number + subscribedTasks: number + queuedMessages: number + processingTasks: number + } { + let totalQueuedMessages = 0 + for (const queue of this.messageQueues.values()) { + totalQueuedMessages += queue.length + } + + return { + connected: this.isConnected, + reconnectAttempts: this.reconnectAttempts, + subscribedTasks: this.subscribedTasks.size, + queuedMessages: totalQueuedMessages, + processingTasks: this.processingQueues.size, + } + } + + /** + * Check if a specific task has queued messages + */ + public hasQueuedMessages(taskId: string): boolean { + const queue = this.messageQueues.get(taskId) + return queue ? queue.length > 0 : false + } + + /** + * Get the number of queued messages for a specific task + */ + public getQueuedMessageCount(taskId: string): number { + const queue = this.messageQueues.get(taskId) + return queue ? queue.length : 0 + } } diff --git a/src/services/task-bridge/__tests__/TaskBridgeService.test.ts b/src/services/task-bridge/__tests__/TaskBridgeService.test.ts index 746e7961e5..3c2d138c10 100644 --- a/src/services/task-bridge/__tests__/TaskBridgeService.test.ts +++ b/src/services/task-bridge/__tests__/TaskBridgeService.test.ts @@ -1,7 +1,7 @@ // npx vitest services/task-bridge/__tests__/TaskBridgeService.test.ts import { EventEmitter } from "events" -import { vi, describe, it, expect, beforeEach, afterEach } from "vitest" +import { vi, describe, it, expect, beforeEach, afterEach, Mock } from "vitest" import type { ClineMessage } from "@roo-code/types" import RedisMock from "ioredis-mock" @@ -13,18 +13,24 @@ vi.mock("ioredis", () => ({ default: RedisMock, })) +// Enhanced MockTask class with all properties needed for message queuing tests class MockTask extends EventEmitter { taskId: string + isStreaming: boolean = false + isPaused: boolean = false + isWaitingForResponse: boolean = false + handleWebviewAskResponse: Mock constructor(taskId: string) { super() this.taskId = taskId + this.handleWebviewAskResponse = vi.fn() } } describe("TaskBridgeService", () => { let taskBridge: TaskBridgeService - let mockTask: Task + let mockTask: MockTask beforeEach(() => { // Clear the singleton instance @@ -35,7 +41,7 @@ describe("TaskBridgeService", () => { namespace: "test:task-bridge", }) - mockTask = new MockTask("test-task-123") as unknown as Task + mockTask = new MockTask("test-task-123") }) afterEach(async () => { @@ -43,6 +49,8 @@ describe("TaskBridgeService", () => { await taskBridge.disconnect() } + // Reset singleton instance + TaskBridgeService.resetInstance() vi.clearAllMocks() }) @@ -86,18 +94,20 @@ describe("TaskBridgeService", () => { }) it("should subscribe to task channels", async () => { - await taskBridge.subscribeToTask(mockTask) + await taskBridge.subscribeToTask(mockTask as unknown as Task) expect(taskBridge.subscribedTaskCount).toBe(1) }) it("should throw error if not connected", async () => { await taskBridge.disconnect() - await expect(taskBridge.subscribeToTask(mockTask)).rejects.toThrow("TaskBridgeService is not connected") + await expect(taskBridge.subscribeToTask(mockTask as unknown as Task)).rejects.toThrow( + "TaskBridgeService is not connected", + ) }) it("should unsubscribe from task channels", async () => { - await taskBridge.subscribeToTask(mockTask) + await taskBridge.subscribeToTask(mockTask as unknown as Task) await taskBridge.unsubscribeFromTask(mockTask.taskId) expect(taskBridge.subscribedTaskCount).toBe(0) @@ -107,7 +117,7 @@ describe("TaskBridgeService", () => { describe("Task Event Forwarding", () => { beforeEach(async () => { await taskBridge.initialize() - await taskBridge.subscribeToTask(mockTask) + await taskBridge.subscribeToTask(mockTask as unknown as Task) }) it("should forward message events", async () => { @@ -121,21 +131,19 @@ describe("TaskBridgeService", () => { text: "Test message", } - // Emit the event from the mock task + // Note: The TaskBridgeService doesn't actually forward message events + // It only forwards task status events. This test is kept for reference + // but the expectation is that message events are NOT forwarded mockTask.emit("message", { action: "created", message }) - // Wait for async publish + // Wait for async operations await new Promise((resolve) => setTimeout(resolve, 50)) - // Check that publish was called with correct parameters - expect(publishSpy).toHaveBeenCalledWith( + // Verify that message events are not forwarded + expect(publishSpy).not.toHaveBeenCalledWith( "test:task-bridge:test-task-123:client", - expect.stringContaining('"type":"task_event"'), + expect.stringContaining('"eventType":"message"'), ) - - const publishedData = JSON.parse(publishSpy.mock.calls[0][1] as string) - expect(publishedData.type).toBe("task_event") - expect(publishedData.payload.eventType).toBe("message") }) it("should forward task status events", async () => { @@ -169,20 +177,22 @@ describe("TaskBridgeService", () => { describe("Message Handling", () => { beforeEach(async () => { await taskBridge.initialize() - await taskBridge.subscribeToTask(mockTask) + await taskBridge.subscribeToTask(mockTask as unknown as Task) }) - it("should log queued messages", async () => { - const consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + it("should handle incoming messages", async () => { + // Setup task as ready + mockTask.emit("taskFree", mockTask.taskId) + await new Promise((resolve) => setTimeout(resolve, 50)) const message = { taskId: "test-task-123", - type: "message_queued", + type: "message", payload: { - eventType: "test", + eventType: "message", data: { - text: "Test queued message", - timestamp: Date.now(), + text: "Test message", + images: ["test.png"], }, }, timestamp: Date.now(), @@ -195,17 +205,10 @@ describe("TaskBridgeService", () => { // Wait for message to be processed await new Promise((resolve) => setTimeout(resolve, 50)) - expect(consoleLogSpy).toHaveBeenCalledWith( - "Received message_queued event for task: test-task-123", - expect.objectContaining({ - eventType: "test", - data: expect.objectContaining({ - text: "Test queued message", - }), - }), - ) - - consoleLogSpy.mockRestore() + // Verify the message was delivered to the task + expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Test message", [ + "test.png", + ]) }) it("should ignore messages for unsubscribed tasks", async () => { @@ -217,10 +220,12 @@ describe("TaskBridgeService", () => { const message = { taskId: "unsubscribed-task-123", - type: "message_queued", + type: "message", payload: { - eventType: "test", - data: {}, + eventType: "message", + data: { + text: "Test message", + }, }, timestamp: Date.now(), } @@ -238,55 +243,630 @@ describe("TaskBridgeService", () => { }) }) - describe("Publishing Messages", () => { + describe("Error Handling", () => { + it("should handle malformed messages gracefully", async () => { + await taskBridge.initialize() + await taskBridge.subscribeToTask(mockTask as unknown as Task) + + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + // Simulate receiving invalid JSON + const subscriber = (taskBridge as any).subscriber + subscriber.emit("message", "test:task-bridge:test-task-123:server", "invalid json") + + await new Promise((resolve) => setTimeout(resolve, 50)) + + expect(consoleErrorSpy).toHaveBeenCalledWith("Error handling incoming message:", expect.any(Error)) + + consoleErrorSpy.mockRestore() + }) + }) + + describe("Message Queuing", () => { beforeEach(async () => { await taskBridge.initialize() }) - it("should publish messages to external app", async () => { - const publisher = (taskBridge as any).publisher - const publishSpy = vi.spyOn(publisher, "publish") + it("should queue messages when task is not ready", async () => { + // Setup task as not ready (streaming) + mockTask.isStreaming = true + mockTask.isWaitingForResponse = false + + await taskBridge.subscribeToTask(mockTask as any) + + // Send a message through Redis + const message = { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { + text: "Test message", + images: ["image1.png"], + }, + }, + timestamp: Date.now(), + } + + const subscriber = (taskBridge as any).subscriber + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) + + // Wait for message processing + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Verify message was not sent immediately (task is not ready) + expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() + }) + + it("should send messages directly when task is ready", async () => { + await taskBridge.subscribeToTask(mockTask as any) + + // Emit taskFree event to indicate task is ready + mockTask.emit("taskFree", mockTask.taskId) + + // Wait for event processing + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Send a message through Redis + const message = { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { + text: "Test message", + images: ["image1.png"], + }, + }, + timestamp: Date.now(), + } + + const subscriber = (taskBridge as any).subscriber + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) + + // Wait for message processing + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Verify message was sent immediately + expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Test message", [ + "image1.png", + ]) + }) + + it("should process queued messages when task becomes ready", async () => { + // Setup task as not ready initially + mockTask.isStreaming = true + mockTask.isWaitingForResponse = false + + await taskBridge.subscribeToTask(mockTask as any) + + // Send multiple messages through Redis + const messages = [ + { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { text: "Message 1" }, + }, + timestamp: Date.now(), + }, + { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { text: "Message 2", images: ["image2.png"] }, + }, + timestamp: Date.now() + 1, + }, + { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { text: "Message 3" }, + }, + timestamp: Date.now() + 2, + }, + ] - await taskBridge.publish("test-task-123", "task_status", { - eventType: "status", - data: { status: "processing" }, + const subscriber = (taskBridge as any).subscriber + for (const msg of messages) { + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(msg)) + await new Promise((resolve) => setTimeout(resolve, 10)) + } + + // Verify messages were not sent (task not ready) + expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() + + // Make task ready by emitting taskFree + mockTask.emit("taskFree", mockTask.taskId) + + // Wait for queue processing + await new Promise((resolve) => setTimeout(resolve, 200)) + + // Verify all messages were sent in order + expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledTimes(3) + expect(mockTask.handleWebviewAskResponse).toHaveBeenNthCalledWith( + 1, + "messageResponse", + "Message 1", + undefined, + ) + expect(mockTask.handleWebviewAskResponse).toHaveBeenNthCalledWith(2, "messageResponse", "Message 2", [ + "image2.png", + ]) + expect(mockTask.handleWebviewAskResponse).toHaveBeenNthCalledWith( + 3, + "messageResponse", + "Message 3", + undefined, + ) + }) + }) + + describe("Error Handling and Retry Logic", () => { + beforeEach(async () => { + await taskBridge.initialize() + }) + + it("should retry failed messages with exponential backoff", async () => { + // Make handleWebviewAskResponse fail initially + let callCount = 0 + mockTask.handleWebviewAskResponse.mockImplementation(() => { + callCount++ + if (callCount <= 2) { + return Promise.reject(new Error(`Attempt ${callCount} failed`)) + } + return Promise.resolve(undefined) + }) + + await taskBridge.subscribeToTask(mockTask as any) + + // Send a message while task is not ready + const message = { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { + text: "Test message", + }, + }, + timestamp: Date.now(), + } + + const subscriber = (taskBridge as any).subscriber + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Make task ready to trigger processing + mockTask.emit("taskFree", mockTask.taskId) + + // Wait for initial attempt + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(callCount).toBe(1) + + // Wait for first retry (1 second) + await new Promise((resolve) => setTimeout(resolve, 1100)) + expect(callCount).toBe(2) + + // Wait for second retry (2 seconds) + await new Promise((resolve) => setTimeout(resolve, 2100)) + expect(callCount).toBe(3) + + // Message should have succeeded on third attempt + expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledTimes(3) + }, 5000) + + it("should remove messages after max retries", async () => { + // Make handleWebviewAskResponse always fail + let callCount = 0 + mockTask.handleWebviewAskResponse.mockImplementation(() => { + callCount++ + return Promise.reject(new Error("Always fails")) }) - expect(publishSpy).toHaveBeenCalledWith("test:task-bridge:test-task-123:client", expect.any(String)) + await taskBridge.subscribeToTask(mockTask as any) + + // Send a message while task is not ready + const message = { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { + text: "Test message", + }, + }, + timestamp: Date.now(), + } + + const subscriber = (taskBridge as any).subscriber + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Make task ready to trigger processing + mockTask.emit("taskFree", mockTask.taskId) + + // Wait for initial attempt + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(callCount).toBe(1) + + // Wait for first retry (1s) + await new Promise((resolve) => setTimeout(resolve, 1100)) + expect(callCount).toBe(2) + + // Wait for second retry (2s) + await new Promise((resolve) => setTimeout(resolve, 2100)) + expect(callCount).toBe(3) - const sentMessage = JSON.parse(publishSpy.mock.calls[0][1] as string) - expect(sentMessage.type).toBe("task_status") - expect(sentMessage.payload.data.status).toBe("processing") + // Should have attempted 3 times total + expect(callCount).toBe(3) + + // The message should be removed after max retries + // Send another message to verify the queue is working + mockTask.handleWebviewAskResponse.mockResolvedValue(undefined) + + const newMessage = { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { + text: "New message", + }, + }, + timestamp: Date.now(), + } + + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(newMessage)) + await new Promise((resolve) => setTimeout(resolve, 100)) + + // The new message should be delivered immediately (task is ready) + expect(mockTask.handleWebviewAskResponse).toHaveBeenLastCalledWith( + "messageResponse", + "New message", + undefined, + ) + }, 10000) // Increase timeout for this test + }) + + describe("Task State Management", () => { + beforeEach(async () => { + await taskBridge.initialize() }) - it("should throw error if not connected", async () => { - await taskBridge.disconnect() + it("should track task state changes through events", async () => { + await taskBridge.subscribeToTask(mockTask as any) + + // Send a message while task is not ready + const message = { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { + text: "Test message", + }, + }, + timestamp: Date.now(), + } + + const subscriber = (taskBridge as any).subscriber + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Message should not be processed yet + expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() + + // Emit taskFree event to indicate task is ready + mockTask.emit("taskFree", mockTask.taskId) + + // Wait for async processing + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Message should have been processed + expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Test message", undefined) + }) + + it("should not process messages when task is paused", async () => { + await taskBridge.subscribeToTask(mockTask as any) + + // Pause the task + mockTask.isPaused = true + mockTask.emit("taskPaused") + + // Send messages + const messages = [ + { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { text: "Message 1" }, + }, + timestamp: Date.now(), + }, + { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { text: "Message 2" }, + }, + timestamp: Date.now() + 1, + }, + ] + + const subscriber = (taskBridge as any).subscriber + for (const msg of messages) { + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(msg)) + } + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Messages should not be processed (task is paused) + expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() + + // Unpause and make task ready + mockTask.isPaused = false + mockTask.emit("taskUnpaused") + mockTask.emit("taskFree", mockTask.taskId) + + // Wait for processing + await new Promise((resolve) => setTimeout(resolve, 200)) + + // Messages should now be processed + expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledTimes(2) + }) + + it("should clean up queue when task is aborted", async () => { + await taskBridge.subscribeToTask(mockTask as any) + + // Send messages + const messages = [ + { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { text: "Message 1" }, + }, + timestamp: Date.now(), + }, + { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { text: "Message 2" }, + }, + timestamp: Date.now() + 1, + }, + ] + + const subscriber = (taskBridge as any).subscriber + for (const msg of messages) { + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(msg)) + } + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Abort the task + mockTask.emit("taskAborted") + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Messages should not be processed + expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() + + // Try to make task ready - messages should still not be processed (queue was cleared) + mockTask.emit("taskFree", mockTask.taskId) + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() + }) + + it("should clean up queue when task is completed", async () => { + await taskBridge.subscribeToTask(mockTask as any) + + // Send a message + const message = { + taskId: mockTask.taskId, + type: "message", + payload: { + eventType: "message", + data: { + text: "Message 1", + }, + }, + timestamp: Date.now(), + } + + const subscriber = (taskBridge as any).subscriber + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) + + // Wait a bit + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Complete the task + mockTask.emit("taskCompleted") + + // Wait for cleanup + await new Promise((resolve) => setTimeout(resolve, 100)) - await expect( - taskBridge.publish("test-task-123", "task_status", { - eventType: "status", - data: {}, - }), - ).rejects.toThrow("TaskBridgeService is not connected") + // Message should not be processed + expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() }) }) - describe("Error Handling", () => { - it("should handle malformed messages gracefully", async () => { + describe("Race Condition Prevention", () => { + beforeEach(async () => { await taskBridge.initialize() - await taskBridge.subscribeToTask(mockTask) + }) - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + it("should handle concurrent processQueueForTask calls without race conditions", async () => { + await taskBridge.subscribeToTask(mockTask as any) + + // Send multiple messages while task is not ready + const messages = Array.from({ length: 5 }, (_, i) => ({ + taskId: mockTask.taskId, + type: "message" as const, + payload: { + eventType: "message", + data: { text: `Message ${i + 1}` }, + }, + timestamp: Date.now() + i, + })) - // Simulate receiving invalid JSON const subscriber = (taskBridge as any).subscriber - subscriber.emit("message", "test:task-bridge:test-task-123:server", "invalid json") + for (const msg of messages) { + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(msg)) + } + // Wait for messages to be queued await new Promise((resolve) => setTimeout(resolve, 50)) - expect(consoleErrorSpy).toHaveBeenCalledWith("Error handling incoming message:", expect.any(Error)) + // Simulate multiple concurrent taskFree events + // This would previously cause race conditions + const promises = Array.from({ length: 3 }, () => { + mockTask.emit("taskFree", mockTask.taskId) + return new Promise((resolve) => setTimeout(resolve, 10)) + }) - consoleErrorSpy.mockRestore() + await Promise.all(promises) + + // Wait for all processing to complete + await new Promise((resolve) => setTimeout(resolve, 200)) + + // All messages should be processed exactly once + expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledTimes(5) + for (let i = 0; i < 5; i++) { + expect(mockTask.handleWebviewAskResponse).toHaveBeenNthCalledWith( + i + 1, + "messageResponse", + `Message ${i + 1}`, + undefined, + ) + } + }) + + it("should not lose messages when task state changes rapidly", async () => { + await taskBridge.subscribeToTask(mockTask as any) + + // Send messages + const messages = Array.from({ length: 3 }, (_, i) => ({ + taskId: mockTask.taskId, + type: "message" as const, + payload: { + eventType: "message", + data: { text: `Message ${i + 1}` }, + }, + timestamp: Date.now() + i, + })) + + const subscriber = (taskBridge as any).subscriber + for (const msg of messages) { + subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(msg)) + } + + // Rapidly toggle task state + mockTask.emit("taskFree", mockTask.taskId) + await new Promise((resolve) => setTimeout(resolve, 5)) + + mockTask.emit("taskBusy", mockTask.taskId) + await new Promise((resolve) => setTimeout(resolve, 5)) + + mockTask.emit("taskFree", mockTask.taskId) + await new Promise((resolve) => setTimeout(resolve, 5)) + + mockTask.emit("taskBusy", mockTask.taskId) + await new Promise((resolve) => setTimeout(resolve, 5)) + + mockTask.emit("taskFree", mockTask.taskId) + + // Wait for processing to complete + await new Promise((resolve) => setTimeout(resolve, 200)) + + // All messages should still be processed + expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledTimes(3) + }) + }) + + describe("Multiple Tasks", () => { + beforeEach(async () => { + await taskBridge.initialize() + }) + + it("should maintain separate queues for different tasks", async () => { + const task1 = new MockTask("task-1") + const task2 = new MockTask("task-2") + + await taskBridge.subscribeToTask(task1 as any) + await taskBridge.subscribeToTask(task2 as any) + + // Task 2 is ready (emit taskFree) + task2.emit("taskFree", task2.taskId) + + // Wait for event processing + await new Promise((resolve) => setTimeout(resolve, 50)) + + // Send messages for both tasks through Redis + const message1 = { + taskId: task1.taskId, + type: "message", + payload: { + eventType: "message", + data: { + text: "Task 1 Message", + }, + }, + timestamp: Date.now(), + } + + const message2 = { + taskId: task2.taskId, + type: "message", + payload: { + eventType: "message", + data: { + text: "Task 2 Message", + }, + }, + timestamp: Date.now(), + } + + const subscriber = (taskBridge as any).subscriber + subscriber.emit("message", "test:task-bridge:task-1:server", JSON.stringify(message1)) + subscriber.emit("message", "test:task-bridge:task-2:server", JSON.stringify(message2)) + + // Wait for message processing + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Task 1 message should not be processed (no taskFree event) + expect(task1.handleWebviewAskResponse).not.toHaveBeenCalled() + + // Task 2 message should be sent immediately + expect(task2.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Task 2 Message", undefined) + + // Now make task 1 ready + task1.emit("taskFree", task1.taskId) + + // Wait for queue processing + await new Promise((resolve) => setTimeout(resolve, 100)) + + // Task 1 message should now be processed + expect(task1.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Task 1 Message", undefined) }) }) }) From ed2edcfc926040d33a21e42f30ddef6eb5a5a07b Mon Sep 17 00:00:00 2001 From: cte Date: Thu, 31 Jul 2025 02:40:59 -0700 Subject: [PATCH 3/5] More progress --- src/core/task/Task.ts | 12 +- src/core/tools/attemptCompletionTool.ts | 4 +- src/services/task-bridge/TaskBridgeService.ts | 249 +++-- .../__tests__/TaskBridgeService.test.ts | 872 ------------------ 4 files changed, 133 insertions(+), 1004 deletions(-) delete mode 100644 src/services/task-bridge/__tests__/TaskBridgeService.test.ts diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 28bb291d47..51ab74f887 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -108,8 +108,6 @@ export type TaskEvents = { taskCompleted: [taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage] taskTokenUsageUpdated: [taskId: string, tokenUsage: TokenUsage] taskToolFailed: [taskId: string, tool: ToolName, error: string] - taskBusy: [taskId: string] - taskFree: [taskId: string] } export type TaskEventHandlers = { @@ -712,7 +710,9 @@ export class Task extends EventEmitter { await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) } + console.log(`[Task#${this.taskId}] pWaitFor askResponse...`, type) await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) + console.log(`[Task#${this.taskId}] pWaitFor askResponse done!`, this.askResponse) if (this.lastMessageTs !== askTs) { // Could happen if we send multiple asks in a row i.e. with @@ -1019,7 +1019,7 @@ export class Task extends EventEmitter { const lastClineMessage = this.clineMessages .slice() .reverse() - .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // could be multiple resume tasks + .find((m) => !(m.ask === "resume_task" || m.ask === "resume_completed_task")) // Could be multiple resume tasks. let askType: ClineAsk if (lastClineMessage?.ask === "completion_result") { @@ -1030,9 +1030,11 @@ export class Task extends EventEmitter { this.isInitialized = true - const { response, text, images } = await this.ask(askType) // calls poststatetowebview + const { response, text, images } = await this.ask(askType) // Calls `postStateToWebview`. + let responseText: string | undefined let responseImages: string[] | undefined + if (response === "messageResponse") { await this.say("user_feedback", text, images) responseText = text @@ -1572,7 +1574,6 @@ export class Task extends EventEmitter { let assistantMessage = "" let reasoningMessage = "" this.isStreaming = true - this.emit("taskBusy", this.taskId) try { for await (const chunk of stream) { @@ -1679,7 +1680,6 @@ export class Task extends EventEmitter { } } finally { this.isStreaming = false - this.emit("taskFree", this.taskId) } if (inputTokens > 0 || outputTokens > 0 || cacheWriteTokens > 0 || cacheReadTokens > 0) { diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/attemptCompletionTool.ts index ef7881854f..79f2bee075 100644 --- a/src/core/tools/attemptCompletionTool.ts +++ b/src/core/tools/attemptCompletionTool.ts @@ -41,11 +41,13 @@ export async function attemptCompletionTool( if (preventCompletionWithOpenTodos && hasIncompleteTodos) { cline.consecutiveMistakeCount++ cline.recordToolError("attempt_completion") + pushToolResult( formatResponse.toolError( "Cannot complete task while there are incomplete todos. Please finish all todos before attempting completion.", ), ) + return } @@ -72,7 +74,7 @@ export async function attemptCompletionTool( await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) } } else { - // no command, still outputting partial result + // No command, still outputting partial result await cline.say("completion_result", removeClosingTag("result", result), undefined, block.partial) } return diff --git a/src/services/task-bridge/TaskBridgeService.ts b/src/services/task-bridge/TaskBridgeService.ts index 50a7c04e4e..4eb4a65d72 100644 --- a/src/services/task-bridge/TaskBridgeService.ts +++ b/src/services/task-bridge/TaskBridgeService.ts @@ -19,10 +19,7 @@ const taskBridgeMessageTypes = ["message", "task_event"] as const type TaskBridgeMessageType = (typeof taskBridgeMessageTypes)[number] -const taskBridgeMessagePayloadSchema = z.object({ - eventType: z.string(), - data: z.record(z.string(), z.unknown()), -}) +const taskBridgeMessagePayloadSchema = z.record(z.string(), z.unknown()) type TaskBridgeMessagePayload = z.infer @@ -35,11 +32,13 @@ const taskBridgeMessageSchema = z.object({ export type TaskBridgeMessage = z.infer -export interface QueuedMessage { - text: string - images?: string[] - timestamp: number -} +const queuedMessageSchema = z.object({ + text: z.string(), + images: z.array(z.string()).optional(), + timestamp: z.number(), +}) + +export type QueuedMessage = z.infer interface InternalQueuedMessage { id: string @@ -90,7 +89,30 @@ export class TaskBridgeService { connectionTimeout, commandTimeout, } - this.validateConfig() + + if (!this.config.url) { + throw new Error("[TaskBridgeService] Redis URL is required") + } + + if (!this.config.namespace || this.config.namespace.trim() === "") { + throw new Error("[TaskBridgeService] Namespace is required") + } + + if (this.config.maxReconnectAttempts! < 0) { + throw new Error("[TaskBridgeService] maxReconnectAttempts must be non-negative") + } + + if (this.config.reconnectDelay! < 0) { + throw new Error("[TaskBridgeService] reconnectDelay must be non-negative") + } + + if (this.config.connectionTimeout! < 0) { + throw new Error("[TaskBridgeService] connectionTimeout must be non-negative") + } + + if (this.config.commandTimeout! < 0) { + throw new Error("[TaskBridgeService] commandTimeout must be non-negative") + } } public static getInstance(config?: TaskBridgeConfig) { @@ -164,7 +186,6 @@ export class TaskBridgeService { switch (message.type) { case "message": - console.log(`Received message event for task: ${taskId}`, message.payload) this.handleQueuedMessage(taskId, message.payload) break } @@ -172,10 +193,7 @@ export class TaskBridgeService { console.error("Error handling incoming message:", error) } }) - - // Connect explicitly await Promise.all([this.publisher.connect(), this.subscriber.connect()]) - await Promise.all([this.waitForConnection(this.publisher), this.waitForConnection(this.subscriber)]) this.isConnected = true @@ -183,30 +201,39 @@ export class TaskBridgeService { console.log(`[TaskBridgeService] connected -> ${this.config.url}`) } catch (error) { - // Clean up on failure if (this.publisher) { + console.log(`[TaskBridgeService] disconnecting publisher`) await this.publisher.quit().catch(() => {}) this.publisher = null } + if (this.subscriber) { + console.log(`[TaskBridgeService] disconnecting subscriber`) await this.subscriber.quit().catch(() => {}) this.subscriber = null } + throw error } } public async subscribeToTask(task: Task): Promise { + const channel = this.serverChannel(task.taskId) + console.log(`[TaskBridgeService] subscribeToTask -> ${channel}`) + if (!this.isConnected || !this.subscriber) { throw new Error("TaskBridgeService is not connected") } + await this.subscriber.subscribe(channel) this.subscribedTasks.set(task.taskId, task) - await this.subscriber.subscribe(this.serverChannel(task.taskId)) this.setupTaskEventListeners(task) } public async unsubscribeFromTask(taskId: string): Promise { + const channel = this.serverChannel(taskId) + console.log(`[TaskBridgeService] unsubscribeFromTask -> ${channel}`) + if (!this.subscriber) { return } @@ -218,7 +245,7 @@ export class TaskBridgeService { this.subscribedTasks.delete(taskId) } - await this.subscriber.unsubscribe(this.serverChannel(taskId)) + await this.subscriber.unsubscribe(channel) } public async publish(taskId: string, type: TaskBridgeMessageType, payload: TaskBridgeMessagePayload) { @@ -226,8 +253,6 @@ export class TaskBridgeService { throw new Error("TaskBridgeService is not connected") } - const channel = this.clientChannel(taskId) - const data: TaskBridgeMessage = { taskId, type, @@ -235,38 +260,42 @@ export class TaskBridgeService { timestamp: Date.now(), } - console.log(`[TaskBridgeService] publishing to ${channel}`, data) - await this.publisher.publish(channel, JSON.stringify(data)) + await this.publisher.publish(this.clientChannel(taskId), JSON.stringify(data)) } public async disconnect(): Promise { - // Clear reconnection timeout + console.log(`[TaskBridgeService] disconnecting`) + if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout) this.reconnectTimeout = null } - // Clear all queue processing timeouts for (const timeoutId of this.queueProcessingTimeouts.values()) { clearTimeout(timeoutId) } + this.queueProcessingTimeouts.clear() - // Wait for all processing to complete + // Wait for all processing to complete. const processingPromises = Array.from(this.processingPromises.values()) + if (processingPromises.length > 0) { await Promise.allSettled(processingPromises) } + this.processingPromises.clear() - // Unsubscribe from all tasks + // Unsubscribe from all tasks. const unsubscribePromises = [] + for (const taskId of this.subscribedTasks.keys()) { unsubscribePromises.push(this.unsubscribeFromTask(taskId)) } + await Promise.allSettled(unsubscribePromises) - // Remove event listeners before closing connections + // Remove event listeners before closing connections. if (this.publisher) { this.publisher.removeAllListeners() await this.publisher.quit() @@ -279,13 +308,12 @@ export class TaskBridgeService { this.subscriber = null } - // Clear all internal state + // Clear internal state. this.messageQueues.clear() this.taskStatuses.clear() this.processingQueues.clear() this.subscribedTasks.clear() this.taskEventHandlers = {} - this.isConnected = false TaskBridgeService.instance = null } @@ -325,7 +353,6 @@ export class TaskBridgeService { private handleConnectionError(error?: Error): void { this.isConnected = false - // Log the error for debugging if (error) { console.error("[TaskBridgeService] Connection error:", error.message) } @@ -345,6 +372,7 @@ export class TaskBridgeService { } this.reconnectAttempts++ + console.log( `[TaskBridgeService] Scheduling reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`, ) @@ -362,29 +390,39 @@ export class TaskBridgeService { const callbacks: Partial = { // message: ({ action, message }) => // this.publish(task.taskId, "task_event", { eventType: "message", data: { action, message } }), - taskStarted: () => - this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "started" } }), - taskPaused: () => - this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "paused" } }), - taskUnpaused: () => - this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "unpaused" } }), + taskStarted: () => { + console.log(`[TaskBridgeService#${task.taskId}] taskStarted`) + this.taskStatuses.set(task.taskId, false) + console.log(`[TaskBridgeService#${task.taskId}] busy`) + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "started" } }) + }, + taskUnpaused: () => { + console.log(`[TaskBridgeService#${task.taskId}] taskUnpaused`) + this.taskStatuses.set(task.taskId, false) + console.log(`[TaskBridgeService#${task.taskId}] busy`) + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "unpaused" } }) + }, + taskPaused: () => { + console.log(`[TaskBridgeService#${task.taskId}] taskPaused`) + this.taskStatuses.set(task.taskId, true) + console.log(`[TaskBridgeService#${task.taskId}] free`) + this.processQueueForTask(task.taskId) + this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "paused" } }) + }, taskAborted: () => { - this.cleanupTaskQueue(task.taskId) + console.log(`[TaskBridgeService#${task.taskId}] taskAborted`) + this.taskStatuses.set(task.taskId, true) + this.processQueueForTask(task.taskId) + console.log(`[TaskBridgeService#${task.taskId}] free`) this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "aborted" } }) }, taskCompleted: () => { - this.cleanupTaskQueue(task.taskId) + console.log(`[TaskBridgeService#${task.taskId}] taskCompleted`) + this.taskStatuses.set(task.taskId, true) + console.log(`[TaskBridgeService#${task.taskId}] free`) + this.processQueueForTask(task.taskId) this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "completed" } }) }, - taskBusy: (taskId: string) => { - this.taskStatuses.set(taskId, false) - this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "busy" } }) - }, - taskFree: (taskId: string) => { - this.taskStatuses.set(taskId, true) - this.processQueueForTask(taskId) - this.publish(task.taskId, "task_event", { eventType: "status", data: { status: "free" } }) - }, } this.taskEventHandlers[task.taskId] = callbacks @@ -420,24 +458,22 @@ export class TaskBridgeService { return this.taskStatuses.get(taskId) ?? false } - private handleQueuedMessage(taskId: string, payload: TaskBridgeMessagePayload): void { - const messageData = payload.data as { text?: string; images?: string[] } - - if (!messageData.text) { - console.warn(`Received queued message without text for task: ${taskId}`) - return - } - - const queuedMessage: QueuedMessage = { - text: messageData.text, - images: messageData.images, - timestamp: Date.now(), - } - - if (this.isTaskReady(taskId)) { - this.deliverMessage(taskId, queuedMessage) - } else { - this.enqueueMessage(taskId, queuedMessage) + private handleQueuedMessage(taskId: string, payload: TaskBridgeMessagePayload) { + try { + console.log(`[TaskBridgeService#${taskId}] handleQueuedMessage`, payload) + const queuedMessage = queuedMessageSchema.parse(payload) + const isReady = this.isTaskReady(taskId) + console.log(`[TaskBridgeService#${taskId}] Task ready status: ${isReady}`) + + if (isReady) { + console.log(`[TaskBridgeService#${taskId}] Task is ready, delivering message immediately`) + this.deliverMessage(taskId, queuedMessage) + } else { + console.log(`[TaskBridgeService#${taskId}] Task is busy, enqueuing message`) + this.enqueueMessage(taskId, queuedMessage) + } + } catch (error) { + console.error(`[TaskBridgeService#${taskId}] Error handling queued message:`, error, payload) } } @@ -458,6 +494,7 @@ export class TaskBridgeService { } private async deliverMessage(taskId: string, message: QueuedMessage): Promise { + console.log(`[TaskBridgeService#${taskId}] deliverMessage: ${message.text}`) const task = this.subscribedTasks.get(taskId) if (!task) { @@ -465,6 +502,9 @@ export class TaskBridgeService { return false } + this.taskStatuses.set(taskId, false) + console.log(`[TaskBridgeService#${taskId}] busy`) + try { await task.handleWebviewAskResponse("messageResponse", message.text, message.images) return true @@ -475,10 +515,14 @@ export class TaskBridgeService { } private async processQueueForTask(taskId: string): Promise { + console.log(`[TaskBridgeService#${taskId}] processQueueForTask`) + // Check if there's already a processing promise for this task. const existingPromise = this.processingPromises.get(taskId) if (existingPromise) { + console.log(`[TaskBridgeService#${taskId}] waiting for existing processing to complete`) + // Wait for the existing processing to complete. await existingPromise @@ -487,6 +531,7 @@ export class TaskBridgeService { if (this.messageQueues.has(taskId) && this.isTaskReady(taskId)) { return this.processQueueForTask(taskId) } + return } @@ -494,25 +539,29 @@ export class TaskBridgeService { const queue = this.messageQueues.get(taskId) if (!queue || queue.length === 0) { + console.log(`[TaskBridgeService#${taskId}] processQueueForTask - no queued message`) return } - // Create and store the processing promise. - const processingPromise = this._processQueue(taskId) - this.processingPromises.set(taskId, processingPromise) + setTimeout(async () => { + // Create and store the processing promise. + const processingPromise = this._processQueue(taskId) + this.processingPromises.set(taskId, processingPromise) - try { - await processingPromise - } finally { - // Clean up the promise reference. - this.processingPromises.delete(taskId) - } + try { + await processingPromise + } finally { + // Clean up the promise reference. + this.processingPromises.delete(taskId) + } + }, 500) } private async _processQueue(taskId: string): Promise { const queue = this.messageQueues.get(taskId) if (!queue || queue.length === 0) { + console.log(`[TaskBridgeService#${taskId}] _processQueue - no queued messages`) return } @@ -521,7 +570,7 @@ export class TaskBridgeService { try { while (queue.length > 0 && this.isTaskReady(taskId)) { const queuedMessage = queue[0] - console.log(`Processing queued message for task ${taskId}. Remaining: ${queue.length}`) + console.log(`Processing queued message for task ${taskId}: ${JSON.stringify(queuedMessage)}`) const success = await this.deliverMessage(taskId, queuedMessage.message) if (success) { @@ -561,57 +610,6 @@ export class TaskBridgeService { } } - private cleanupTaskQueue(taskId: string): void { - const timeoutId = this.queueProcessingTimeouts.get(taskId) - - if (timeoutId) { - clearTimeout(timeoutId) - this.queueProcessingTimeouts.delete(taskId) - } - - // Wait for any ongoing processing to complete before cleaning up.. - const processingPromise = this.processingPromises.get(taskId) - - if (processingPromise) { - // Don't await here to avoid blocking, just delete the reference. - this.processingPromises.delete(taskId) - } - - this.processingQueues.delete(taskId) - this.messageQueues.delete(taskId) - this.taskStatuses.delete(taskId) - console.log(`Cleaned up queue for task ${taskId}`) - } - - private validateConfig(): void { - if (!this.config.url) { - throw new Error("[TaskBridgeService] Redis URL is required") - } - - if (!this.config.namespace || this.config.namespace.trim() === "") { - throw new Error("[TaskBridgeService] Namespace is required") - } - - if (this.config.maxReconnectAttempts! < 0) { - throw new Error("[TaskBridgeService] maxReconnectAttempts must be non-negative") - } - - if (this.config.reconnectDelay! < 0) { - throw new Error("[TaskBridgeService] reconnectDelay must be non-negative") - } - - if (this.config.connectionTimeout! < 0) { - throw new Error("[TaskBridgeService] connectionTimeout must be non-negative") - } - - if (this.config.commandTimeout! < 0) { - throw new Error("[TaskBridgeService] commandTimeout must be non-negative") - } - } - - /** - * Get the current connection status - */ public getStatus(): { connected: boolean reconnectAttempts: number @@ -620,6 +618,7 @@ export class TaskBridgeService { processingTasks: number } { let totalQueuedMessages = 0 + for (const queue of this.messageQueues.values()) { totalQueuedMessages += queue.length } diff --git a/src/services/task-bridge/__tests__/TaskBridgeService.test.ts b/src/services/task-bridge/__tests__/TaskBridgeService.test.ts deleted file mode 100644 index 3c2d138c10..0000000000 --- a/src/services/task-bridge/__tests__/TaskBridgeService.test.ts +++ /dev/null @@ -1,872 +0,0 @@ -// npx vitest services/task-bridge/__tests__/TaskBridgeService.test.ts - -import { EventEmitter } from "events" -import { vi, describe, it, expect, beforeEach, afterEach, Mock } from "vitest" -import type { ClineMessage } from "@roo-code/types" -import RedisMock from "ioredis-mock" - -import { TaskBridgeService } from "../TaskBridgeService" -import type { Task } from "../../../core/task/Task" - -// Mock ioredis with ioredis-mock -vi.mock("ioredis", () => ({ - default: RedisMock, -})) - -// Enhanced MockTask class with all properties needed for message queuing tests -class MockTask extends EventEmitter { - taskId: string - isStreaming: boolean = false - isPaused: boolean = false - isWaitingForResponse: boolean = false - handleWebviewAskResponse: Mock - - constructor(taskId: string) { - super() - this.taskId = taskId - this.handleWebviewAskResponse = vi.fn() - } -} - -describe("TaskBridgeService", () => { - let taskBridge: TaskBridgeService - let mockTask: MockTask - - beforeEach(() => { - // Clear the singleton instance - ;(TaskBridgeService as any).instance = null - - taskBridge = TaskBridgeService.getInstance({ - url: "redis://localhost:6379", - namespace: "test:task-bridge", - }) - - mockTask = new MockTask("test-task-123") - }) - - afterEach(async () => { - if (taskBridge.connected) { - await taskBridge.disconnect() - } - - // Reset singleton instance - TaskBridgeService.resetInstance() - vi.clearAllMocks() - }) - - describe("Singleton Pattern", () => { - it("should return the same instance", () => { - const instance1 = TaskBridgeService.getInstance() - const instance2 = TaskBridgeService.getInstance() - expect(instance1).toBe(instance2) - }) - }) - - describe("Connection Management", () => { - it("should initialize Redis connections", async () => { - await taskBridge.initialize() - expect(taskBridge.connected).toBe(true) - }) - - it("should not reinitialize if already connected", async () => { - await taskBridge.initialize() - const firstPublisher = (taskBridge as any).publisher - - await taskBridge.initialize() - const secondPublisher = (taskBridge as any).publisher - - expect(firstPublisher).toBe(secondPublisher) - }) - - it("should disconnect properly", async () => { - await taskBridge.initialize() - await taskBridge.disconnect() - - expect(taskBridge.connected).toBe(false) - expect((taskBridge as any).publisher).toBeNull() - expect((taskBridge as any).subscriber).toBeNull() - }) - }) - - describe("Task Subscription", () => { - beforeEach(async () => { - await taskBridge.initialize() - }) - - it("should subscribe to task channels", async () => { - await taskBridge.subscribeToTask(mockTask as unknown as Task) - expect(taskBridge.subscribedTaskCount).toBe(1) - }) - - it("should throw error if not connected", async () => { - await taskBridge.disconnect() - - await expect(taskBridge.subscribeToTask(mockTask as unknown as Task)).rejects.toThrow( - "TaskBridgeService is not connected", - ) - }) - - it("should unsubscribe from task channels", async () => { - await taskBridge.subscribeToTask(mockTask as unknown as Task) - await taskBridge.unsubscribeFromTask(mockTask.taskId) - - expect(taskBridge.subscribedTaskCount).toBe(0) - }) - }) - - describe("Task Event Forwarding", () => { - beforeEach(async () => { - await taskBridge.initialize() - await taskBridge.subscribeToTask(mockTask as unknown as Task) - }) - - it("should forward message events", async () => { - const publisher = (taskBridge as any).publisher - const publishSpy = vi.spyOn(publisher, "publish") - - const message: ClineMessage = { - ts: Date.now(), - type: "say", - say: "text", - text: "Test message", - } - - // Note: The TaskBridgeService doesn't actually forward message events - // It only forwards task status events. This test is kept for reference - // but the expectation is that message events are NOT forwarded - mockTask.emit("message", { action: "created", message }) - - // Wait for async operations - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Verify that message events are not forwarded - expect(publishSpy).not.toHaveBeenCalledWith( - "test:task-bridge:test-task-123:client", - expect.stringContaining('"eventType":"message"'), - ) - }) - - it("should forward task status events", async () => { - const publisher = (taskBridge as any).publisher - const publishSpy = vi.spyOn(publisher, "publish") - - const events = ["taskStarted", "taskPaused", "taskUnpaused", "taskAborted"] - - for (const event of events) { - mockTask.emit(event as any) - } - - // Wait for async publishes - await new Promise((resolve) => setTimeout(resolve, 100)) - - expect(publishSpy).toHaveBeenCalledTimes(events.length) - - // Verify each event was forwarded correctly - const statuses = publishSpy.mock.calls.map((call) => { - const parsed = JSON.parse(call[1] as string) - return parsed.payload.data.status - }) - - expect(statuses).toContain("started") - expect(statuses).toContain("paused") - expect(statuses).toContain("unpaused") - expect(statuses).toContain("aborted") - }) - }) - - describe("Message Handling", () => { - beforeEach(async () => { - await taskBridge.initialize() - await taskBridge.subscribeToTask(mockTask as unknown as Task) - }) - - it("should handle incoming messages", async () => { - // Setup task as ready - mockTask.emit("taskFree", mockTask.taskId) - await new Promise((resolve) => setTimeout(resolve, 50)) - - const message = { - taskId: "test-task-123", - type: "message", - payload: { - eventType: "message", - data: { - text: "Test message", - images: ["test.png"], - }, - }, - timestamp: Date.now(), - } - - // Simulate receiving a message on the server channel - const subscriber = (taskBridge as any).subscriber - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) - - // Wait for message to be processed - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Verify the message was delivered to the task - expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Test message", [ - "test.png", - ]) - }) - - it("should ignore messages for unsubscribed tasks", async () => { - const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) - - // Manually subscribe the subscriber to the channel (simulating a lingering subscription) - const subscriber = (taskBridge as any).subscriber - await subscriber.subscribe("test:task-bridge:unsubscribed-task-123:server") - - const message = { - taskId: "unsubscribed-task-123", - type: "message", - payload: { - eventType: "message", - data: { - text: "Test message", - }, - }, - timestamp: Date.now(), - } - - // Simulate receiving a message for an unsubscribed task - subscriber.emit("message", "test:task-bridge:unsubscribed-task-123:server", JSON.stringify(message)) - - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(consoleWarnSpy).toHaveBeenCalledWith("Received message for unsubscribed task: unsubscribed-task-123") - - // Clean up - await subscriber.unsubscribe("test:task-bridge:unsubscribed-task-123:server") - consoleWarnSpy.mockRestore() - }) - }) - - describe("Error Handling", () => { - it("should handle malformed messages gracefully", async () => { - await taskBridge.initialize() - await taskBridge.subscribeToTask(mockTask as unknown as Task) - - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - - // Simulate receiving invalid JSON - const subscriber = (taskBridge as any).subscriber - subscriber.emit("message", "test:task-bridge:test-task-123:server", "invalid json") - - await new Promise((resolve) => setTimeout(resolve, 50)) - - expect(consoleErrorSpy).toHaveBeenCalledWith("Error handling incoming message:", expect.any(Error)) - - consoleErrorSpy.mockRestore() - }) - }) - - describe("Message Queuing", () => { - beforeEach(async () => { - await taskBridge.initialize() - }) - - it("should queue messages when task is not ready", async () => { - // Setup task as not ready (streaming) - mockTask.isStreaming = true - mockTask.isWaitingForResponse = false - - await taskBridge.subscribeToTask(mockTask as any) - - // Send a message through Redis - const message = { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { - text: "Test message", - images: ["image1.png"], - }, - }, - timestamp: Date.now(), - } - - const subscriber = (taskBridge as any).subscriber - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) - - // Wait for message processing - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Verify message was not sent immediately (task is not ready) - expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() - }) - - it("should send messages directly when task is ready", async () => { - await taskBridge.subscribeToTask(mockTask as any) - - // Emit taskFree event to indicate task is ready - mockTask.emit("taskFree", mockTask.taskId) - - // Wait for event processing - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Send a message through Redis - const message = { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { - text: "Test message", - images: ["image1.png"], - }, - }, - timestamp: Date.now(), - } - - const subscriber = (taskBridge as any).subscriber - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) - - // Wait for message processing - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Verify message was sent immediately - expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Test message", [ - "image1.png", - ]) - }) - - it("should process queued messages when task becomes ready", async () => { - // Setup task as not ready initially - mockTask.isStreaming = true - mockTask.isWaitingForResponse = false - - await taskBridge.subscribeToTask(mockTask as any) - - // Send multiple messages through Redis - const messages = [ - { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { text: "Message 1" }, - }, - timestamp: Date.now(), - }, - { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { text: "Message 2", images: ["image2.png"] }, - }, - timestamp: Date.now() + 1, - }, - { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { text: "Message 3" }, - }, - timestamp: Date.now() + 2, - }, - ] - - const subscriber = (taskBridge as any).subscriber - for (const msg of messages) { - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(msg)) - await new Promise((resolve) => setTimeout(resolve, 10)) - } - - // Verify messages were not sent (task not ready) - expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() - - // Make task ready by emitting taskFree - mockTask.emit("taskFree", mockTask.taskId) - - // Wait for queue processing - await new Promise((resolve) => setTimeout(resolve, 200)) - - // Verify all messages were sent in order - expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledTimes(3) - expect(mockTask.handleWebviewAskResponse).toHaveBeenNthCalledWith( - 1, - "messageResponse", - "Message 1", - undefined, - ) - expect(mockTask.handleWebviewAskResponse).toHaveBeenNthCalledWith(2, "messageResponse", "Message 2", [ - "image2.png", - ]) - expect(mockTask.handleWebviewAskResponse).toHaveBeenNthCalledWith( - 3, - "messageResponse", - "Message 3", - undefined, - ) - }) - }) - - describe("Error Handling and Retry Logic", () => { - beforeEach(async () => { - await taskBridge.initialize() - }) - - it("should retry failed messages with exponential backoff", async () => { - // Make handleWebviewAskResponse fail initially - let callCount = 0 - mockTask.handleWebviewAskResponse.mockImplementation(() => { - callCount++ - if (callCount <= 2) { - return Promise.reject(new Error(`Attempt ${callCount} failed`)) - } - return Promise.resolve(undefined) - }) - - await taskBridge.subscribeToTask(mockTask as any) - - // Send a message while task is not ready - const message = { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { - text: "Test message", - }, - }, - timestamp: Date.now(), - } - - const subscriber = (taskBridge as any).subscriber - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) - - // Wait a bit - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Make task ready to trigger processing - mockTask.emit("taskFree", mockTask.taskId) - - // Wait for initial attempt - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(callCount).toBe(1) - - // Wait for first retry (1 second) - await new Promise((resolve) => setTimeout(resolve, 1100)) - expect(callCount).toBe(2) - - // Wait for second retry (2 seconds) - await new Promise((resolve) => setTimeout(resolve, 2100)) - expect(callCount).toBe(3) - - // Message should have succeeded on third attempt - expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledTimes(3) - }, 5000) - - it("should remove messages after max retries", async () => { - // Make handleWebviewAskResponse always fail - let callCount = 0 - mockTask.handleWebviewAskResponse.mockImplementation(() => { - callCount++ - return Promise.reject(new Error("Always fails")) - }) - - await taskBridge.subscribeToTask(mockTask as any) - - // Send a message while task is not ready - const message = { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { - text: "Test message", - }, - }, - timestamp: Date.now(), - } - - const subscriber = (taskBridge as any).subscriber - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) - - // Wait a bit - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Make task ready to trigger processing - mockTask.emit("taskFree", mockTask.taskId) - - // Wait for initial attempt - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(callCount).toBe(1) - - // Wait for first retry (1s) - await new Promise((resolve) => setTimeout(resolve, 1100)) - expect(callCount).toBe(2) - - // Wait for second retry (2s) - await new Promise((resolve) => setTimeout(resolve, 2100)) - expect(callCount).toBe(3) - - // Should have attempted 3 times total - expect(callCount).toBe(3) - - // The message should be removed after max retries - // Send another message to verify the queue is working - mockTask.handleWebviewAskResponse.mockResolvedValue(undefined) - - const newMessage = { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { - text: "New message", - }, - }, - timestamp: Date.now(), - } - - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(newMessage)) - await new Promise((resolve) => setTimeout(resolve, 100)) - - // The new message should be delivered immediately (task is ready) - expect(mockTask.handleWebviewAskResponse).toHaveBeenLastCalledWith( - "messageResponse", - "New message", - undefined, - ) - }, 10000) // Increase timeout for this test - }) - - describe("Task State Management", () => { - beforeEach(async () => { - await taskBridge.initialize() - }) - - it("should track task state changes through events", async () => { - await taskBridge.subscribeToTask(mockTask as any) - - // Send a message while task is not ready - const message = { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { - text: "Test message", - }, - }, - timestamp: Date.now(), - } - - const subscriber = (taskBridge as any).subscriber - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) - - // Wait a bit - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Message should not be processed yet - expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() - - // Emit taskFree event to indicate task is ready - mockTask.emit("taskFree", mockTask.taskId) - - // Wait for async processing - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Message should have been processed - expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Test message", undefined) - }) - - it("should not process messages when task is paused", async () => { - await taskBridge.subscribeToTask(mockTask as any) - - // Pause the task - mockTask.isPaused = true - mockTask.emit("taskPaused") - - // Send messages - const messages = [ - { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { text: "Message 1" }, - }, - timestamp: Date.now(), - }, - { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { text: "Message 2" }, - }, - timestamp: Date.now() + 1, - }, - ] - - const subscriber = (taskBridge as any).subscriber - for (const msg of messages) { - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(msg)) - } - - // Wait a bit - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Messages should not be processed (task is paused) - expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() - - // Unpause and make task ready - mockTask.isPaused = false - mockTask.emit("taskUnpaused") - mockTask.emit("taskFree", mockTask.taskId) - - // Wait for processing - await new Promise((resolve) => setTimeout(resolve, 200)) - - // Messages should now be processed - expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledTimes(2) - }) - - it("should clean up queue when task is aborted", async () => { - await taskBridge.subscribeToTask(mockTask as any) - - // Send messages - const messages = [ - { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { text: "Message 1" }, - }, - timestamp: Date.now(), - }, - { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { text: "Message 2" }, - }, - timestamp: Date.now() + 1, - }, - ] - - const subscriber = (taskBridge as any).subscriber - for (const msg of messages) { - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(msg)) - } - - // Wait a bit - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Abort the task - mockTask.emit("taskAborted") - - // Wait for cleanup - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Messages should not be processed - expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() - - // Try to make task ready - messages should still not be processed (queue was cleared) - mockTask.emit("taskFree", mockTask.taskId) - await new Promise((resolve) => setTimeout(resolve, 100)) - expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() - }) - - it("should clean up queue when task is completed", async () => { - await taskBridge.subscribeToTask(mockTask as any) - - // Send a message - const message = { - taskId: mockTask.taskId, - type: "message", - payload: { - eventType: "message", - data: { - text: "Message 1", - }, - }, - timestamp: Date.now(), - } - - const subscriber = (taskBridge as any).subscriber - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(message)) - - // Wait a bit - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Complete the task - mockTask.emit("taskCompleted") - - // Wait for cleanup - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Message should not be processed - expect(mockTask.handleWebviewAskResponse).not.toHaveBeenCalled() - }) - }) - - describe("Race Condition Prevention", () => { - beforeEach(async () => { - await taskBridge.initialize() - }) - - it("should handle concurrent processQueueForTask calls without race conditions", async () => { - await taskBridge.subscribeToTask(mockTask as any) - - // Send multiple messages while task is not ready - const messages = Array.from({ length: 5 }, (_, i) => ({ - taskId: mockTask.taskId, - type: "message" as const, - payload: { - eventType: "message", - data: { text: `Message ${i + 1}` }, - }, - timestamp: Date.now() + i, - })) - - const subscriber = (taskBridge as any).subscriber - for (const msg of messages) { - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(msg)) - } - - // Wait for messages to be queued - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Simulate multiple concurrent taskFree events - // This would previously cause race conditions - const promises = Array.from({ length: 3 }, () => { - mockTask.emit("taskFree", mockTask.taskId) - return new Promise((resolve) => setTimeout(resolve, 10)) - }) - - await Promise.all(promises) - - // Wait for all processing to complete - await new Promise((resolve) => setTimeout(resolve, 200)) - - // All messages should be processed exactly once - expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledTimes(5) - for (let i = 0; i < 5; i++) { - expect(mockTask.handleWebviewAskResponse).toHaveBeenNthCalledWith( - i + 1, - "messageResponse", - `Message ${i + 1}`, - undefined, - ) - } - }) - - it("should not lose messages when task state changes rapidly", async () => { - await taskBridge.subscribeToTask(mockTask as any) - - // Send messages - const messages = Array.from({ length: 3 }, (_, i) => ({ - taskId: mockTask.taskId, - type: "message" as const, - payload: { - eventType: "message", - data: { text: `Message ${i + 1}` }, - }, - timestamp: Date.now() + i, - })) - - const subscriber = (taskBridge as any).subscriber - for (const msg of messages) { - subscriber.emit("message", "test:task-bridge:test-task-123:server", JSON.stringify(msg)) - } - - // Rapidly toggle task state - mockTask.emit("taskFree", mockTask.taskId) - await new Promise((resolve) => setTimeout(resolve, 5)) - - mockTask.emit("taskBusy", mockTask.taskId) - await new Promise((resolve) => setTimeout(resolve, 5)) - - mockTask.emit("taskFree", mockTask.taskId) - await new Promise((resolve) => setTimeout(resolve, 5)) - - mockTask.emit("taskBusy", mockTask.taskId) - await new Promise((resolve) => setTimeout(resolve, 5)) - - mockTask.emit("taskFree", mockTask.taskId) - - // Wait for processing to complete - await new Promise((resolve) => setTimeout(resolve, 200)) - - // All messages should still be processed - expect(mockTask.handleWebviewAskResponse).toHaveBeenCalledTimes(3) - }) - }) - - describe("Multiple Tasks", () => { - beforeEach(async () => { - await taskBridge.initialize() - }) - - it("should maintain separate queues for different tasks", async () => { - const task1 = new MockTask("task-1") - const task2 = new MockTask("task-2") - - await taskBridge.subscribeToTask(task1 as any) - await taskBridge.subscribeToTask(task2 as any) - - // Task 2 is ready (emit taskFree) - task2.emit("taskFree", task2.taskId) - - // Wait for event processing - await new Promise((resolve) => setTimeout(resolve, 50)) - - // Send messages for both tasks through Redis - const message1 = { - taskId: task1.taskId, - type: "message", - payload: { - eventType: "message", - data: { - text: "Task 1 Message", - }, - }, - timestamp: Date.now(), - } - - const message2 = { - taskId: task2.taskId, - type: "message", - payload: { - eventType: "message", - data: { - text: "Task 2 Message", - }, - }, - timestamp: Date.now(), - } - - const subscriber = (taskBridge as any).subscriber - subscriber.emit("message", "test:task-bridge:task-1:server", JSON.stringify(message1)) - subscriber.emit("message", "test:task-bridge:task-2:server", JSON.stringify(message2)) - - // Wait for message processing - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Task 1 message should not be processed (no taskFree event) - expect(task1.handleWebviewAskResponse).not.toHaveBeenCalled() - - // Task 2 message should be sent immediately - expect(task2.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Task 2 Message", undefined) - - // Now make task 1 ready - task1.emit("taskFree", task1.taskId) - - // Wait for queue processing - await new Promise((resolve) => setTimeout(resolve, 100)) - - // Task 1 message should now be processed - expect(task1.handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "Task 1 Message", undefined) - }) - }) -}) From b58d66eb36b323819aa878e7bd60ced747ed9e96 Mon Sep 17 00:00:00 2001 From: cte Date: Thu, 31 Jul 2025 12:47:08 -0700 Subject: [PATCH 4/5] Move some things around --- packages/cloud/package.json | 3 + packages/cloud/src/CloudAPI.ts | 141 +++++++ packages/cloud/src/CloudService.ts | 25 +- packages/cloud/src/CloudSettingsService.ts | 2 +- packages/cloud/src/CloudShareService.ts | 43 +++ packages/cloud/src/Config.ts | 2 - packages/cloud/src/ShareService.ts | 88 ----- packages/cloud/src/StaticSettingsService.ts | 2 +- .../cloud/src}/TaskBridgeService.ts | 39 +- packages/cloud/src/TelemetryClient.ts | 5 +- packages/cloud/src/__tests__/CloudAPI.test.ts | 359 ++++++++++++++++++ .../cloud/src/__tests__/CloudService.test.ts | 10 +- ...vice.test.ts => CloudShareService.test.ts} | 40 +- .../src/__tests__/auth/WebAuthService.spec.ts | 2 +- packages/cloud/src/auth/AuthService.ts | 1 + .../cloud/src/auth/StaticTokenAuthService.ts | 3 + packages/cloud/src/auth/WebAuthService.ts | 26 +- packages/cloud/src/errors.ts | 42 ++ packages/cloud/src/index.ts | 5 +- packages/cloud/src/utils.ts | 5 - packages/types/src/cloud.ts | 11 + packages/types/src/index.ts | 9 +- packages/types/src/task.ts | 28 ++ pnpm-lock.yaml | 18 +- src/core/task/Task.ts | 33 +- src/package.json | 3 - src/services/task-bridge/index.ts | 6 - 27 files changed, 760 insertions(+), 191 deletions(-) create mode 100644 packages/cloud/src/CloudAPI.ts create mode 100644 packages/cloud/src/CloudShareService.ts delete mode 100644 packages/cloud/src/ShareService.ts rename {src/services/task-bridge => packages/cloud/src}/TaskBridgeService.ts (94%) create mode 100644 packages/cloud/src/__tests__/CloudAPI.test.ts rename packages/cloud/src/__tests__/{ShareService.test.ts => CloudShareService.test.ts} (86%) create mode 100644 packages/cloud/src/errors.ts create mode 100644 packages/types/src/task.ts delete mode 100644 src/services/task-bridge/index.ts diff --git a/packages/cloud/package.json b/packages/cloud/package.json index d67b5ae7eb..2737312192 100644 --- a/packages/cloud/package.json +++ b/packages/cloud/package.json @@ -13,13 +13,16 @@ "dependencies": { "@roo-code/telemetry": "workspace:^", "@roo-code/types": "workspace:^", + "ioredis": "^5.3.2", "zod": "^3.25.61" }, "devDependencies": { "@roo-code/config-eslint": "workspace:^", "@roo-code/config-typescript": "workspace:^", + "@types/ioredis-mock": "^8.2.6", "@types/node": "20.x", "@types/vscode": "^1.84.0", + "ioredis-mock": "^8.9.0", "vitest": "^3.2.3" } } diff --git a/packages/cloud/src/CloudAPI.ts b/packages/cloud/src/CloudAPI.ts new file mode 100644 index 0000000000..58c5507ad5 --- /dev/null +++ b/packages/cloud/src/CloudAPI.ts @@ -0,0 +1,141 @@ +import { + type ShareVisibility, + type ShareResponse, + shareResponseSchema, + type TaskBridgeRegisterResponse, + taskBridgeRegisterResponseSchema, +} from "@roo-code/types" + +import { getRooCodeApiUrl } from "./config" +import type { AuthService } from "./auth" +import { getUserAgent } from "./utils" +import { AuthenticationError, CloudAPIError, NetworkError, TaskNotFoundError } from "./errors" + +interface CloudAPIRequestOptions extends Omit { + timeout?: number + headers?: Record +} + +export class CloudAPI { + private authService: AuthService + private log: (...args: unknown[]) => void + private baseUrl: string + + constructor(authService: AuthService, log?: (...args: unknown[]) => void) { + this.authService = authService + this.log = log || console.log + this.baseUrl = getRooCodeApiUrl() + } + + private async request( + endpoint: string, + options: CloudAPIRequestOptions & { + parseResponse?: (data: unknown) => T + } = {}, + ): Promise { + const { timeout = 10000, parseResponse, headers = {}, ...fetchOptions } = options + + const sessionToken = this.authService.getSessionToken() + + if (!sessionToken) { + throw new AuthenticationError() + } + + const url = `${this.baseUrl}${endpoint}` + + const requestHeaders = { + "Content-Type": "application/json", + Authorization: `Bearer ${sessionToken}`, + "User-Agent": getUserAgent(), + ...headers, + } + + try { + const response = await fetch(url, { + ...fetchOptions, + headers: requestHeaders, + signal: AbortSignal.timeout(timeout), + }) + + if (!response.ok) { + await this.handleErrorResponse(response, endpoint) + } + + const data = await response.json() + + if (parseResponse) { + return parseResponse(data) + } + + return data as T + } catch (error) { + if (error instanceof TypeError && error.message.includes("fetch")) { + throw new NetworkError(`Network error while calling ${endpoint}`) + } + + if (error instanceof CloudAPIError) { + throw error + } + + if (error instanceof Error && error.name === "AbortError") { + throw new CloudAPIError(`Request to ${endpoint} timed out`, undefined, undefined) + } + + throw new CloudAPIError( + `Unexpected error while calling ${endpoint}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + private async handleErrorResponse(response: Response, endpoint: string): Promise { + let responseBody: unknown + + try { + responseBody = await response.json() + } catch { + responseBody = await response.text() + } + + switch (response.status) { + case 401: + throw new AuthenticationError() + case 404: + if (endpoint.includes("/share")) { + throw new TaskNotFoundError() + } + throw new CloudAPIError(`Resource not found: ${endpoint}`, 404, responseBody) + default: + throw new CloudAPIError( + `HTTP ${response.status}: ${response.statusText}`, + response.status, + responseBody, + ) + } + } + + async shareTask(taskId: string, visibility: ShareVisibility = "organization"): Promise { + this.log(`[CloudAPI] Sharing task ${taskId} with visibility: ${visibility}`) + + const response = await this.request("/api/extension/share", { + method: "POST", + body: JSON.stringify({ taskId, visibility }), + parseResponse: (data) => shareResponseSchema.parse(data), + }) + + this.log("[CloudAPI] Share response:", response) + return response + } + + async registerTaskBridge(taskId: string, bridgeUrl?: string): Promise { + this.log(`[CloudAPI] Registering task bridge for ${taskId}`, bridgeUrl ? `with URL: ${bridgeUrl}` : "") + + const response = await this.request(`/api/extension/tasks/${taskId}/register-bridge`, { + method: "POST", + body: JSON.stringify({ taskId, bridgeUrl }), + parseResponse: (data) => taskBridgeRegisterResponseSchema.parse(data), + }) + + this.log("[CloudAPI] Task bridge registration response:", response) + return response + } +} diff --git a/packages/cloud/src/CloudService.ts b/packages/cloud/src/CloudService.ts index 30d1545b23..48f637a0d1 100644 --- a/packages/cloud/src/CloudService.ts +++ b/packages/cloud/src/CloudService.ts @@ -7,17 +7,20 @@ import type { OrganizationSettings, ClineMessage, ShareVisibility, + TaskBridgeRegisterResponse, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudServiceCallbacks } from "./types" -import type { AuthService } from "./auth" -import { WebAuthService, StaticTokenAuthService } from "./auth" +import { type AuthService, WebAuthService, StaticTokenAuthService } from "./auth" +import { TaskNotFoundError } from "./errors" + import type { SettingsService } from "./SettingsService" import { CloudSettingsService } from "./CloudSettingsService" import { StaticSettingsService } from "./StaticSettingsService" import { TelemetryClient } from "./TelemetryClient" -import { ShareService, TaskNotFoundError } from "./ShareService" +import { CloudShareService } from "./CloudShareService" +import { CloudAPI } from "./CloudAPI" export class CloudService { private static _instance: CloudService | null = null @@ -28,7 +31,8 @@ export class CloudService { private authService: AuthService | null = null private settingsService: SettingsService | null = null private telemetryClient: TelemetryClient | null = null - private shareService: ShareService | null = null + private shareService: CloudShareService | null = null + private cloudAPI: CloudAPI | null = null private isInitialized = false private log: (...args: unknown[]) => void @@ -80,8 +84,9 @@ export class CloudService { this.settingsService = cloudSettingsService } + this.cloudAPI = new CloudAPI(this.authService, this.log) this.telemetryClient = new TelemetryClient(this.authService, this.settingsService) - this.shareService = new ShareService(this.authService, this.settingsService, this.log) + this.shareService = new CloudShareService(this.cloudAPI, this.settingsService, this.log) try { TelemetryService.instance.register(this.telemetryClient) @@ -202,7 +207,7 @@ export class CloudService { return await this.shareService!.shareTask(taskId, visibility) } catch (error) { if (error instanceof TaskNotFoundError && clineMessages) { - // Backfill messages and retry + // Backfill messages and retry. await this.telemetryClient!.backfillMessages(clineMessages, taskId) return await this.shareService!.shareTask(taskId, visibility) } @@ -215,6 +220,13 @@ export class CloudService { return this.shareService!.canShareTask() } + // Task Bridge + + public async registerTaskBridge(taskId: string, bridgeUrl?: string): Promise { + this.ensureInitialized() + return this.cloudAPI!.registerTaskBridge(taskId, bridgeUrl) + } + // Lifecycle public dispose(): void { @@ -225,6 +237,7 @@ export class CloudService { this.authService.off("logged-out", this.authListener) this.authService.off("user-info", this.authListener) } + if (this.settingsService) { this.settingsService.dispose() } diff --git a/packages/cloud/src/CloudSettingsService.ts b/packages/cloud/src/CloudSettingsService.ts index 6692d8141d..4d856f4df2 100644 --- a/packages/cloud/src/CloudSettingsService.ts +++ b/packages/cloud/src/CloudSettingsService.ts @@ -7,7 +7,7 @@ import { organizationSettingsSchema, } from "@roo-code/types" -import { getRooCodeApiUrl } from "./Config" +import { getRooCodeApiUrl } from "./config" import type { AuthService } from "./auth" import { RefreshTimer } from "./RefreshTimer" import type { SettingsService } from "./SettingsService" diff --git a/packages/cloud/src/CloudShareService.ts b/packages/cloud/src/CloudShareService.ts new file mode 100644 index 0000000000..91e0f6aa3f --- /dev/null +++ b/packages/cloud/src/CloudShareService.ts @@ -0,0 +1,43 @@ +import * as vscode from "vscode" + +import type { ShareResponse, ShareVisibility } from "@roo-code/types" + +import type { CloudAPI } from "./CloudAPI" +import type { SettingsService } from "./SettingsService" + +export class CloudShareService { + private cloudAPI: CloudAPI + private settingsService: SettingsService + private log: (...args: unknown[]) => void + + constructor(cloudAPI: CloudAPI, settingsService: SettingsService, log?: (...args: unknown[]) => void) { + this.cloudAPI = cloudAPI + this.settingsService = settingsService + this.log = log || console.log + } + + async shareTask(taskId: string, visibility: ShareVisibility = "organization"): Promise { + try { + const response = await this.cloudAPI.shareTask(taskId, visibility) + + if (response.success && response.shareUrl) { + // Copy to clipboard. + await vscode.env.clipboard.writeText(response.shareUrl) + } + + return response + } catch (error) { + this.log("[ShareService] Error sharing task:", error) + throw error + } + } + + async canShareTask(): Promise { + try { + return !!this.settingsService.getSettings()?.cloudSettings?.enableTaskSharing + } catch (error) { + this.log("[ShareService] Error checking if task can be shared:", error) + return false + } + } +} diff --git a/packages/cloud/src/Config.ts b/packages/cloud/src/Config.ts index 08b0cc7a18..e682d718ce 100644 --- a/packages/cloud/src/Config.ts +++ b/packages/cloud/src/Config.ts @@ -1,7 +1,5 @@ -// Production constants export const PRODUCTION_CLERK_BASE_URL = "https://clerk.roocode.com" export const PRODUCTION_ROO_CODE_API_URL = "https://app.roocode.com" -// Functions with environment variable fallbacks export const getClerkBaseUrl = () => process.env.CLERK_BASE_URL || PRODUCTION_CLERK_BASE_URL export const getRooCodeApiUrl = () => process.env.ROO_CODE_API_URL || PRODUCTION_ROO_CODE_API_URL diff --git a/packages/cloud/src/ShareService.ts b/packages/cloud/src/ShareService.ts deleted file mode 100644 index 5dcc7cae3f..0000000000 --- a/packages/cloud/src/ShareService.ts +++ /dev/null @@ -1,88 +0,0 @@ -import * as vscode from "vscode" - -import { shareResponseSchema } from "@roo-code/types" -import { getRooCodeApiUrl } from "./Config" -import type { AuthService } from "./auth" -import type { SettingsService } from "./SettingsService" -import { getUserAgent } from "./utils" - -export type ShareVisibility = "organization" | "public" - -export class TaskNotFoundError extends Error { - constructor(taskId?: string) { - super(taskId ? `Task '${taskId}' not found` : "Task not found") - Object.setPrototypeOf(this, TaskNotFoundError.prototype) - } -} - -export class ShareService { - private authService: AuthService - private settingsService: SettingsService - private log: (...args: unknown[]) => void - - constructor(authService: AuthService, settingsService: SettingsService, log?: (...args: unknown[]) => void) { - this.authService = authService - this.settingsService = settingsService - this.log = log || console.log - } - - /** - * Share a task with specified visibility - * Returns the share response data - */ - async shareTask(taskId: string, visibility: ShareVisibility = "organization") { - try { - const sessionToken = this.authService.getSessionToken() - if (!sessionToken) { - throw new Error("Authentication required") - } - - const response = await fetch(`${getRooCodeApiUrl()}/api/extension/share`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${sessionToken}`, - "User-Agent": getUserAgent(), - }, - body: JSON.stringify({ taskId, visibility }), - signal: AbortSignal.timeout(10000), - }) - - if (!response.ok) { - if (response.status === 404) { - throw new TaskNotFoundError(taskId) - } - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } - - const data = shareResponseSchema.parse(await response.json()) - this.log("[share] Share link created successfully:", data) - - if (data.success && data.shareUrl) { - // Copy to clipboard - await vscode.env.clipboard.writeText(data.shareUrl) - } - - return data - } catch (error) { - this.log("[share] Error sharing task:", error) - throw error - } - } - - /** - * Check if sharing is available - */ - async canShareTask(): Promise { - try { - if (!this.authService.isAuthenticated()) { - return false - } - - return !!this.settingsService.getSettings()?.cloudSettings?.enableTaskSharing - } catch (error) { - this.log("[share] Error checking if task can be shared:", error) - return false - } - } -} diff --git a/packages/cloud/src/StaticSettingsService.ts b/packages/cloud/src/StaticSettingsService.ts index 3aac37bda5..97e6cf7ea8 100644 --- a/packages/cloud/src/StaticSettingsService.ts +++ b/packages/cloud/src/StaticSettingsService.ts @@ -36,6 +36,6 @@ export class StaticSettingsService implements SettingsService { } public dispose(): void { - // No resources to clean up for static settings + // No resources to clean up for static settings. } } diff --git a/src/services/task-bridge/TaskBridgeService.ts b/packages/cloud/src/TaskBridgeService.ts similarity index 94% rename from src/services/task-bridge/TaskBridgeService.ts rename to packages/cloud/src/TaskBridgeService.ts index 4eb4a65d72..ccc7851a5e 100644 --- a/src/services/task-bridge/TaskBridgeService.ts +++ b/packages/cloud/src/TaskBridgeService.ts @@ -1,7 +1,7 @@ import Redis from "ioredis" import { z } from "zod" -import { type TaskEvents, type TaskEventHandlers, Task } from "../../core/task/Task" +import type { TaskLike, TaskEventHandlers } from "@roo-code/types" const NAMESPACE = "bridge" @@ -58,7 +58,7 @@ export class TaskBridgeService { private isConnected: boolean = false private reconnectAttempts: number = 0 private reconnectTimeout: NodeJS.Timeout | null = null - private subscribedTasks: Map = new Map() + private subscribedTasks: Map = new Map() private taskEventHandlers: Record> = {} private messageQueues: Map = new Map() @@ -193,6 +193,7 @@ export class TaskBridgeService { console.error("Error handling incoming message:", error) } }) + await Promise.all([this.publisher.connect(), this.subscriber.connect()]) await Promise.all([this.waitForConnection(this.publisher), this.waitForConnection(this.subscriber)]) @@ -217,7 +218,7 @@ export class TaskBridgeService { } } - public async subscribeToTask(task: Task): Promise { + public async subscribeToTask(task: TaskLike): Promise { const channel = this.serverChannel(task.taskId) console.log(`[TaskBridgeService] subscribeToTask -> ${channel}`) @@ -386,7 +387,7 @@ export class TaskBridgeService { }, this.config.reconnectDelay) } - private setupTaskEventListeners(task: Task) { + private setupTaskEventListeners(task: TaskLike) { const callbacks: Partial = { // message: ({ action, message }) => // this.publish(task.taskId, "task_event", { eventType: "message", data: { action, message } }), @@ -427,22 +428,40 @@ export class TaskBridgeService { this.taskEventHandlers[task.taskId] = callbacks - for (const [eventName, handler] of Object.entries(callbacks)) { - task.on(eventName as keyof TaskEvents, handler) + const registerHandler = ( + eventName: K, + handler: TaskEventHandlers[K] | undefined, + ) => { + if (handler) { + task.on(eventName, handler) + } } + + ;(Object.keys(callbacks) as Array).forEach((eventName) => { + registerHandler(eventName, callbacks[eventName]) + }) } - private removeTaskEventListeners(task: Task): void { + private removeTaskEventListeners(task: TaskLike): void { const handlers = this.taskEventHandlers[task.taskId] if (!handlers) { return } - for (const [eventName, handler] of Object.entries(handlers)) { - task.off(eventName as keyof TaskEvents, handler) + const unregisterHandler = ( + eventName: K, + handler: TaskEventHandlers[K] | undefined, + ) => { + if (handler) { + task.off(eventName, handler) + } } + ;(Object.keys(handlers) as Array).forEach((eventName) => { + unregisterHandler(eventName, handlers[eventName]) + }) + delete this.taskEventHandlers[task.taskId] } @@ -506,7 +525,7 @@ export class TaskBridgeService { console.log(`[TaskBridgeService#${taskId}] busy`) try { - await task.handleWebviewAskResponse("messageResponse", message.text, message.images) + task.setMessageResponse(message.text, message.images) return true } catch (error) { console.error(`Failed to deliver message to task ${taskId}:`, error) diff --git a/packages/cloud/src/TelemetryClient.ts b/packages/cloud/src/TelemetryClient.ts index e33843a30c..20d7445bff 100644 --- a/packages/cloud/src/TelemetryClient.ts +++ b/packages/cloud/src/TelemetryClient.ts @@ -6,10 +6,11 @@ import { } from "@roo-code/types" import { BaseTelemetryClient } from "@roo-code/telemetry" -import { getRooCodeApiUrl } from "./Config" -import type { AuthService } from "./auth" import type { SettingsService } from "./SettingsService" +import { getRooCodeApiUrl } from "./config" +import type { AuthService } from "./auth" + export class TelemetryClient extends BaseTelemetryClient { constructor( private authService: AuthService, diff --git a/packages/cloud/src/__tests__/CloudAPI.test.ts b/packages/cloud/src/__tests__/CloudAPI.test.ts new file mode 100644 index 0000000000..884bf227d7 --- /dev/null +++ b/packages/cloud/src/__tests__/CloudAPI.test.ts @@ -0,0 +1,359 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import type { MockedFunction } from "vitest" + +import { CloudAPIError, TaskNotFoundError, AuthenticationError, NetworkError } from "../errors" +import type { AuthService } from "../auth" + +import { CloudAPI } from "../CloudAPI" + +const mockFetch = vi.fn() +global.fetch = mockFetch as any + +vi.mock("../Config", () => ({ + getRooCodeApiUrl: () => "https://app.roocode.com", +})) + +vi.mock("../utils", () => ({ + getUserAgent: () => "Roo-Code 1.0.0", +})) + +describe("CloudAPI", () => { + let cloudAPI: CloudAPI + let mockAuthService: AuthService + let mockLog: MockedFunction<(...args: unknown[]) => void> + + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockClear() + + mockLog = vi.fn() + mockAuthService = { + getSessionToken: vi.fn(), + } as any + + cloudAPI = new CloudAPI(mockAuthService, mockLog) + }) + + describe("constructor", () => { + it("should initialize with auth service and logger", () => { + expect(cloudAPI).toBeDefined() + expect(mockLog).not.toHaveBeenCalled() + }) + + it("should use console.log when no logger provided", () => { + const apiWithoutLogger = new CloudAPI(mockAuthService) + expect(apiWithoutLogger).toBeDefined() + }) + }) + + describe("shareTask", () => { + it("should successfully share a task with organization visibility", async () => { + const mockResponse = { + success: true, + shareUrl: "https://app.roocode.com/share/abc123", + isNewShare: true, + } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }) + + const result = await cloudAPI.shareTask("task-123", "organization") + + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/share", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer session-token", + "User-Agent": "Roo-Code 1.0.0", + }, + body: JSON.stringify({ taskId: "task-123", visibility: "organization" }), + signal: expect.any(AbortSignal), + }) + expect(mockLog).toHaveBeenCalledWith("[CloudAPI] Sharing task task-123 with visibility: organization") + expect(mockLog).toHaveBeenCalledWith("[CloudAPI] Share response:", mockResponse) + }) + + it("should default to organization visibility when not specified", async () => { + const mockResponse = { success: true } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }) + + await cloudAPI.shareTask("task-123") + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.roocode.com/api/extension/share", + expect.objectContaining({ + body: JSON.stringify({ taskId: "task-123", visibility: "organization" }), + }), + ) + }) + + it("should handle public visibility", async () => { + const mockResponse = { success: true } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }) + + await cloudAPI.shareTask("task-123", "public") + + expect(mockFetch).toHaveBeenCalledWith( + "https://app.roocode.com/api/extension/share", + expect.objectContaining({ + body: JSON.stringify({ taskId: "task-123", visibility: "public" }), + }), + ) + }) + + it("should throw AuthenticationError when no session token", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue(undefined) + + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow(AuthenticationError) + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow("Authentication required") + }) + + it("should throw TaskNotFoundError for 404 responses", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + json: vi.fn().mockResolvedValue({ error: "Task not found" }), + }) + + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow(TaskNotFoundError) + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow("Task not found") + }) + + it("should validate response schema", async () => { + const invalidResponse = { invalid: "data" } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(invalidResponse), + }) + + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow() + }) + }) + + describe("registerTaskBridge", () => { + it("should successfully register task bridge without URL", async () => { + const mockResponse = { + success: true, + bridgeId: "bridge-123", + } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }) + + const result = await cloudAPI.registerTaskBridge("task-123") + + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalledWith("https://app.roocode.com/api/extension/task-bridge/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer session-token", + "User-Agent": "Roo-Code 1.0.0", + }, + body: JSON.stringify({ taskId: "task-123" }), + signal: expect.any(AbortSignal), + }) + expect(mockLog).toHaveBeenCalledWith("[CloudAPI] Registering task bridge for task-123", "") + expect(mockLog).toHaveBeenCalledWith("[CloudAPI] Task bridge registration response:", mockResponse) + }) + + it("should successfully register task bridge with URL", async () => { + const mockResponse = { + success: true, + bridgeId: "bridge-123", + } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }) + + const result = await cloudAPI.registerTaskBridge("task-123", "redis://localhost:6379") + + expect(result).toEqual(mockResponse) + expect(mockFetch).toHaveBeenCalledWith( + "https://app.roocode.com/api/extension/task-bridge/register", + expect.objectContaining({ + body: JSON.stringify({ taskId: "task-123", bridgeUrl: "redis://localhost:6379" }), + }), + ) + expect(mockLog).toHaveBeenCalledWith( + "[CloudAPI] Registering task bridge for task-123", + "with URL: redis://localhost:6379", + ) + }) + + it("should handle registration failure", async () => { + const mockResponse = { + success: false, + error: "Task already has a bridge", + } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(mockResponse), + }) + + const result = await cloudAPI.registerTaskBridge("task-123") + + expect(result).toEqual(mockResponse) + expect(result.success).toBe(false) + expect(result.error).toBe("Task already has a bridge") + }) + + it("should throw AuthenticationError when no session token", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue(undefined) + + await expect(cloudAPI.registerTaskBridge("task-123")).rejects.toThrow(AuthenticationError) + }) + + it("should validate response schema", async () => { + const invalidResponse = { invalid: "data" } + + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: true, + json: vi.fn().mockResolvedValue(invalidResponse), + }) + + await expect(cloudAPI.registerTaskBridge("task-123")).rejects.toThrow() + }) + }) + + describe("error handling", () => { + it("should handle 401 authentication errors", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + json: vi.fn().mockResolvedValue({ error: "Invalid token" }), + }) + + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow(AuthenticationError) + }) + + it("should handle generic HTTP errors", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: vi.fn().mockResolvedValue({ error: "Server error" }), + }) + + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow(CloudAPIError) + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow("HTTP 500: Internal Server Error") + }) + + it("should handle network errors", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockRejectedValue(new TypeError("Failed to fetch")) + + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow(NetworkError) + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow( + "Network error while calling /api/extension/share", + ) + }) + + it("should handle timeout errors", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + const timeoutError = new Error("AbortError") + timeoutError.name = "AbortError" + mockFetch.mockRejectedValue(timeoutError) + + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow(CloudAPIError) + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow("Request to /api/extension/share timed out") + }) + + it("should handle unexpected errors", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockRejectedValue(new Error("Unexpected error")) + + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow(CloudAPIError) + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow( + "Unexpected error while calling /api/extension/share: Unexpected error", + ) + }) + + it("should handle non-JSON error responses", async () => { + ;(mockAuthService.getSessionToken as any).mockReturnValue("session-token") + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + text: vi.fn().mockResolvedValue("Plain text error"), + }) + + await expect(cloudAPI.shareTask("task-123")).rejects.toThrow(CloudAPIError) + }) + }) + + describe("custom error classes", () => { + it("should create CloudAPIError with correct properties", () => { + const error = new CloudAPIError("Test error", 500, { details: "test" }) + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe("CloudAPIError") + expect(error.message).toBe("Test error") + expect(error.statusCode).toBe(500) + expect(error.responseBody).toEqual({ details: "test" }) + }) + + it("should create TaskNotFoundError with correct properties", () => { + const error = new TaskNotFoundError("task-123") + expect(error).toBeInstanceOf(CloudAPIError) + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe("TaskNotFoundError") + expect(error.message).toBe("Task 'task-123' not found") + expect(error.statusCode).toBe(404) + }) + + it("should create TaskNotFoundError without taskId", () => { + const error = new TaskNotFoundError() + expect(error.message).toBe("Task not found") + }) + + it("should create AuthenticationError with correct properties", () => { + const error = new AuthenticationError("Custom auth error") + expect(error).toBeInstanceOf(CloudAPIError) + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe("AuthenticationError") + expect(error.message).toBe("Custom auth error") + expect(error.statusCode).toBe(401) + }) + + it("should create NetworkError with correct properties", () => { + const error = new NetworkError("Network failed") + expect(error).toBeInstanceOf(CloudAPIError) + expect(error).toBeInstanceOf(Error) + expect(error.name).toBe("NetworkError") + expect(error.message).toBe("Network failed") + expect(error.statusCode).toBeUndefined() + }) + }) +}) diff --git a/packages/cloud/src/__tests__/CloudService.test.ts b/packages/cloud/src/__tests__/CloudService.test.ts index 1384b6de6b..7dbfc668b6 100644 --- a/packages/cloud/src/__tests__/CloudService.test.ts +++ b/packages/cloud/src/__tests__/CloudService.test.ts @@ -1,15 +1,17 @@ // npx vitest run src/__tests__/CloudService.test.ts import * as vscode from "vscode" + import type { ClineMessage } from "@roo-code/types" +import { TelemetryService } from "@roo-code/telemetry" import { CloudService } from "../CloudService" import { WebAuthService } from "../auth/WebAuthService" import { CloudSettingsService } from "../CloudSettingsService" -import { ShareService, TaskNotFoundError } from "../ShareService" +import { CloudShareService } from "../CloudShareService" import { TelemetryClient } from "../TelemetryClient" -import { TelemetryService } from "@roo-code/telemetry" import { CloudServiceCallbacks } from "../types" +import { TaskNotFoundError } from "../errors" vi.mock("vscode", () => ({ ExtensionContext: vi.fn(), @@ -31,7 +33,7 @@ vi.mock("../auth/WebAuthService") vi.mock("../CloudSettingsService") -vi.mock("../ShareService") +vi.mock("../CloudShareService") vi.mock("../TelemetryClient") @@ -151,7 +153,7 @@ describe("CloudService", () => { vi.mocked(WebAuthService).mockImplementation(() => mockAuthService as unknown as WebAuthService) vi.mocked(CloudSettingsService).mockImplementation(() => mockSettingsService as unknown as CloudSettingsService) - vi.mocked(ShareService).mockImplementation(() => mockShareService as unknown as ShareService) + vi.mocked(CloudShareService).mockImplementation(() => mockShareService as unknown as CloudShareService) vi.mocked(TelemetryClient).mockImplementation(() => mockTelemetryClient as unknown as TelemetryClient) vi.mocked(TelemetryService.hasInstance).mockReturnValue(true) diff --git a/packages/cloud/src/__tests__/ShareService.test.ts b/packages/cloud/src/__tests__/CloudShareService.test.ts similarity index 86% rename from packages/cloud/src/__tests__/ShareService.test.ts rename to packages/cloud/src/__tests__/CloudShareService.test.ts index dd5b669603..6fae1fbb9f 100644 --- a/packages/cloud/src/__tests__/ShareService.test.ts +++ b/packages/cloud/src/__tests__/CloudShareService.test.ts @@ -3,9 +3,11 @@ import type { MockedFunction } from "vitest" import * as vscode from "vscode" -import { ShareService, TaskNotFoundError } from "../ShareService" -import type { AuthService } from "../auth" +import { CloudAPI } from "../CloudAPI" +import { CloudShareService } from "../CloudShareService" import type { SettingsService } from "../SettingsService" +import type { AuthService } from "../auth" +import { CloudAPIError, TaskNotFoundError } from "../errors" // Mock fetch const mockFetch = vi.fn() @@ -44,10 +46,11 @@ vi.mock("../utils", () => ({ getUserAgent: () => "Roo-Code 1.0.0", })) -describe("ShareService", () => { - let shareService: ShareService +describe("CloudShareService", () => { + let shareService: CloudShareService let mockAuthService: AuthService let mockSettingsService: SettingsService + let mockCloudAPI: CloudAPI let mockLog: MockedFunction<(...args: unknown[]) => void> beforeEach(() => { @@ -65,7 +68,8 @@ describe("ShareService", () => { getSettings: vi.fn(), } as any - shareService = new ShareService(mockAuthService, mockSettingsService, mockLog) + mockCloudAPI = new CloudAPI(mockAuthService, mockLog) + shareService = new CloudShareService(mockCloudAPI, mockSettingsService, mockLog) }) describe("shareTask", () => { @@ -189,12 +193,12 @@ describe("ShareService", () => { ok: false, status: 404, statusText: "Not Found", + json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + text: vi.fn().mockResolvedValue("Not Found"), }) await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow(TaskNotFoundError) - await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow( - "Task 'task-123' not found", - ) + await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow("Task not found") }) it("should throw generic Error for non-404 HTTP errors", async () => { @@ -203,12 +207,14 @@ describe("ShareService", () => { ok: false, status: 500, statusText: "Internal Server Error", + json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + text: vi.fn().mockResolvedValue("Internal Server Error"), }) + await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow(CloudAPIError) await expect(shareService.shareTask("task-123", "organization")).rejects.toThrow( "HTTP 500: Internal Server Error", ) - await expect(shareService.shareTask("task-123", "organization")).rejects.not.toThrow(TaskNotFoundError) }) it("should create TaskNotFoundError with correct properties", async () => { @@ -217,6 +223,8 @@ describe("ShareService", () => { ok: false, status: 404, statusText: "Not Found", + json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + text: vi.fn().mockResolvedValue("Not Found"), }) try { @@ -225,7 +233,7 @@ describe("ShareService", () => { } catch (error) { expect(error).toBeInstanceOf(TaskNotFoundError) expect(error).toBeInstanceOf(Error) - expect((error as TaskNotFoundError).message).toBe("Task 'task-123' not found") + expect((error as TaskNotFoundError).message).toBe("Task not found") } }) }) @@ -277,8 +285,8 @@ describe("ShareService", () => { expect(result).toBe(false) }) - it("should return false when not authenticated", async () => { - ;(mockAuthService.isAuthenticated as any).mockReturnValue(false) + it("should return false when settings service returns undefined", async () => { + ;(mockSettingsService.getSettings as any).mockReturnValue(undefined) const result = await shareService.canShareTask() @@ -286,13 +294,17 @@ describe("ShareService", () => { }) it("should handle errors gracefully", async () => { - ;(mockAuthService.isAuthenticated as any).mockImplementation(() => { - throw new Error("Auth error") + ;(mockSettingsService.getSettings as any).mockImplementation(() => { + throw new Error("Settings error") }) const result = await shareService.canShareTask() expect(result).toBe(false) + expect(mockLog).toHaveBeenCalledWith( + "[ShareService] Error checking if task can be shared:", + expect.any(Error), + ) }) }) }) diff --git a/packages/cloud/src/__tests__/auth/WebAuthService.spec.ts b/packages/cloud/src/__tests__/auth/WebAuthService.spec.ts index 0e6681c20b..0500aa0a35 100644 --- a/packages/cloud/src/__tests__/auth/WebAuthService.spec.ts +++ b/packages/cloud/src/__tests__/auth/WebAuthService.spec.ts @@ -6,7 +6,7 @@ import * as vscode from "vscode" import { WebAuthService } from "../../auth/WebAuthService" import { RefreshTimer } from "../../RefreshTimer" -import * as Config from "../../Config" +import * as Config from "../../config" import * as utils from "../../utils" // Mock external dependencies diff --git a/packages/cloud/src/auth/AuthService.ts b/packages/cloud/src/auth/AuthService.ts index 11ed5161ed..f969314873 100644 --- a/packages/cloud/src/auth/AuthService.ts +++ b/packages/cloud/src/auth/AuthService.ts @@ -1,4 +1,5 @@ import EventEmitter from "events" + import type { CloudUserInfo } from "@roo-code/types" export interface AuthServiceEvents { diff --git a/packages/cloud/src/auth/StaticTokenAuthService.ts b/packages/cloud/src/auth/StaticTokenAuthService.ts index 11fc18d3fb..5a793efc86 100644 --- a/packages/cloud/src/auth/StaticTokenAuthService.ts +++ b/packages/cloud/src/auth/StaticTokenAuthService.ts @@ -1,6 +1,9 @@ import EventEmitter from "events" + import * as vscode from "vscode" + import type { CloudUserInfo } from "@roo-code/types" + import type { AuthService, AuthServiceEvents, AuthState } from "./AuthService" export class StaticTokenAuthService extends EventEmitter implements AuthService { diff --git a/packages/cloud/src/auth/WebAuthService.ts b/packages/cloud/src/auth/WebAuthService.ts index 82d3122426..890da4b836 100644 --- a/packages/cloud/src/auth/WebAuthService.ts +++ b/packages/cloud/src/auth/WebAuthService.ts @@ -6,11 +6,19 @@ import { z } from "zod" import type { CloudUserInfo, CloudOrganizationMembership } from "@roo-code/types" -import { getClerkBaseUrl, getRooCodeApiUrl, PRODUCTION_CLERK_BASE_URL } from "../Config" -import { RefreshTimer } from "../RefreshTimer" +import { getClerkBaseUrl, getRooCodeApiUrl, PRODUCTION_CLERK_BASE_URL } from "../config" import { getUserAgent } from "../utils" +import { InvalidClientTokenError } from "../errors" +import { RefreshTimer } from "../RefreshTimer" + import type { AuthService, AuthServiceEvents, AuthState } from "./AuthService" +const AUTH_STATE_KEY = "clerk-auth-state" + +/** + * AuthCredentials + */ + const authCredentialsSchema = z.object({ clientToken: z.string().min(1, "Client token cannot be empty"), sessionId: z.string().min(1, "Session ID cannot be empty"), @@ -19,7 +27,9 @@ const authCredentialsSchema = z.object({ type AuthCredentials = z.infer -const AUTH_STATE_KEY = "clerk-auth-state" +/** + * Clerk Schemas + */ const clerkSignInResponseSchema = z.object({ response: z.object({ @@ -69,13 +79,6 @@ const clerkOrganizationMembershipsSchema = z.object({ ), }) -class InvalidClientTokenError extends Error { - constructor() { - super("Invalid/Expired client token") - Object.setPrototypeOf(this, InvalidClientTokenError.prototype) - } -} - export class WebAuthService extends EventEmitter implements AuthService { private context: vscode.ExtensionContext private timer: RefreshTimer @@ -94,8 +97,9 @@ export class WebAuthService extends EventEmitter implements A this.context = context this.log = log || console.log - // Calculate auth credentials key based on Clerk base URL + // Calculate auth credentials key based on Clerk base URL. const clerkBaseUrl = getClerkBaseUrl() + if (clerkBaseUrl !== PRODUCTION_CLERK_BASE_URL) { this.authCredentialsKey = `clerk-auth-credentials-${clerkBaseUrl}` } else { diff --git a/packages/cloud/src/errors.ts b/packages/cloud/src/errors.ts new file mode 100644 index 0000000000..7400f26b39 --- /dev/null +++ b/packages/cloud/src/errors.ts @@ -0,0 +1,42 @@ +export class CloudAPIError extends Error { + constructor( + message: string, + public statusCode?: number, + public responseBody?: unknown, + ) { + super(message) + this.name = "CloudAPIError" + Object.setPrototypeOf(this, CloudAPIError.prototype) + } +} + +export class TaskNotFoundError extends CloudAPIError { + constructor(taskId?: string) { + super(taskId ? `Task '${taskId}' not found` : "Task not found", 404) + this.name = "TaskNotFoundError" + Object.setPrototypeOf(this, TaskNotFoundError.prototype) + } +} + +export class AuthenticationError extends CloudAPIError { + constructor(message = "Authentication required") { + super(message, 401) + this.name = "AuthenticationError" + Object.setPrototypeOf(this, AuthenticationError.prototype) + } +} + +export class NetworkError extends CloudAPIError { + constructor(message = "Network error occurred") { + super(message) + this.name = "NetworkError" + Object.setPrototypeOf(this, NetworkError.prototype) + } +} + +export class InvalidClientTokenError extends Error { + constructor() { + super("Invalid/Expired client token") + Object.setPrototypeOf(this, InvalidClientTokenError.prototype) + } +} diff --git a/packages/cloud/src/index.ts b/packages/cloud/src/index.ts index 9770f349c6..4892a9a254 100644 --- a/packages/cloud/src/index.ts +++ b/packages/cloud/src/index.ts @@ -1,2 +1,5 @@ +export * from "./config" + +export * from "./CloudAPI" export * from "./CloudService" -export * from "./Config" +export * from "./TaskBridgeService" diff --git a/packages/cloud/src/utils.ts b/packages/cloud/src/utils.ts index cf87aa5e28..071fc09697 100644 --- a/packages/cloud/src/utils.ts +++ b/packages/cloud/src/utils.ts @@ -1,10 +1,5 @@ import * as vscode from "vscode" -/** - * Get the User-Agent string for API requests - * @param context Optional extension context for more accurate version detection - * @returns User-Agent string in format "Roo-Code {version}" - */ export function getUserAgent(context?: vscode.ExtensionContext): string { return `Roo-Code ${context?.extension?.packageJSON?.version || "unknown"}` } diff --git a/packages/types/src/cloud.ts b/packages/types/src/cloud.ts index 5ef90b6e5a..b9c25c8fb8 100644 --- a/packages/types/src/cloud.ts +++ b/packages/types/src/cloud.ts @@ -152,3 +152,14 @@ export const shareResponseSchema = z.object({ }) export type ShareResponse = z.infer + +/** + * Task Bridge Types + */ + +export const taskBridgeRegisterResponseSchema = z.object({ + success: z.boolean(), + error: z.string().optional(), +}) + +export type TaskBridgeRegisterResponse = z.infer diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 44937da235..ed02278533 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -1,8 +1,6 @@ -export * from "./providers/index.js" - export * from "./api.js" -export * from "./codebase-index.js" export * from "./cloud.js" +export * from "./codebase-index.js" export * from "./experiment.js" export * from "./followup.js" export * from "./global-settings.js" @@ -15,9 +13,12 @@ export * from "./mode.js" export * from "./model.js" export * from "./provider-settings.js" export * from "./sharing.js" +export * from "./task.js" +export * from "./todo.js" export * from "./telemetry.js" export * from "./terminal.js" export * from "./tool.js" export * from "./type-fu.js" export * from "./vscode.js" -export * from "./todo.js" + +export * from "./providers/index.js" diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts new file mode 100644 index 0000000000..d0c205df14 --- /dev/null +++ b/packages/types/src/task.ts @@ -0,0 +1,28 @@ +import { type ClineMessage, type TokenUsage } from "./message.js" +import { type ToolUsage, type ToolName } from "./tool.js" + +export type TaskEvents = { + message: [{ action: "created" | "updated"; message: ClineMessage }] + taskStarted: [] + taskModeSwitched: [taskId: string, mode: string] + taskPaused: [] + taskUnpaused: [] + taskAskResponded: [] + taskAborted: [] + taskSpawned: [taskId: string] + taskCompleted: [taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage] + taskTokenUsageUpdated: [taskId: string, tokenUsage: TokenUsage] + taskToolFailed: [taskId: string, tool: ToolName, error: string] +} + +export type TaskEventHandlers = { + [K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise +} + +export interface TaskLike { + readonly taskId: string + + on(event: K, listener: (...args: TaskEvents[K]) => void | Promise): this + off(event: K, listener: (...args: TaskEvents[K]) => void | Promise): this + setMessageResponse(text: string, images?: string[]): void +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f580d9194..ec305c9adb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -361,6 +361,9 @@ importers: '@roo-code/types': specifier: workspace:^ version: link:../types + ioredis: + specifier: ^5.3.2 + version: 5.6.1 zod: specifier: ^3.25.61 version: 3.25.61 @@ -371,12 +374,18 @@ importers: '@roo-code/config-typescript': specifier: workspace:^ version: link:../config-typescript + '@types/ioredis-mock': + specifier: ^8.2.6 + version: 8.2.6(ioredis@5.6.1) '@types/node': specifier: 20.x version: 20.17.57 '@types/vscode': specifier: ^1.84.0 version: 1.100.0 + ioredis-mock: + specifier: ^8.9.0 + version: 8.9.0(@types/ioredis-mock@8.2.6(ioredis@5.6.1))(ioredis@5.6.1) vitest: specifier: ^3.2.3 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.57)(@vitest/ui@3.2.4)(jiti@2.4.2)(jsdom@26.1.0)(lightningcss@1.30.1)(tsx@4.19.4)(yaml@2.8.0) @@ -665,9 +674,6 @@ importers: ignore: specifier: ^7.0.3 version: 7.0.4 - ioredis: - specifier: ^5.3.2 - version: 5.6.1 isbinaryfile: specifier: ^5.0.2 version: 5.0.4 @@ -804,9 +810,6 @@ importers: '@types/glob': specifier: ^8.1.0 version: 8.1.0 - '@types/ioredis-mock': - specifier: ^8.2.6 - version: 8.2.6(ioredis@5.6.1) '@types/mocha': specifier: ^10.0.10 version: 10.0.10 @@ -855,9 +858,6 @@ importers: glob: specifier: ^11.0.1 version: 11.0.2 - ioredis-mock: - specifier: ^8.9.0 - version: 8.9.0(@types/ioredis-mock@8.2.6(ioredis@5.6.1))(ioredis@5.6.1) mkdirp: specifier: ^3.0.1 version: 3.0.1 diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 51ab74f887..f6436a0ce9 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -10,6 +10,8 @@ import pWaitFor from "p-wait-for" import { serializeError } from "serialize-error" import { + type TaskLike, + type TaskEvents, type ProviderSettings, type TokenUsage, type ToolUsage, @@ -19,15 +21,15 @@ import { type ClineMessage, type ClineSay, type ToolProgressStatus, - DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, type HistoryItem, TelemetryEventName, TodoItem, getApiProtocol, getModelId, + DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" -import { CloudService } from "@roo-code/cloud" +import { CloudService, TaskBridgeService } from "@roo-code/cloud" // api import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api" @@ -52,7 +54,6 @@ import { BrowserSession } from "../../services/browser/BrowserSession" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" import { RepoPerTaskCheckpointService } from "../../services/checkpoints" -import { TaskBridgeService } from "../../services/task-bridge/TaskBridgeService" // integrations import { DiffViewProvider } from "../../integrations/editor/DiffViewProvider" @@ -96,24 +97,6 @@ import { restoreTodoListForTask } from "../tools/updateTodoListTool" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes -export type TaskEvents = { - message: [{ action: "created" | "updated"; message: ClineMessage }] - taskStarted: [] - taskModeSwitched: [taskId: string, mode: string] - taskPaused: [] - taskUnpaused: [] - taskAskResponded: [] - taskAborted: [] - taskSpawned: [taskId: string] - taskCompleted: [taskId: string, tokenUsage: TokenUsage, toolUsage: ToolUsage] - taskTokenUsageUpdated: [taskId: string, tokenUsage: TokenUsage] - taskToolFailed: [taskId: string, tool: ToolName, error: string] -} - -export type TaskEventHandlers = { - [K in keyof TaskEvents]: (...args: TaskEvents[K]) => void | Promise -} - export type TaskOptions = { provider: ClineProvider apiConfiguration: ProviderSettings @@ -132,7 +115,7 @@ export type TaskOptions = { onCreated?: (task: Task) => void } -export class Task extends EventEmitter { +export class Task extends EventEmitter implements TaskLike { todoList?: TodoItem[] readonly taskId: string readonly instanceId: string @@ -729,7 +712,11 @@ export class Task extends EventEmitter { return result } - public async handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { + public setMessageResponse(text: string, images?: string[]) { + this.handleWebviewAskResponse("messageResponse", text, images) + } + + handleWebviewAskResponse(askResponse: ClineAskResponse, text?: string, images?: string[]) { this.askResponse = askResponse this.askResponseText = text this.askResponseImages = images diff --git a/src/package.json b/src/package.json index 407977a675..8503d2bdc6 100644 --- a/src/package.json +++ b/src/package.json @@ -445,7 +445,6 @@ "gray-matter": "^4.0.3", "i18next": "^25.0.0", "ignore": "^7.0.3", - "ioredis": "^5.3.2", "isbinaryfile": "^5.0.2", "lodash.debounce": "^4.0.8", "mammoth": "^1.9.1", @@ -493,7 +492,6 @@ "@types/diff": "^5.2.1", "@types/diff-match-patch": "^1.0.36", "@types/glob": "^8.1.0", - "@types/ioredis-mock": "^8.2.6", "@types/mocha": "^10.0.10", "@types/node": "20.x", "@types/node-cache": "^4.1.3", @@ -510,7 +508,6 @@ "esbuild": "^0.25.0", "execa": "^9.5.2", "glob": "^11.0.1", - "ioredis-mock": "^8.9.0", "mkdirp": "^3.0.1", "nock": "^14.0.4", "npm-run-all2": "^8.0.1", diff --git a/src/services/task-bridge/index.ts b/src/services/task-bridge/index.ts deleted file mode 100644 index 086873f9e6..0000000000 --- a/src/services/task-bridge/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - type TaskBridgeConfig, - type TaskBridgeMessage, - type QueuedMessage, - TaskBridgeService, -} from "./TaskBridgeService" From aca97292a1d875396f7b1c0440a723f2ebecf13f Mon Sep 17 00:00:00 2001 From: cte Date: Thu, 31 Jul 2025 12:59:57 -0700 Subject: [PATCH 5/5] Rename file --- packages/cloud/src/{Config.ts => config.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/cloud/src/{Config.ts => config.ts} (100%) diff --git a/packages/cloud/src/Config.ts b/packages/cloud/src/config.ts similarity index 100% rename from packages/cloud/src/Config.ts rename to packages/cloud/src/config.ts