Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions packages/types/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RooCodeAPIEvents> {
initializeHeadless(): Promise<HeadlessCapabilities>
startHeadlessTask(input: {
text: string
images?: string[]
configuration?: RooCodeSettings
}): Promise<HeadlessTaskReference>
resumeHeadlessTask(taskId: string): Promise<HeadlessTaskReference>
respondToHeadlessAsk(input: { taskId: string; askId: string; response: HeadlessAskResponse }): Promise<void>
cancelHeadlessTask(input: {
rootTaskId: string
reason: "user" | "signal" | "timeout"
}): Promise<HeadlessCancelSettlement>
getHeadlessTaskResult(rootTaskId: string): Promise<HeadlessTaskResult | undefined>
waitForHeadlessTaskResult(rootTaskId: string): Promise<HeadlessTaskResult>
shutdownHeadless(): Promise<HeadlessShutdownReport>
/**
* Starts a new task with an optional initial message and images.
* @param task Optional initial task message.
Expand Down
41 changes: 41 additions & 0 deletions packages/types/src/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,6 +51,11 @@ export enum RooCodeEventName {
CommandsResponse = "commandsResponse",
ModesResponse = "modesResponse",
ModelsResponse = "modelsResponse",

// Direct headless API
HeadlessAsk = "headlessAsk",
HeadlessTerminalFailure = "headlessTerminalFailure",
HeadlessTaskResult = "headlessTaskResult",
}

/**
Expand Down Expand Up @@ -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<typeof rooCodeEventsSchema>
Expand Down Expand Up @@ -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<typeof taskEventSchema>
22 changes: 22 additions & 0 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1265,6 +1265,7 @@ export class Task extends EventEmitter<TaskEvents> 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
Expand All @@ -1286,6 +1287,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.askResponseText = undefined
this.askResponseImages = undefined
askTs = Date.now()
this.pendingHeadlessAskId = askTs
this.lastMessageTs = askTs
await this.addToClineMessages({
ts: askTs,
Expand All @@ -1304,6 +1306,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
this.askResponseText = undefined
this.askResponseImages = undefined
askTs = Date.now()
this.pendingHeadlessAskId = askTs
this.lastMessageTs = askTs
await this.addToClineMessages({
ts: askTs,
Expand All @@ -1316,6 +1319,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
})
}

this.pendingHeadlessAskId = askTs
const timeouts: NodeJS.Timeout[] = []

if (approval.decision === "approve") {
Expand Down Expand Up @@ -1429,10 +1433,14 @@ export class Task extends EventEmitter<TaskEvents> 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.
Expand All @@ -1443,6 +1451,7 @@ export class Task extends EventEmitter<TaskEvents> 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))
Expand Down Expand Up @@ -1510,6 +1519,19 @@ export class Task extends EventEmitter<TaskEvents> 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.
Expand Down
15 changes: 15 additions & 0 deletions src/core/task/__tests__/ask-clear-approval-buttons.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
28 changes: 24 additions & 4 deletions src/core/webview/ClineProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`)
})

Expand Down Expand Up @@ -3054,6 +3055,16 @@ export class ClineProvider
return this.taskRegistry.current
}

private readonly taskHistoryInitialization: Promise<void>

public waitUntilReady(): Promise<void> {
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) {
Expand Down Expand Up @@ -3221,18 +3232,18 @@ export class ClineProvider
return task
}

public async cancelTask(): Promise<void> {
public async cancelTask(options: { rehydrate?: boolean } = {}): Promise<void> {
const task = this.getCurrentTask()

if (!task) {
return
}

console.log(`[cancelTask] cancelling task ${task.taskId}.${task.instanceId}`)
await this.cancelTaskInternal(task)
await this.cancelTaskInternal(task, options)
}

private async cancelTaskInternal(task: Task): Promise<void> {
private async cancelTaskInternal(task: Task, options: { rehydrate?: boolean }): Promise<void> {
let historyItem: HistoryItem | undefined
try {
const history = await this.getTaskWithId(task.taskId)
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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" }
Expand Down
5 changes: 0 additions & 5 deletions src/eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading