Просмотр исходного кода

Emit event when a task ask requires interaction (#7128)

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
Chris Estreich 4 месяцев назад
Родитель
Сommit
2a974e8bf6

+ 1 - 1
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",

+ 14 - 0
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],

+ 47 - 10
packages/types/src/message.ts

@@ -44,24 +44,61 @@ export const clineAskSchema = z.enum(clineAsks)
 
 export type ClineAsk = z.infer<typeof clineAskSchema>
 
+// 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 IdleAsk = (typeof idleAsks)[number]
+
+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 BlockingAsk = (typeof blockingAsks)[number]
+export type InteractiveAsk = (typeof interactiveAsks)[number]
 
-export function isBlockingAsk(ask: ClineAsk): ask is BlockingAsk {
-	return blockingAsks.includes(ask)
+export function isInteractiveAsk(ask: ClineAsk): ask is InteractiveAsk {
+	return (interactiveAsks as readonly ClineAsk[]).includes(ask)
 }
 
 /**

+ 19 - 5
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<typeof taskMetadataSchema>
 
 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<K extends keyof TaskEvents>(event: K, listener: (...args: TaskEvents[K]) => void | Promise<void>): this
 	off<K extends keyof TaskEvents>(event: K, listener: (...args: TaskEvents[K]) => void | Promise<void>): 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

+ 10 - 10
pnpm-lock.yaml

@@ -584,8 +584,8 @@ importers:
         specifier: ^1.14.0
         version: 1.14.0([email protected])
       '@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/[email protected]6.0':
-    resolution: {integrity: sha512-AMHjPFK6lSZeutELzdYgxs4r7tUW8NEffRkM3NagtGKK5KY/pCRwctdP0TDCJGwLCRu5JI21Ww9uYCgAQ4MM3Q==}
+  '@roo-code/[email protected]7.0':
+    resolution: {integrity: sha512-Sh7KGbVapxocnoyDzWkvvtVzam5jZnU2RGKT7V0v3CbFMQc329NBnHRAgVtJUwcGPHUFyAkwEHo181Fn3rFpZw==}
 
-  '@roo-code/[email protected]1.0':
-    resolution: {integrity: sha512-h+wihwF9iuKfb7xycS5yXgDzGGypjiZF4Sy4tu6vdkhzVcE8ExFtCwGn1w535p9KaLE1QCV/G5NddgajqRyPAQ==}
+  '@roo-code/[email protected]2.0':
+    resolution: {integrity: sha512-jPCVZ2j4Y0MUiHvAJbXduR3yFGb5A3KDvIThi8cKBAUg8zb7jhIYKTvJ5vub1MGZkpK11K0JG6ex4RL19FjobA==}
 
   '@sec-ant/[email protected]':
     resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -12314,9 +12314,9 @@ snapshots:
   '@rollup/[email protected]':
     optional: true
 
-  '@roo-code/[email protected]6.0':
+  '@roo-code/[email protected]7.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/[email protected]1.0':
+  '@roo-code/[email protected]2.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/[email protected])(@types/node@20.17.50)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
+      vitest: 3.2.4(@types/[email protected])(@types/node@24.2.1)(@vitest/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
 
   '@vitest/[email protected]':
     dependencies:

+ 114 - 28
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<TaskEvents> implements TaskLike {
 	providerRef: WeakRef<ClineProvider>
 	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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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<TaskEvents> 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
-
-		if (!partial && !isReady && isBlockingAsk(type)) {
-			this.blockingAsk = type
-			this.emit(RooCodeEventName.TaskIdle, this.taskId)
+		// 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 (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<TaskEvents> 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<TaskEvents> 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) {
+
+			if (provider) {
+				provider.postMessageToWebview({ type: "invoke", invoke: "sendMessage", text, images })
+			} else {
 				console.error("[Task#submitUserMessage] Provider reference lost")
-				return
 			}
-
-			void provider.postMessageToWebview({
-				type: "invoke",
-				invoke: "sendMessage",
-				text: trimmed,
-				images: imgs,
-			})
 		} catch (error) {
 			console.error("[Task#submitUserMessage] Failed to submit user message:", error)
 		}
@@ -1030,12 +1097,11 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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
+	}
 }

+ 7 - 0
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),
 			])
 		}

+ 1 - 1
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:^",