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

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Chris Estreich 2025-08-15 23:46:51 -10:00 committed by GitHub
parent f3864ffebb
commit 2a974e8bf6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 213 additions and 55 deletions

View file

@ -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",

View file

@ -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],

View file

@ -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 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)
}
/**

View file

@ -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

20
pnpm-lock.yaml generated
View file

@ -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:

View file

@ -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
// 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<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) {
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<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
}
}

View file

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

View file

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