From edf4e6384a129ccc3d0dc2bfa9d626b857b9ffde Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 10:16:00 -0400 Subject: [PATCH 1/3] feat(cli): add direct headless extension API --- packages/types/src/api.ts | 52 ++++ packages/types/src/events.ts | 41 +++ src/core/task/Task.ts | 22 ++ src/core/webview/ClineProvider.ts | 28 +- src/eslint-suppressions.json | 5 - src/extension/__tests__/api-headless.spec.ts | 119 ++++++++ src/extension/api.ts | 279 ++++++++++++++++++- 7 files changed, 522 insertions(+), 24 deletions(-) create mode 100644 src/extension/__tests__/api-headless.spec.ts diff --git a/packages/types/src/api.ts b/packages/types/src/api.ts index 89e9c8bc2b..e1a06357f2 100644 --- a/packages/types/src/api.ts +++ b/packages/types/src/api.ts @@ -4,12 +4,64 @@ import type { Socket } from "net" import type { RooCodeEvents } from "./events.js" import type { RooCodeSettings } from "./global-settings.js" import type { HistoryItem } from "./history.js" +import type { TokenUsage } from "./message.js" +import type { ToolUsage } from "./tool.js" import type { ProviderSettingsEntry, ProviderSettings } from "./provider-settings.js" import type { IpcMessage, IpcServerEvents } from "./ipc.js" export type RooCodeAPIEvents = RooCodeEvents +export type HeadlessCapabilities = { + checkpoints: false + typedAsks: true + rootTaskResults: true +} + +export type HeadlessTaskReference = { taskId: string; rootTaskId: string } + +export type HeadlessAskResponse = + | { response: "approve" } + | { response: "reject" } + | { response: "message"; text: string; images?: string[] } + +export type HeadlessCancelSettlement = { + rootTaskId: string + resumable: boolean + status: "interrupted" | "failed" +} + +export type HeadlessTaskResult = { + rootTaskId: string + currentTaskId: string + outcome: "completed" | "cancelled" | "failed" + resumable: boolean + content?: string + error?: { code: "task_failed" | "cancel_failed" | "shutdown"; message: string } + tokenUsage?: TokenUsage + toolUsage?: ToolUsage +} + +export type HeadlessShutdownReport = { + settledRuns: number + pendingRuns: number +} + export interface RooCodeAPI extends EventEmitter { + initializeHeadless(): Promise + startHeadlessTask(input: { + text: string + images?: string[] + configuration?: RooCodeSettings + }): Promise + resumeHeadlessTask(taskId: string): Promise + respondToHeadlessAsk(input: { taskId: string; askId: string; response: HeadlessAskResponse }): Promise + cancelHeadlessTask(input: { + rootTaskId: string + reason: "user" | "signal" | "timeout" + }): Promise + getHeadlessTaskResult(rootTaskId: string): Promise + waitForHeadlessTaskResult(rootTaskId: string): Promise + shutdownHeadless(): Promise /** * Starts a new task with an optional initial message and images. * @param task Optional initial task message. diff --git a/packages/types/src/events.ts b/packages/types/src/events.ts index fc6c3c25d4..62c792e842 100644 --- a/packages/types/src/events.ts +++ b/packages/types/src/events.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { clineMessageSchema, queuedMessageSchema, tokenUsageSchema } from "./message.js" import { modelInfoSchema } from "./model.js" import { toolNamesSchema, toolUsageSchema } from "./tool.js" +import { historyItemSchema } from "./history.js" /** * RooCodeEventName @@ -50,6 +51,11 @@ export enum RooCodeEventName { CommandsResponse = "commandsResponse", ModesResponse = "modesResponse", ModelsResponse = "modelsResponse", + + // Direct headless API + HeadlessAsk = "headlessAsk", + HeadlessTerminalFailure = "headlessTerminalFailure", + HeadlessTaskResult = "headlessTaskResult", } /** @@ -124,6 +130,29 @@ export const rooCodeEventsSchema = z.object({ ]), [RooCodeEventName.ModesResponse]: z.tuple([z.array(z.object({ slug: z.string(), name: z.string() }))]), [RooCodeEventName.ModelsResponse]: z.tuple([z.record(z.string(), modelInfoSchema)]), + [RooCodeEventName.HeadlessAsk]: z.tuple([ + z.object({ + taskId: z.string(), + rootTaskId: z.string(), + askId: z.string(), + ask: z.string(), + text: z.string().optional(), + isProtected: z.boolean().optional(), + }), + ]), + [RooCodeEventName.HeadlessTerminalFailure]: z.tuple([ + z.object({ taskId: z.string(), rootTaskId: z.string(), code: z.string(), message: z.string() }), + ]), + [RooCodeEventName.HeadlessTaskResult]: z.tuple([ + z.object({ + rootTaskId: z.string(), + currentTaskId: z.string(), + outcome: z.enum(["completed", "cancelled", "failed"]), + resumable: z.boolean(), + content: z.string().optional(), + historyItem: historyItemSchema.optional(), + }), + ]), }) export type RooCodeEvents = z.infer @@ -269,6 +298,18 @@ export const taskEventSchema = z.discriminatedUnion("eventName", [ payload: rooCodeEventsSchema.shape[RooCodeEventName.ModelsResponse], taskId: z.number().optional(), }), + z.object({ + eventName: z.literal(RooCodeEventName.HeadlessAsk), + payload: rooCodeEventsSchema.shape[RooCodeEventName.HeadlessAsk], + }), + z.object({ + eventName: z.literal(RooCodeEventName.HeadlessTerminalFailure), + payload: rooCodeEventsSchema.shape[RooCodeEventName.HeadlessTerminalFailure], + }), + z.object({ + eventName: z.literal(RooCodeEventName.HeadlessTaskResult), + payload: rooCodeEventsSchema.shape[RooCodeEventName.HeadlessTaskResult], + }), ]) export type TaskEvent = z.infer diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index fe68f4ab0e..13190c1955 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -1265,6 +1265,7 @@ export class Task extends EventEmitter implements TaskLike { // So in this case we must make sure that the message ts is // never altered after first setting it. askTs = lastMessage.ts + this.pendingHeadlessAskId = askTs this.lastMessageTs = askTs lastMessage.text = text lastMessage.partial = false @@ -1286,6 +1287,7 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseText = undefined this.askResponseImages = undefined askTs = Date.now() + this.pendingHeadlessAskId = askTs this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, @@ -1304,6 +1306,7 @@ export class Task extends EventEmitter implements TaskLike { this.askResponseText = undefined this.askResponseImages = undefined askTs = Date.now() + this.pendingHeadlessAskId = askTs this.lastMessageTs = askTs await this.addToClineMessages({ ts: askTs, @@ -1316,6 +1319,7 @@ export class Task extends EventEmitter implements TaskLike { }) } + this.pendingHeadlessAskId = askTs const timeouts: NodeJS.Timeout[] = [] if (approval.decision === "approve") { @@ -1429,10 +1433,14 @@ export class Task extends EventEmitter implements TaskLike { /* v8 ignore next 3 -- abort-while-waiting path; covered by e2e standalone-resume test */ if (this.abort) { + timeouts.forEach((timeout) => clearTimeout(timeout)) + if (this.pendingHeadlessAskId === askTs) this.pendingHeadlessAskId = undefined throw new Error(`[ZooCode#ask] task ${this.taskId}.${this.instanceId} aborted`) } if (this.lastMessageTs !== askTs) { + timeouts.forEach((timeout) => clearTimeout(timeout)) + if (this.pendingHeadlessAskId === askTs) this.pendingHeadlessAskId = undefined // Could happen if we send multiple asks in a row i.e. with // command_output. It's important that when we know an ask could // fail, it is handled gracefully. @@ -1443,6 +1451,7 @@ export class Task extends EventEmitter implements TaskLike { this.askResponse = undefined this.askResponseText = undefined this.askResponseImages = undefined + if (this.pendingHeadlessAskId === askTs) this.pendingHeadlessAskId = undefined // Cancel the timeouts if they are still running. timeouts.forEach((timeout) => clearTimeout(timeout)) @@ -1510,6 +1519,19 @@ export class Task extends EventEmitter implements TaskLike { } } + private pendingHeadlessAskId: number | undefined + + public get pendingAskId(): number | undefined { + return this.pendingHeadlessAskId + } + + public respondToAsk(askId: number, askResponse: ClineAskResponse, text?: string, images?: string[]): boolean { + if (this.abort || this.pendingHeadlessAskId !== askId) return false + this.pendingHeadlessAskId = undefined + this.handleWebviewAskResponse(askResponse, text, images) + return true + } + /** * Cancel any pending auto-approval timeout. * Called when user interacts (types, clicks buttons, etc.) to prevent the timeout from firing. diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7a404c9292..cbdebfea5c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -247,7 +247,8 @@ export class ClineProvider this.scheduleGlobalStateWriteThrough() }, }) - this.initializeTaskHistoryStore().catch((error) => { + this.taskHistoryInitialization = this.initializeTaskHistoryStore() + this.taskHistoryInitialization.catch((error) => { this.log(`Failed to initialize TaskHistoryStore: ${error}`) }) @@ -3054,6 +3055,16 @@ export class ClineProvider return this.taskRegistry.current } + private readonly taskHistoryInitialization: Promise + + public waitUntilReady(): Promise { + return this.taskHistoryInitialization + } + + public getTaskById(taskId: string): Task | undefined { + return this.taskRegistry.getById(taskId) + } + private logWebviewHiddenDiagnostics(): void { const task = this.getCurrentTask() if (!task || task.abort || task.abandoned) { @@ -3221,7 +3232,7 @@ export class ClineProvider return task } - public async cancelTask(): Promise { + public async cancelTask(options: { rehydrate?: boolean } = {}): Promise { const task = this.getCurrentTask() if (!task) { @@ -3229,10 +3240,10 @@ export class ClineProvider } console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`) - await this.cancelTaskInternal(task) + await this.cancelTaskInternal(task, options) } - private async cancelTaskInternal(task: Task): Promise { + private async cancelTaskInternal(task: Task, options: { rehydrate?: boolean }): Promise { let historyItem: HistoryItem | undefined try { const history = await this.getTaskWithId(task.taskId) @@ -3315,6 +3326,11 @@ export class ClineProvider return } + if (!task.parentTaskId && historyItem.status !== "interrupted") { + historyItem = { ...historyItem, status: "interrupted" } + await this.updateTaskHistory(historyItem) + } + if (task.parentTaskId) { try { await this.runDelegationTransition(task.parentTaskId, async () => { @@ -3362,6 +3378,10 @@ export class ClineProvider } } + if (options.rehydrate === false) { + return + } + // Clears task again, so we need to abortTask manually above. await this.createTaskWithHistoryItem({ ...historyItem, rootTask, parentTask }) } diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 13d7b06c96..1af0427b29 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -1184,11 +1184,6 @@ "count": 7 } }, - "extension/api.ts": { - "@typescript-eslint/no-explicit-any": { - "count": 9 - } - }, "i18n/index.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 diff --git a/src/extension/__tests__/api-headless.spec.ts b/src/extension/__tests__/api-headless.spec.ts new file mode 100644 index 0000000000..7e3e550f6b --- /dev/null +++ b/src/extension/__tests__/api-headless.spec.ts @@ -0,0 +1,119 @@ +import { EventEmitter } from "events" + +import { beforeEach, describe, expect, it, vi } from "vitest" +import * as vscode from "vscode" + +import { RooCodeEventName } from "@roo-code/types" + +import { ClineProvider } from "../../core/webview/ClineProvider" +import { API } from "../api" + +vi.mock("vscode") +vi.mock("../../core/webview/ClineProvider") + +type FakeTask = EventEmitter & { + taskId: string + rootTaskId?: string + parentTaskId?: string + pendingAskId?: number + respondToAsk: ReturnType +} + +function createTask(taskId: string, options: { rootTaskId?: string; parentTaskId?: string } = {}): FakeTask { + return Object.assign(new EventEmitter(), { + taskId, + ...options, + respondToAsk: vi.fn().mockReturnValue(true), + }) +} + +describe("API headless facade", () => { + let api: API + let providerEvents: EventEmitter + let history: Map + let provider: ClineProvider + let rootTask: FakeTask + let currentTask: FakeTask | undefined + let createTaskMock: ReturnType + + beforeEach(() => { + providerEvents = new EventEmitter() + history = new Map() + rootTask = createTask("root-1") + currentTask = rootTask + createTaskMock = vi.fn().mockImplementation(async () => { + providerEvents.emit(RooCodeEventName.TaskCreated, rootTask) + return rootTask + }) + + const fakeProvider = { + context: {}, + on: providerEvents.on.bind(providerEvents), + waitUntilReady: vi.fn().mockResolvedValue(undefined), + createTask: createTaskMock, + createTaskWithHistoryItem: vi.fn(), + getTaskWithId: vi.fn(), + getTaskById: vi.fn((taskId: string) => (taskId === currentTask?.taskId ? currentTask : undefined)), + getCurrentTask: vi.fn(() => currentTask), + cancelTask: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn().mockResolvedValue(undefined), + taskHistoryStore: { get: (taskId: string) => history.get(taskId) }, + } + // ClineProvider is concrete and has private state; this precise fake exercises only the API boundary above. + provider = fakeProvider as unknown as ClineProvider + api = new API({ appendLine: vi.fn() } as unknown as vscode.OutputChannel, provider) + }) + + it("starts directly without invoking VS Code or a webview", async () => { + await expect(api.startHeadlessTask({ text: " preserve whitespace " })).resolves.toEqual({ + taskId: "root-1", + rootTaskId: "root-1", + }) + expect(createTaskMock).toHaveBeenCalledWith(" preserve whitespace ", undefined, undefined, {}, undefined) + expect(vscode.commands.executeCommand).not.toHaveBeenCalled() + }) + + it("routes a response only to the matching task and ask", async () => { + await api.startHeadlessTask({ text: "task" }) + await api.respondToHeadlessAsk({ taskId: "root-1", askId: "42", response: { response: "reject" } }) + expect(rootTask.respondToAsk).toHaveBeenCalledWith(42, "noButtonClicked", undefined, undefined) + await expect( + api.respondToHeadlessAsk({ taskId: "other", askId: "42", response: { response: "approve" } }), + ).rejects.toThrow("not active") + }) + + it("ignores child completion and waits for persisted root completion", async () => { + await api.startHeadlessTask({ text: "delegate" }) + const child = createTask("child-1", { rootTaskId: "root-1", parentTaskId: "root-1" }) + providerEvents.emit(RooCodeEventName.TaskCreated, child) + child.emit(RooCodeEventName.TaskCompleted, "child-1", {}, {}) + expect(await api.getHeadlessTaskResult("root-1")).toBeUndefined() + + history.set("root-1", { status: "completed" }) + rootTask.emit(RooCodeEventName.TaskCompleted, "root-1", { totalTokensIn: 1 }, {}) + await expect(api.waitForHeadlessTaskResult("root-1")).resolves.toMatchObject({ + outcome: "completed", + rootTaskId: "root-1", + }) + }) + + it("settles cancellation only after canonical cancellation returns", async () => { + await api.startHeadlessTask({ text: "cancel" }) + history.set("root-1", { status: "interrupted" }) + const settlement = await api.cancelHeadlessTask({ rootTaskId: "root-1", reason: "signal" }) + expect(provider.cancelTask).toHaveBeenCalledWith({ rehydrate: false }) + expect(settlement).toEqual({ rootTaskId: "root-1", resumable: true, status: "interrupted" }) + await expect(api.waitForHeadlessTaskResult("root-1")).resolves.toMatchObject({ outcome: "cancelled" }) + }) + + it("settles pending runs and disposes exactly once on shutdown", async () => { + await api.startHeadlessTask({ text: "pending" }) + await expect(api.shutdownHeadless()).resolves.toEqual({ settledRuns: 1, pendingRuns: 1 }) + await expect(api.waitForHeadlessTaskResult("root-1")).resolves.toMatchObject({ + outcome: "failed", + error: { code: "shutdown" }, + }) + await api.shutdownHeadless() + expect(provider.dispose).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/extension/api.ts b/src/extension/api.ts index 2d0d5a6975..f2f60c2390 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -14,6 +14,12 @@ import { type ProviderSettingsEntry, type TaskEvent, type CreateTaskOptions, + type HeadlessAskResponse, + type HeadlessCancelSettlement, + type HeadlessCapabilities, + type HeadlessShutdownReport, + type HeadlessTaskReference, + type HeadlessTaskResult, RooCodeEventName, TaskCommandName, isSecretStateKey, @@ -30,6 +36,16 @@ import { openClineInNewTab } from "../activate/registerCommands" import { getCommands } from "../services/command/commands" import { getModels } from "../api/providers/fetchers/modelCache" +type HeadlessRun = { + rootTaskId: string + currentTaskId: string + cancellationRequested: boolean + completionContent?: string + result?: HeadlessTaskResult + promise: Promise + resolve: (result: HeadlessTaskResult) => void +} + export class API extends EventEmitter implements RooCodeAPI { private readonly outputChannel: vscode.OutputChannel private readonly sidebarProvider: ClineProvider @@ -37,6 +53,8 @@ export class API extends EventEmitter implements RooCodeAPI { private readonly ipc?: IpcServer private readonly log: (...args: unknown[]) => void private logfile?: string + private readonly headlessRuns = new Map() + private headlessShutdown = false constructor( outputChannel: vscode.OutputChannel, @@ -167,6 +185,202 @@ export class API extends EventEmitter implements RooCodeAPI { return super.emit(eventName, ...args) } + public async initializeHeadless(): Promise { + if (this.headlessShutdown) throw new Error("Headless API is shut down") + await this.sidebarProvider.waitUntilReady() + return { checkpoints: false, typedAsks: true, rootTaskResults: true } + } + + public async startHeadlessTask({ + text, + images, + configuration, + }: { + text: string + images?: string[] + configuration?: RooCodeSettings + }): Promise { + await this.initializeHeadless() + if (!text.trim()) throw new Error("Headless task text must not be blank") + if ([...this.headlessRuns.values()].some((run) => !run.result)) { + throw new Error("A headless root task is already active") + } + const task = await this.sidebarProvider.createTask(text, images, undefined, {}, configuration) + const rootTaskId = task.rootTaskId ?? task.taskId + this.createHeadlessRun(rootTaskId, task.taskId) + return { taskId: task.taskId, rootTaskId } + } + + public async resumeHeadlessTask(taskId: string): Promise { + await this.initializeHeadless() + if ([...this.headlessRuns.values()].some((run) => !run.result)) { + throw new Error("A headless root task is already active") + } + const { historyItem } = await this.sidebarProvider.getTaskWithId(taskId) + const task = await this.sidebarProvider.createTaskWithHistoryItem(historyItem) + const rootTaskId = historyItem.rootTaskId ?? historyItem.id + this.createHeadlessRun(rootTaskId, task.taskId) + return { taskId: task.taskId, rootTaskId } + } + + public async respondToHeadlessAsk({ + taskId, + askId, + response, + }: { + taskId: string + askId: string + response: HeadlessAskResponse + }): Promise { + const task = this.sidebarProvider.getTaskById(taskId) + if (!task) throw new Error(`Task ${taskId} is not active`) + const numericAskId = Number(askId) + if (!Number.isSafeInteger(numericAskId)) throw new Error(`Invalid ask ID ${askId}`) + const mappedResponse = + response.response === "approve" + ? "yesButtonClicked" + : response.response === "reject" + ? "noButtonClicked" + : "messageResponse" + const accepted = task.respondToAsk( + numericAskId, + mappedResponse, + response.response === "message" ? response.text : undefined, + response.response === "message" ? response.images : undefined, + ) + if (!accepted) throw new Error(`Ask ${askId} is not pending on task ${taskId}`) + } + + public async cancelHeadlessTask({ + rootTaskId, + }: { + rootTaskId: string + reason: "user" | "signal" | "timeout" + }): Promise { + const run = this.headlessRuns.get(rootTaskId) + if (!run || run.result) throw new Error(`Root task ${rootTaskId} is not active`) + const currentTask = this.sidebarProvider.getCurrentTask() + if (!currentTask || (currentTask.rootTaskId ?? currentTask.taskId) !== rootTaskId) { + throw new Error(`Root task ${rootTaskId} is not current`) + } + run.cancellationRequested = true + try { + await this.sidebarProvider.cancelTask({ rehydrate: false }) + const history = this.sidebarProvider.taskHistoryStore.get(currentTask.taskId) + const resumable = history?.status === "interrupted" + this.settleHeadlessRun(run, { + rootTaskId, + currentTaskId: currentTask.taskId, + outcome: "cancelled", + resumable, + }) + return { rootTaskId, resumable, status: "interrupted" } + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + this.settleHeadlessRun(run, { + rootTaskId, + currentTaskId: currentTask.taskId, + outcome: "failed", + resumable: false, + error: { code: "cancel_failed", message }, + }) + return { rootTaskId, resumable: false, status: "failed" } + } + } + + public async getHeadlessTaskResult(rootTaskId: string): Promise { + return this.headlessRuns.get(rootTaskId)?.result + } + + public async waitForHeadlessTaskResult(rootTaskId: string): Promise { + const run = this.headlessRuns.get(rootTaskId) + if (!run) throw new Error(`Unknown headless root task ${rootTaskId}`) + return run.promise + } + + public async shutdownHeadless(): Promise { + if (this.headlessShutdown) { + const pendingRuns = [...this.headlessRuns.values()].filter((run) => !run.result).length + return { settledRuns: this.headlessRuns.size - pendingRuns, pendingRuns } + } + this.headlessShutdown = true + const pending = [...this.headlessRuns.values()].filter((run) => !run.result) + for (const run of pending) { + this.settleHeadlessRun(run, { + rootTaskId: run.rootTaskId, + currentTaskId: run.currentTaskId, + outcome: "failed", + resumable: false, + error: { code: "shutdown", message: "Headless API shut down before task settlement" }, + }) + } + await this.sidebarProvider.dispose() + return { settledRuns: this.headlessRuns.size, pendingRuns: pending.length } + } + + private createHeadlessRun(rootTaskId: string, currentTaskId: string): HeadlessRun { + let resolve!: (result: HeadlessTaskResult) => void + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + const run: HeadlessRun = { rootTaskId, currentTaskId, cancellationRequested: false, promise, resolve } + this.headlessRuns.set(rootTaskId, run) + return run + } + + private settleHeadlessRun(run: HeadlessRun, result: HeadlessTaskResult): void { + if (run.result) return + run.result = result + run.resolve(result) + this.emit(RooCodeEventName.HeadlessTaskResult, { + rootTaskId: result.rootTaskId, + currentTaskId: result.currentTaskId, + outcome: result.outcome, + resumable: result.resumable, + content: result.content, + historyItem: this.sidebarProvider.taskHistoryStore.get(result.rootTaskId), + }) + } + + private async settleCompletedHeadlessRun( + taskId: string, + tokenUsage: HeadlessTaskResult["tokenUsage"], + toolUsage: HeadlessTaskResult["toolUsage"], + ): Promise { + const run = this.headlessRuns.get(taskId) + if (!run || run.result || run.cancellationRequested) return + try { + await pWaitFor(() => this.sidebarProvider.taskHistoryStore.get(taskId)?.status === "completed", { + timeout: 5_000, + interval: 20, + }) + this.settleHeadlessRun(run, { + rootTaskId: taskId, + currentTaskId: taskId, + outcome: "completed", + resumable: false, + content: run.completionContent, + tokenUsage, + toolUsage, + }) + } catch { + const result: HeadlessTaskResult = { + rootTaskId: taskId, + currentTaskId: taskId, + outcome: "failed", + resumable: false, + error: { code: "task_failed", message: "Root completion was not persisted" }, + } + this.emit(RooCodeEventName.HeadlessTerminalFailure, { + taskId, + rootTaskId: taskId, + code: "task_failed", + message: result.error!.message, + }) + this.settleHeadlessRun(run, result) + } + } + public async startNewTask({ configuration, text, @@ -329,6 +543,9 @@ export class API extends EventEmitter implements RooCodeAPI { private registerListeners(provider: ClineProvider) { provider.on(RooCodeEventName.TaskCreated, (task) => { + const rootTaskId = task.rootTaskId ?? task.taskId + const existingRun = this.headlessRuns.get(rootTaskId) + if (existingRun) existingRun.currentTaskId = task.taskId // Task Lifecycle task.on(RooCodeEventName.TaskStarted, async () => { @@ -344,10 +561,30 @@ export class API extends EventEmitter implements RooCodeAPI { await this.fileLog( `[${new Date().toISOString()}] taskCompleted -> ${task.taskId} | ${JSON.stringify(tokenUsage, null, 2)} | ${JSON.stringify(toolUsage, null, 2)}\n`, ) + if (!task.parentTaskId && task.taskId === rootTaskId) { + void this.settleCompletedHeadlessRun(task.taskId, tokenUsage, toolUsage) + } }) task.on(RooCodeEventName.TaskAborted, () => { this.emit(RooCodeEventName.TaskAborted, task.taskId) + const run = this.headlessRuns.get(rootTaskId) + if (run && !run.result && !run.cancellationRequested && task.taskId === rootTaskId) { + const result: HeadlessTaskResult = { + rootTaskId, + currentTaskId: task.taskId, + outcome: "failed", + resumable: false, + error: { code: "task_failed", message: "Root task aborted unexpectedly" }, + } + this.emit(RooCodeEventName.HeadlessTerminalFailure, { + taskId: task.taskId, + rootTaskId, + code: "task_failed", + message: result.error!.message, + }) + this.settleHeadlessRun(run, result) + } }) task.on(RooCodeEventName.TaskFocused, () => { @@ -388,22 +625,34 @@ export class API extends EventEmitter implements RooCodeAPI { this.emit(RooCodeEventName.TaskSpawned, task.taskId, childTaskId) }) - task.on(RooCodeEventName.TaskDelegated as any, (childTaskId: string) => { - ;(this.emit as any)(RooCodeEventName.TaskDelegated, task.taskId, childTaskId) - }) - - task.on(RooCodeEventName.TaskDelegationCompleted as any, (childTaskId: string, summary: string) => { - ;(this.emit as any)(RooCodeEventName.TaskDelegationCompleted, task.taskId, childTaskId, summary) - }) - - task.on(RooCodeEventName.TaskDelegationResumed as any, (childTaskId: string) => { - ;(this.emit as any)(RooCodeEventName.TaskDelegationResumed, task.taskId, childTaskId) - }) - // Task Execution task.on(RooCodeEventName.Message, async (message) => { this.emit(RooCodeEventName.Message, { taskId: task.taskId, ...message }) + const run = this.headlessRuns.get(rootTaskId) + if ( + run && + message.message.type === "say" && + message.message.say === "completion_result" && + message.message.partial !== true + ) { + run.completionContent = message.message.text + } + if ( + message.message.type === "ask" && + message.message.ask && + message.message.partial !== true && + !message.message.isAnswered + ) { + this.emit(RooCodeEventName.HeadlessAsk, { + taskId: task.taskId, + rootTaskId, + askId: String(message.message.ts), + ask: message.message.ask, + text: message.message.text, + isProtected: message.message.isProtected, + }) + } if (message.message.partial !== true) { await this.fileLog(`[${new Date().toISOString()}] ${JSON.stringify(message.message, null, 2)}\n`) @@ -439,13 +688,13 @@ export class API extends EventEmitter implements RooCodeAPI { // Delegation events are emitted by the provider, not by individual task instances. provider.on(RooCodeEventName.TaskDelegated, (parentTaskId, childTaskId) => { - ;(this.emit as any)(RooCodeEventName.TaskDelegated, parentTaskId, childTaskId) + this.emit(RooCodeEventName.TaskDelegated, parentTaskId, childTaskId) }) provider.on(RooCodeEventName.TaskDelegationCompleted, (parentTaskId, childTaskId, summary) => { - ;(this.emit as any)(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, summary) + this.emit(RooCodeEventName.TaskDelegationCompleted, parentTaskId, childTaskId, summary) }) provider.on(RooCodeEventName.TaskDelegationResumed, (parentTaskId, childTaskId) => { - ;(this.emit as any)(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) + this.emit(RooCodeEventName.TaskDelegationResumed, parentTaskId, childTaskId) }) } From f6ecb17b7a7e6ef986287c72232b2437a5aec503 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 14:09:12 -0400 Subject: [PATCH 2/3] fix(api): ignore cancellation after completion --- src/extension/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/extension/api.ts b/src/extension/api.ts index f2f60c2390..d5a976db52 100644 --- a/src/extension/api.ts +++ b/src/extension/api.ts @@ -474,6 +474,8 @@ export class API extends EventEmitter implements RooCodeAPI { } public async cancelCurrentTask() { + const currentTask = this.sidebarProvider.getCurrentTask() + if (currentTask && this.sidebarProvider.taskHistoryStore.get(currentTask.taskId)?.status === "completed") return await this.sidebarProvider.cancelTask() } From 7a6c363ecd3c3bbe0d38faa7db91432188118617 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 15:02:22 -0400 Subject: [PATCH 3/3] test(api): cover headless lifecycle failures --- .../ask-clear-approval-buttons.spec.ts | 15 ++ .../ClineProvider.flicker-free-cancel.spec.ts | 44 ++++++ src/extension/__tests__/api-headless.spec.ts | 128 ++++++++++++++++++ 3 files changed, 187 insertions(+) diff --git a/src/core/task/__tests__/ask-clear-approval-buttons.spec.ts b/src/core/task/__tests__/ask-clear-approval-buttons.spec.ts index 240dc9f22a..ce5af1f192 100644 --- a/src/core/task/__tests__/ask-clear-approval-buttons.spec.ts +++ b/src/core/task/__tests__/ask-clear-approval-buttons.spec.ts @@ -35,6 +35,21 @@ async function attachQueue(task: Task) { } describe("Task.ask auto-approval stamping", () => { + it("accepts a response only for the exact pending headless ask", () => { + const task = buildTask(undefined) + const handleWebviewAskResponse = vi.fn() + task["pendingHeadlessAskId"] = 42 + task["handleWebviewAskResponse"] = handleWebviewAskResponse + + expect(task.pendingAskId).toBe(42) + expect(task.respondToAsk(41, "messageResponse", "wrong")).toBe(false) + expect(task.pendingAskId).toBe(42) + expect(task.respondToAsk(42, "messageResponse", "answer", ["image"])).toBe(true) + expect(handleWebviewAskResponse).toHaveBeenCalledWith("messageResponse", "answer", ["image"]) + expect(task.pendingAskId).toBeUndefined() + expect(task.respondToAsk(42, "messageResponse")).toBe(false) + }) + it("stamps isAnswered:true on the message when a command ask is auto-approved", async () => { const postMessageToWebview = vi.fn().mockResolvedValue(undefined) const provider: ProviderStub = { diff --git a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts index 3513bd3bd5..e5b77e8ac3 100644 --- a/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.flicker-free-cancel.spec.ts @@ -403,6 +403,14 @@ describe("ClineProvider flicker-free cancel", () => { await provider.dispose() }) + it("exposes readiness and registered tasks to headless callers", async () => { + seedRegistry(provider, mockTask1) + + await expect(provider.waitUntilReady()).resolves.toBeUndefined() + expect(provider.getTaskById("task-1")).toBe(mockTask1) + expect(provider.getTaskById("missing")).toBeUndefined() + }) + it("should not remove current task from stack when rehydrating same taskId", async () => { // Setup: Add a task to the registry first seedRegistry(provider, mockTask1) @@ -656,6 +664,42 @@ describe("ClineProvider flicker-free cancel", () => { ) }) + it("persists a top-level interruption without rehydrating a headless cancellation", async () => { + const historyItem: HistoryItem = { + id: "root-1", + number: 1, + task: "root task", + ts: Date.now(), + tokensIn: 10, + tokensOut: 20, + totalCost: 0.001, + workspace: "/test/workspace", + status: "active", + } + Object.assign(mockTask1, { + taskId: "root-1", + instanceId: "instance-root", + parentTaskId: undefined, + cancelCurrentRequest: vi.fn(), + abortTask: vi.fn().mockResolvedValue(undefined), + abandoned: false, + isStreaming: false, + didFinishAbortingStream: true, + isWaitingForFirstChunk: false, + }) + seedRegistry(provider, mockTask1) + provider.getTaskWithId = vi.fn().mockResolvedValue({ historyItem }) as unknown as ClineProvider["getTaskWithId"] + const updateTaskHistorySpy = vi.spyOn(provider, "updateTaskHistory").mockResolvedValue([]) + const createTaskWithHistoryItemSpy = vi.spyOn(provider, "createTaskWithHistoryItem") + + await provider.cancelTask({ rehydrate: false }) + + expect(updateTaskHistorySpy).toHaveBeenCalledWith( + expect.objectContaining({ id: "root-1", status: "interrupted" }), + ) + expect(createTaskWithHistoryItemSpy).not.toHaveBeenCalled() + }) + it("detaches runtime parent links when delegated parent detach fails", async () => { const mockRootTask = { taskId: "root-1" } const mockParentTask = { taskId: "parent-1" } diff --git a/src/extension/__tests__/api-headless.spec.ts b/src/extension/__tests__/api-headless.spec.ts index 7e3e550f6b..7187e3a5e5 100644 --- a/src/extension/__tests__/api-headless.spec.ts +++ b/src/extension/__tests__/api-headless.spec.ts @@ -10,6 +10,11 @@ import { API } from "../api" vi.mock("vscode") vi.mock("../../core/webview/ClineProvider") +vi.mock("p-wait-for", () => ({ + default: vi.fn(async (condition: () => boolean) => { + if (!condition()) throw new Error("condition not met") + }), +})) type FakeTask = EventEmitter & { taskId: string @@ -73,10 +78,48 @@ describe("API headless facade", () => { expect(vscode.commands.executeCommand).not.toHaveBeenCalled() }) + it("validates initialization and active-run boundaries", async () => { + await expect(api.startHeadlessTask({ text: " " })).rejects.toThrow("must not be blank") + await api.startHeadlessTask({ text: "active" }) + await expect(api.startHeadlessTask({ text: "second" })).rejects.toThrow("already active") + await expect(api.resumeHeadlessTask("other")).rejects.toThrow("already active") + await expect(api.waitForHeadlessTaskResult("missing")).rejects.toThrow("Unknown headless root task") + await api.shutdownHeadless() + await expect(api.initializeHeadless()).rejects.toThrow("shut down") + }) + + it("resumes history directly and preserves its root identity", async () => { + const historyItem = { id: "child-1", rootTaskId: "root-1", status: "interrupted" } + const resumedTask = createTask("child-1", { rootTaskId: "root-1", parentTaskId: "root-1" }) + vi.mocked(provider.getTaskWithId).mockResolvedValue({ historyItem } as never) + vi.mocked(provider.createTaskWithHistoryItem).mockResolvedValue(resumedTask as never) + + await expect(api.resumeHeadlessTask("child-1")).resolves.toEqual({ + taskId: "child-1", + rootTaskId: "root-1", + }) + expect(provider.createTaskWithHistoryItem).toHaveBeenCalledWith(historyItem) + }) + it("routes a response only to the matching task and ask", async () => { await api.startHeadlessTask({ text: "task" }) await api.respondToHeadlessAsk({ taskId: "root-1", askId: "42", response: { response: "reject" } }) expect(rootTask.respondToAsk).toHaveBeenCalledWith(42, "noButtonClicked", undefined, undefined) + await api.respondToHeadlessAsk({ taskId: "root-1", askId: "43", response: { response: "approve" } }) + expect(rootTask.respondToAsk).toHaveBeenCalledWith(43, "yesButtonClicked", undefined, undefined) + await api.respondToHeadlessAsk({ + taskId: "root-1", + askId: "44", + response: { response: "message", text: "answer", images: ["image"] }, + }) + expect(rootTask.respondToAsk).toHaveBeenCalledWith(44, "messageResponse", "answer", ["image"]) + await expect( + api.respondToHeadlessAsk({ taskId: "root-1", askId: "unsafe", response: { response: "approve" } }), + ).rejects.toThrow("Invalid ask ID") + rootTask.respondToAsk.mockReturnValueOnce(false) + await expect( + api.respondToHeadlessAsk({ taskId: "root-1", askId: "45", response: { response: "approve" } }), + ).rejects.toThrow("is not pending") await expect( api.respondToHeadlessAsk({ taskId: "other", askId: "42", response: { response: "approve" } }), ).rejects.toThrow("not active") @@ -88,15 +131,76 @@ describe("API headless facade", () => { providerEvents.emit(RooCodeEventName.TaskCreated, child) child.emit(RooCodeEventName.TaskCompleted, "child-1", {}, {}) expect(await api.getHeadlessTaskResult("root-1")).toBeUndefined() + rootTask.emit(RooCodeEventName.Message, { + message: { + ts: 41, + type: "say", + say: "completion_result", + text: "final answer", + partial: false, + }, + }) history.set("root-1", { status: "completed" }) rootTask.emit(RooCodeEventName.TaskCompleted, "root-1", { totalTokensIn: 1 }, {}) await expect(api.waitForHeadlessTaskResult("root-1")).resolves.toMatchObject({ outcome: "completed", rootTaskId: "root-1", + content: "final answer", }) }) + it("projects pending asks and settles an unexpected root abort", async () => { + const askListener = vi.fn() + const failureListener = vi.fn() + api.on(RooCodeEventName.HeadlessAsk, askListener) + api.on(RooCodeEventName.HeadlessTerminalFailure, failureListener) + await api.startHeadlessTask({ text: "abort" }) + + rootTask.emit(RooCodeEventName.Message, { + message: { + ts: 42, + type: "ask", + ask: "followup", + text: "Continue?", + partial: false, + isAnswered: false, + }, + }) + expect(askListener).toHaveBeenCalledWith({ + taskId: "root-1", + rootTaskId: "root-1", + askId: "42", + ask: "followup", + text: "Continue?", + isProtected: undefined, + }) + + rootTask.emit(RooCodeEventName.TaskAborted) + await expect(api.waitForHeadlessTaskResult("root-1")).resolves.toMatchObject({ + outcome: "failed", + error: { code: "task_failed", message: "Root task aborted unexpectedly" }, + }) + expect(failureListener).toHaveBeenCalledWith( + expect.objectContaining({ taskId: "root-1", rootTaskId: "root-1", code: "task_failed" }), + ) + }) + + it("reports completion that was not persisted as a terminal failure", async () => { + const failureListener = vi.fn() + api.on(RooCodeEventName.HeadlessTerminalFailure, failureListener) + await api.startHeadlessTask({ text: "complete" }) + + rootTask.emit(RooCodeEventName.TaskCompleted, "root-1", {}, {}) + await expect(api.waitForHeadlessTaskResult("root-1")).resolves.toMatchObject({ + outcome: "failed", + error: { code: "task_failed", message: "Root completion was not persisted" }, + }) + expect(failureListener).toHaveBeenCalledWith( + expect.objectContaining({ taskId: "root-1", rootTaskId: "root-1", code: "task_failed" }), + ) + }) + it("settles cancellation only after canonical cancellation returns", async () => { await api.startHeadlessTask({ text: "cancel" }) history.set("root-1", { status: "interrupted" }) @@ -106,6 +210,30 @@ describe("API headless facade", () => { await expect(api.waitForHeadlessTaskResult("root-1")).resolves.toMatchObject({ outcome: "cancelled" }) }) + it("settles cancellation failures without leaving the run pending", async () => { + await api.startHeadlessTask({ text: "cancel" }) + vi.mocked(provider.cancelTask).mockRejectedValueOnce(new Error("cancel exploded")) + + await expect(api.cancelHeadlessTask({ rootTaskId: "root-1", reason: "user" })).resolves.toEqual({ + rootTaskId: "root-1", + resumable: false, + status: "failed", + }) + await expect(api.waitForHeadlessTaskResult("root-1")).resolves.toMatchObject({ + outcome: "failed", + error: { code: "cancel_failed", message: "cancel exploded" }, + }) + }) + + it("rejects cancellation when the requested root is not current", async () => { + await api.startHeadlessTask({ text: "cancel" }) + currentTask = createTask("other-root") + + await expect(api.cancelHeadlessTask({ rootTaskId: "root-1", reason: "timeout" })).rejects.toThrow( + "is not current", + ) + }) + it("settles pending runs and disposes exactly once on shutdown", async () => { await api.startHeadlessTask({ text: "pending" }) await expect(api.shutdownHeadless()).resolves.toEqual({ settledRuns: 1, pendingRuns: 1 })