From 2a974e8bf63a6974d8ac55fbdac57148e2dd5391 Mon Sep 17 00:00:00 2001 From: Chris Estreich Date: Fri, 15 Aug 2025 23:46:51 -1000 Subject: [PATCH] Emit event when a task ask requires interaction (#7128) Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com> --- packages/types/npm/package.metadata.json | 2 +- packages/types/src/events.ts | 14 +++ packages/types/src/message.ts | 57 +++++++-- packages/types/src/task.ts | 24 +++- pnpm-lock.yaml | 20 ++-- src/core/task/Task.ts | 142 ++++++++++++++++++----- src/core/webview/ClineProvider.ts | 7 ++ src/package.json | 2 +- 8 files changed, 213 insertions(+), 55 deletions(-) diff --git a/packages/types/npm/package.metadata.json b/packages/types/npm/package.metadata.json index f9e3c71ead..a40b43aacc 100644 --- a/packages/types/npm/package.metadata.json +++ b/packages/types/npm/package.metadata.json @@ -1,6 +1,6 @@ { "name": "@roo-code/types", - "version": "1.51.0", + "version": "1.52.0", "description": "TypeScript type definitions for Roo Code.", "publishConfig": { "access": "public", diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index 42c389ab60..2b6b810c81 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -18,6 +18,8 @@ export enum RooCodeEventName { TaskFocused = "taskFocused", TaskUnfocused = "taskUnfocused", TaskActive = "taskActive", + TaskInteractive = "taskInteractive", + TaskResumable = "taskResumable", TaskIdle = "taskIdle", // Subtask Lifecycle @@ -59,6 +61,8 @@ export const rooCodeEventsSchema = z.object({ [RooCodeEventName.TaskFocused]: z.tuple([z.string()]), [RooCodeEventName.TaskUnfocused]: z.tuple([z.string()]), [RooCodeEventName.TaskActive]: z.tuple([z.string()]), + [RooCodeEventName.TaskInteractive]: z.tuple([z.string()]), + [RooCodeEventName.TaskResumable]: z.tuple([z.string()]), [RooCodeEventName.TaskIdle]: z.tuple([z.string()]), [RooCodeEventName.TaskPaused]: z.tuple([z.string()]), @@ -124,6 +128,16 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskActive], taskId: z.number().optional(), }), + z.object({ + eventName: z.literal(RooCodeEventName.TaskInteractive), + payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskInteractive], + taskId: z.number().optional(), + }), + z.object({ + eventName: z.literal(RooCodeEventName.TaskResumable), + payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskResumable], + taskId: z.number().optional(), + }), z.object({ eventName: z.literal(RooCodeEventName.TaskIdle), payload: rooCodeEventsSchema.shape[RooCodeEventName.TaskIdle], diff --git a/packages/types/src/message.ts b/packages/types/src/message.ts index 7197ab29a1..5037370f24 100644 --- a/packages/types/src/message.ts +++ b/packages/types/src/message.ts @@ -44,24 +44,61 @@ export const clineAskSchema = z.enum(clineAsks) export type ClineAsk = z.infer +// Needs classification: +// - `followup` +// - `command_output + /** - * BlockingAsk + * IdleAsk + * + * Asks that put the task into an "idle" state. */ -export const blockingAsks: ClineAsk[] = [ - "api_req_failed", - "mistake_limit_reached", +export const idleAsks = [ "completion_result", - "resume_task", + "api_req_failed", "resume_completed_task", - "command_output", + "mistake_limit_reached", "auto_approval_max_req_reached", -] as const +] as const satisfies readonly ClineAsk[] -export type BlockingAsk = (typeof blockingAsks)[number] +export type IdleAsk = (typeof idleAsks)[number] -export function isBlockingAsk(ask: ClineAsk): ask is BlockingAsk { - return blockingAsks.includes(ask) +export function isIdleAsk(ask: ClineAsk): ask is IdleAsk { + return (idleAsks as readonly ClineAsk[]).includes(ask) +} + +/** + * ResumableAsk + * + * Asks that put the task into an "resumable" state. + */ + +export const resumableAsks = ["resume_task"] as const satisfies readonly ClineAsk[] + +export type ResumableAsk = (typeof resumableAsks)[number] + +export function isResumableAsk(ask: ClineAsk): ask is ResumableAsk { + return (resumableAsks as readonly ClineAsk[]).includes(ask) +} + +/** + * InteractiveAsk + * + * Asks that put the task into an "user interaction required" state. + */ + +export const interactiveAsks = [ + "command", + "tool", + "browser_action_launch", + "use_mcp_server", +] as const satisfies readonly ClineAsk[] + +export type InteractiveAsk = (typeof interactiveAsks)[number] + +export function isInteractiveAsk(ask: ClineAsk): ask is InteractiveAsk { + return (interactiveAsks as readonly ClineAsk[]).includes(ask) } /** diff --git a/packages/types/src/task.ts b/packages/types/src/task.ts index 07789c88de..96daac424b 100644 --- a/packages/types/src/task.ts +++ b/packages/types/src/task.ts @@ -1,7 +1,7 @@ import { z } from "zod" import { RooCodeEventName } from "./events.js" -import { type ClineMessage, type BlockingAsk, type TokenUsage } from "./message.js" +import { type ClineMessage, type TokenUsage } from "./message.js" import { type ToolUsage, type ToolName } from "./tool.js" import type { StaticAppProperties, GitProperties, TelemetryProperties } from "./telemetry.js" @@ -54,6 +54,8 @@ export type TaskProviderEvents = { [RooCodeEventName.TaskFocused]: [taskId: string] [RooCodeEventName.TaskUnfocused]: [taskId: string] [RooCodeEventName.TaskActive]: [taskId: string] + [RooCodeEventName.TaskInteractive]: [taskId: string] + [RooCodeEventName.TaskResumable]: [taskId: string] [RooCodeEventName.TaskIdle]: [taskId: string] } @@ -61,8 +63,15 @@ export type TaskProviderEvents = { * TaskLike */ +export enum TaskStatus { + Running = "running", + Interactive = "interactive", + Resumable = "resumable", + Idle = "idle", + None = "none", +} + export const taskMetadataSchema = z.object({ - taskId: z.string(), task: z.string().optional(), images: z.array(z.string()).optional(), }) @@ -71,14 +80,17 @@ export type TaskMetadata = z.infer export interface TaskLike { readonly taskId: string - readonly rootTask?: TaskLike - readonly blockingAsk?: BlockingAsk + readonly taskStatus: TaskStatus + readonly taskAsk: ClineMessage | undefined readonly metadata: TaskMetadata + readonly rootTask?: TaskLike + 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 + approveAsk(options?: { text?: string; images?: string[] }): void + denyAsk(options?: { text?: string; images?: string[] }): void submitUserMessage(text: string, images?: string[]): void } @@ -90,6 +102,8 @@ export type TaskEvents = { [RooCodeEventName.TaskFocused]: [] [RooCodeEventName.TaskUnfocused]: [] [RooCodeEventName.TaskActive]: [taskId: string] + [RooCodeEventName.TaskInteractive]: [taskId: string] + [RooCodeEventName.TaskResumable]: [taskId: string] [RooCodeEventName.TaskIdle]: [taskId: string] // Subtask Lifecycle diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d514ddf028..0d615c6e04 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -584,8 +584,8 @@ importers: specifier: ^1.14.0 version: 1.14.0(typescript@5.8.3) '@roo-code/cloud': - specifier: ^0.16.0 - version: 0.16.0 + specifier: ^0.17.0 + version: 0.17.0 '@roo-code/ipc': specifier: workspace:^ version: link:../packages/ipc @@ -3106,11 +3106,11 @@ packages: cpu: [x64] os: [win32] - '@roo-code/cloud@0.16.0': - resolution: {integrity: sha512-AMHjPFK6lSZeutELzdYgxs4r7tUW8NEffRkM3NagtGKK5KY/pCRwctdP0TDCJGwLCRu5JI21Ww9uYCgAQ4MM3Q==} + '@roo-code/cloud@0.17.0': + resolution: {integrity: sha512-Sh7KGbVapxocnoyDzWkvvtVzam5jZnU2RGKT7V0v3CbFMQc329NBnHRAgVtJUwcGPHUFyAkwEHo181Fn3rFpZw==} - '@roo-code/types@1.51.0': - resolution: {integrity: sha512-h+wihwF9iuKfb7xycS5yXgDzGGypjiZF4Sy4tu6vdkhzVcE8ExFtCwGn1w535p9KaLE1QCV/G5NddgajqRyPAQ==} + '@roo-code/types@1.52.0': + resolution: {integrity: sha512-jPCVZ2j4Y0MUiHvAJbXduR3yFGb5A3KDvIThi8cKBAUg8zb7jhIYKTvJ5vub1MGZkpK11K0JG6ex4RL19FjobA==} '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} @@ -12314,9 +12314,9 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.40.2': optional: true - '@roo-code/cloud@0.16.0': + '@roo-code/cloud@0.17.0': dependencies: - '@roo-code/types': 1.51.0 + '@roo-code/types': 1.52.0 ioredis: 5.6.1 p-wait-for: 5.0.2 socket.io-client: 4.8.1 @@ -12326,7 +12326,7 @@ snapshots: - supports-color - utf-8-validate - '@roo-code/types@1.51.0': + '@roo-code/types@1.52.0': dependencies: zod: 3.25.76 @@ -13555,7 +13555,7 @@ snapshots: sirv: 3.0.1 tinyglobby: 0.2.14 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.17.50)(@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) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.2.1)(@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) '@vitest/utils@3.2.4': dependencies: diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 204348d028..a167d43798 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -21,16 +21,21 @@ import { type ClineMessage, type ClineSay, type ClineAsk, - type BlockingAsk, + type IdleAsk, + type ResumableAsk, + type InteractiveAsk, type ToolProgressStatus, type HistoryItem, RooCodeEventName, TelemetryEventName, + TaskStatus, TodoItem, + DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, getApiProtocol, getModelId, - DEFAULT_CONSECUTIVE_MISTAKE_LIMIT, - isBlockingAsk, + isIdleAsk, + isInteractiveAsk, + isResumableAsk, } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { CloudService, ExtensionBridgeService } from "@roo-code/cloud" @@ -182,7 +187,12 @@ export class Task extends EventEmitter implements TaskLike { providerRef: WeakRef private readonly globalStoragePath: string abort: boolean = false - blockingAsk?: BlockingAsk + + // TaskStatus + idleAsk?: ClineMessage + resumableAsk?: ClineMessage + interactiveAsk?: ClineMessage + didFinishAbortingStream = false abandoned = false isInitialized = false @@ -290,7 +300,6 @@ export class Task extends EventEmitter implements TaskLike { this.taskId = historyItem ? historyItem.id : crypto.randomUUID() this.metadata = { - taskId: this.taskId, task: historyItem ? historyItem.task : task, images: historyItem ? [] : images, } @@ -497,6 +506,7 @@ export class Task extends EventEmitter implements TaskLike { if (this._taskMode === undefined) { throw new Error("Task mode accessed before initialization. Use getTaskMode() or wait for taskModeReady.") } + return this._taskMode } @@ -615,6 +625,16 @@ export class Task extends EventEmitter implements TaskLike { } } + private findMessageByTimestamp(ts: number): ClineMessage | undefined { + for (let i = this.clineMessages.length - 1; i >= 0; i--) { + if (this.clineMessages[i].ts === ts) { + return this.clineMessages[i] + } + } + + return undefined + } + // Note that `partial` has three valid states true (partial message), // false (completion of partial message), undefined (individual complete // message). @@ -713,16 +733,55 @@ export class Task extends EventEmitter implements TaskLike { await this.addToClineMessages({ ts: askTs, type: "ask", ask: type, text, isProtected }) } - // Detect if the task will enter an idle state. - const isReady = this.askResponse !== undefined || this.lastMessageTs !== askTs + // The state is mutable if the message is complete and the task will + // block (via the `pWaitFor`). + const isBlocking = !(this.askResponse !== undefined || this.lastMessageTs !== askTs) + const isStatusMutable = !partial && isBlocking + let statusMutationTimeouts: NodeJS.Timeout[] = [] - if (!partial && !isReady && isBlockingAsk(type)) { - this.blockingAsk = type - this.emit(RooCodeEventName.TaskIdle, this.taskId) + if (isStatusMutable) { + if (isInteractiveAsk(type)) { + statusMutationTimeouts.push( + setTimeout(() => { + const message = this.findMessageByTimestamp(askTs) + + if (message) { + this.interactiveAsk = message + this.emit(RooCodeEventName.TaskInteractive, this.taskId) + } + }, 1_000), + ) + } else if (isResumableAsk(type)) { + statusMutationTimeouts.push( + setTimeout(() => { + const message = this.findMessageByTimestamp(askTs) + + if (message) { + this.resumableAsk = message + this.emit(RooCodeEventName.TaskResumable, this.taskId) + } + }, 1_000), + ) + } else if (isIdleAsk(type)) { + statusMutationTimeouts.push( + setTimeout(() => { + const message = this.findMessageByTimestamp(askTs) + + if (message) { + this.idleAsk = message + this.emit(RooCodeEventName.TaskIdle, this.taskId) + } + }, 1_000), + ) + } } - console.log(`[Task#${this.taskId}] pWaitFor askResponse(${type}) -> blocking`) + console.log( + `[Task#${this.taskId}] pWaitFor askResponse(${type}) -> blocking (isStatusMutable = ${isStatusMutable}, statusMutationTimeouts = ${statusMutationTimeouts.length})`, + ) + await pWaitFor(() => this.askResponse !== undefined || this.lastMessageTs !== askTs, { interval: 100 }) + console.log(`[Task#${this.taskId}] pWaitFor askResponse(${type}) -> unblocked (${this.askResponse})`) if (this.lastMessageTs !== askTs) { @@ -737,9 +796,14 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseText = undefined this.askResponseImages = undefined + // Cancel the timeouts if they are still running. + statusMutationTimeouts.forEach((timeout) => clearTimeout(timeout)) + // Switch back to an active state. - if (this.blockingAsk) { - this.blockingAsk = undefined + if (this.idleAsk || this.resumableAsk || this.interactiveAsk) { + this.idleAsk = undefined + this.resumableAsk = undefined + this.interactiveAsk = undefined this.emit(RooCodeEventName.TaskActive, this.taskId) } @@ -757,27 +821,30 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseImages = images } + public approveAsk({ text, images }: { text?: string; images?: string[] } = {}) { + this.handleWebviewAskResponse("yesButtonClicked", text, images) + } + + public denyAsk({ text, images }: { text?: string; images?: string[] } = {}) { + this.handleWebviewAskResponse("noButtonClicked", text, images) + } + public submitUserMessage(text: string, images?: string[]): void { try { - const trimmed = (text ?? "").trim() - const imgs = images ?? [] + text = (text ?? "").trim() + images = images ?? [] - if (!trimmed && imgs.length === 0) { + if (text.length === 0 && images.length === 0) { return } const provider = this.providerRef.deref() - if (!provider) { - console.error("[Task#submitUserMessage] Provider reference lost") - return - } - void provider.postMessageToWebview({ - type: "invoke", - invoke: "sendMessage", - text: trimmed, - images: imgs, - }) + if (provider) { + provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images }) + } else { + console.error("[Task#submitUserMessage] Provider reference lost") + } } catch (error) { console.error("[Task#submitUserMessage] Failed to submit user message:", error) } @@ -1030,12 +1097,11 @@ export class Task extends EventEmitter implements TaskLike { } public async resumePausedTask(lastMessage: string) { - // Release this Cline instance from paused state. this.isPaused = false this.emit(RooCodeEventName.TaskUnpaused) // Fake an answer from the subtask that it has completed running and - // this is the result of what it has done add the message to the chat + // this is the result of what it has done add the message to the chat // history and to the webview ui. try { await this.say("subtask_result", lastMessage) @@ -2520,4 +2586,24 @@ export class Task extends EventEmitter implements TaskLike { public get cwd() { return this.workspacePath } + + public get taskStatus(): TaskStatus { + if (this.interactiveAsk) { + return TaskStatus.Interactive + } + + if (this.resumableAsk) { + return TaskStatus.Resumable + } + + if (this.idleAsk) { + return TaskStatus.Idle + } + + return TaskStatus.Running + } + + public get taskAsk(): ClineMessage | undefined { + return this.idleAsk || this.resumableAsk || this.interactiveAsk + } } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 8c4a32c3c8..04d336d957 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -29,6 +29,7 @@ import { type TerminalActionId, type TerminalActionPromptType, type HistoryItem, + type ClineAsk, RooCodeEventName, requestyDefaultModelId, openRouterDefaultModelId, @@ -176,6 +177,8 @@ export class ClineProvider const onTaskFocused = () => this.emit(RooCodeEventName.TaskFocused, instance.taskId) const onTaskUnfocused = () => this.emit(RooCodeEventName.TaskUnfocused, instance.taskId) const onTaskActive = (taskId: string) => this.emit(RooCodeEventName.TaskActive, taskId) + const onTaskInteractive = (taskId: string) => this.emit(RooCodeEventName.TaskInteractive, taskId) + const onTaskResumable = (taskId: string) => this.emit(RooCodeEventName.TaskResumable, taskId) const onTaskIdle = (taskId: string) => this.emit(RooCodeEventName.TaskIdle, taskId) // Attach the listeners. @@ -185,6 +188,8 @@ export class ClineProvider instance.on(RooCodeEventName.TaskFocused, onTaskFocused) instance.on(RooCodeEventName.TaskUnfocused, onTaskUnfocused) instance.on(RooCodeEventName.TaskActive, onTaskActive) + instance.on(RooCodeEventName.TaskInteractive, onTaskInteractive) + instance.on(RooCodeEventName.TaskResumable, onTaskResumable) instance.on(RooCodeEventName.TaskIdle, onTaskIdle) // Store the cleanup functions for later removal. @@ -195,6 +200,8 @@ export class ClineProvider () => instance.off(RooCodeEventName.TaskFocused, onTaskFocused), () => instance.off(RooCodeEventName.TaskUnfocused, onTaskUnfocused), () => instance.off(RooCodeEventName.TaskActive, onTaskActive), + () => instance.off(RooCodeEventName.TaskInteractive, onTaskInteractive), + () => instance.off(RooCodeEventName.TaskResumable, onTaskResumable), () => instance.off(RooCodeEventName.TaskIdle, onTaskIdle), ]) } diff --git a/src/package.json b/src/package.json index 4928f450b3..e011ffdc7c 100644 --- a/src/package.json +++ b/src/package.json @@ -427,7 +427,7 @@ "@mistralai/mistralai": "^1.3.6", "@modelcontextprotocol/sdk": "^1.9.0", "@qdrant/js-client-rest": "^1.14.0", - "@roo-code/cloud": "^0.16.0", + "@roo-code/cloud": "^0.17.0", "@roo-code/ipc": "workspace:^", "@roo-code/telemetry": "workspace:^", "@roo-code/types": "workspace:^",