Skip to content
Merged
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@ This file provides guidance to agents when working with code in this repository.
- Settings View Pattern: When working on `SettingsView`, inputs must bind to the local `cachedState`, NOT the live `useExtensionState()`. The `cachedState` acts as a buffer for user edits, isolating them from the `ContextProxy` source-of-truth until the user explicitly clicks "Save". Wiring inputs directly to the live state causes race conditions.
- Changesets: Do NOT create `.changeset` files for each commit or code change. Changesets are managed separately by maintainers and should not be generated by agents during normal development.

## ESLint Suppressions

`src/eslint-suppressions.json` tracks per-file counts of suppressed lint rules. Suppression counts must never increase. When touching a file, prefer reducing its count when the fix is local and low-risk; avoid broad unrelated cleanup.

When writing new code:

- Fix lint violations in the new code rather than suppressing them.
- Avoid `as any`; use typed APIs directly (e.g. `RooCodeEventName.X` constants with typed `on()`/`listenerCount()`), or bracket notation (`obj["privateField"]`) to access private members. Prefer precise test doubles or `unknown` with a type guard over double assertions (`as unknown as T`); use double assertions only as a last resort, with a comment explaining why.
- Avoid floating promises; add `void`, `await`, or `.catch()` as appropriate.
- After editing a file, run `pnpm --dir src exec eslint --prune-suppressions --max-warnings=0 <relative-file>` and confirm the count for that file did not increase.
- If a suppression is truly unavoidable (e.g. `vi.spyOn(Cls.prototype as any, "privateMethod")` where no typed alternative exists), document why in a comment next to the cast.

## Test Placement Guidance

Prefer the narrowest test layer that proves the behavior. This follows standard test-pyramid guidance: keep most coverage in fast, focused tests; add integration tests for cross-module contracts; reserve end-to-end tests for full workflow confidence.
Expand Down
65 changes: 65 additions & 0 deletions apps/vscode-e2e/src/fixtures/subtasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@ export const SUBTASK_XPROFILE_SAME_CHILD_RESULT = "Same-profile child completed"
export const SUBTASK_XPROFILE_DIFFERENT_CHILD_RESULT = "Different-profile child completed"
export const SUBTASK_XPROFILE_PARENT_RESULT = "Sequential cross-profile parent resumed"

// Scheduler regression tests — exercises TaskScheduler + run() dispatch post-CodeRabbit fix.
// Separate markers to avoid collisions with the other subtask fixtures.
const SCHED_STANDALONE_MARKER = "SCHED_STANDALONE_INTERRUPT_RESUME"
const SCHED_COMPLETED_MARKER = "SCHED_COMPLETED_REOPEN"
export const SCHED_STANDALONE_PROMPT = `${SCHED_STANDALONE_MARKER}: Ask the user exactly this follow-up question: What is the square root of 64? After the user answers, complete with only the answer.`
export const SCHED_STANDALONE_FOLLOWUP_ANSWER = "8"
export const SCHED_COMPLETED_PROMPT = `${SCHED_COMPLETED_MARKER}: Complete immediately with the exact result "Scheduler completed task".`
export const SCHED_COMPLETED_RESULT = "Scheduler completed task"

const apiHangChildMatch = new RegExp(SUBTASK_API_HANG_CHILD_MARKER)

const requestContains = (req: ChatCompletionRequest, expected: string[]) => {
Expand Down Expand Up @@ -396,6 +405,62 @@ export function addSubtaskFixtures(mock: InstanceType<typeof LLMock>) {
},
})

// Scheduler regression fixtures: standalone interrupted task resume and completed task reopen.
mock.addFixture({
match: {
predicate: (req: ChatCompletionRequest) =>
requestContains(req, [SCHED_STANDALONE_MARKER]) &&
!requestContains(req, ["call_sched_standalone_followup_001"]) &&
!requestContains(req, [`<user_message>\\n${SCHED_STANDALONE_FOLLOWUP_ANSWER}\\n</user_message>`]),
},
response: {
toolCalls: [
{
name: "ask_followup_question",
arguments: JSON.stringify({
question: "What is the square root of 64?",
follow_up: [{ text: SCHED_STANDALONE_FOLLOWUP_ANSWER }],
}),
id: "call_sched_standalone_followup_001",
},
],
},
})

mock.addFixture({
match: {
predicate: (req: ChatCompletionRequest) =>
toolResultContains(req, "call_sched_standalone_followup_001", [SCHED_STANDALONE_FOLLOWUP_ANSWER]) ||
requestContains(req, ["call_sched_standalone_followup_001", SCHED_STANDALONE_FOLLOWUP_ANSWER]) ||
requestContains(req, [
SCHED_STANDALONE_MARKER,
`<user_message>\\n${SCHED_STANDALONE_FOLLOWUP_ANSWER}\\n</user_message>`,
]),
},
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: SCHED_STANDALONE_FOLLOWUP_ANSWER }),
id: "call_sched_standalone_completion_002",
},
],
},
})

mock.addFixture({
match: { userMessage: new RegExp(SCHED_COMPLETED_MARKER) },
response: {
toolCalls: [
{
name: "attempt_completion",
arguments: JSON.stringify({ result: SCHED_COMPLETED_RESULT }),
id: "call_sched_completed_completion_001",
},
],
},
})

// Interrupted-child-resumes-and-reports-back scenario (#560)
mock.addFixture({
match: {
Expand Down
139 changes: 139 additions & 0 deletions apps/vscode-e2e/src/suite/subtasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { RooCodeEventName, type ClineMessage } from "@roo-code/types"
import { setDefaultSuiteTimeout } from "./test-utils"
import { sleep, waitFor, waitUntilCompleted } from "./utils"
import {
SCHED_COMPLETED_PROMPT,
SCHED_COMPLETED_RESULT,
SCHED_STANDALONE_FOLLOWUP_ANSWER,
SCHED_STANDALONE_PROMPT,
SUBTASK_ABANDON_CHILD_FOLLOWUP_ANSWER,
SUBTASK_ABANDON_PARENT_PROMPT,
SUBTASK_API_HANG_CHILD_MARKER,
Expand Down Expand Up @@ -945,4 +949,139 @@ suite("Roo Code Subtasks", function () {
await waitFor(() => api.getCurrentTaskStack().length === 0).catch(() => {})
}
})

// TaskScheduler regression: resumeTask on a completed task must show resume_completed_task ask.
// Before the CodeRabbit fix, createTaskWithHistoryItem bypassed the scheduler and called
// Task.run() via the constructor's startTask: true default, causing run() to call startTask()
// (clearing history) instead of resumeTaskFromHistory().
test("resumeTask on a completed task presents resume_completed_task ask", async () => {
const api = globalThis.api
const asks: Record<string, ClineMessage[]> = {}
const says: Record<string, ClineMessage[]> = {}

const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
if (message.type === "ask") {
asks[taskId] = asks[taskId] || []
asks[taskId].push(message)
}
if (message.type === "say" && message.partial === false) {
says[taskId] = says[taskId] || []
says[taskId].push(message)
}
}

api.on(RooCodeEventName.Message, messageHandler)

try {
// Run a task to completion.
const taskId = await waitUntilCompleted({
api,
start: () =>
api.startNewTask({
configuration: {
mode: "ask",
autoApprovalEnabled: true,
enableCheckpoints: false,
},
text: SCHED_COMPLETED_PROMPT,
}),
})

assert.strictEqual(
says[taskId]?.find(({ say }) => say === "completion_result")?.text?.trim(),
SCHED_COMPLETED_RESULT,
"Task should complete with expected result",
)

// Re-open it via resumeTask — should hit resumeTaskFromHistory(), showing resume_completed_task.
await api.resumeTask(taskId)

await waitFor(
() => asks[taskId]?.some(({ type, ask }) => type === "ask" && ask === "resume_completed_task") ?? false,
)
} finally {
api.off(RooCodeEventName.Message, messageHandler)
while (api.getCurrentTaskStack().length > 0) {
await api.clearCurrentTask()
}
await sleep(500)
}
})

// TaskScheduler regression: resumeTask on an interrupted standalone task must show resume_task
// ask and allow the task to complete normally via the scheduler slot.
test("resumeTask on an interrupted standalone task presents resume_task ask and completes", async () => {
const api = globalThis.api
const asks: Record<string, ClineMessage[]> = {}
const says: Record<string, ClineMessage[]> = {}

const messageHandler = ({ taskId, message }: { taskId: string; message: ClineMessage }) => {
if (message.type === "ask") {
asks[taskId] = asks[taskId] || []
asks[taskId].push(message)
}
if (message.type === "say" && message.partial === false) {
says[taskId] = says[taskId] || []
says[taskId].push(message)
}
}

api.on(RooCodeEventName.Message, messageHandler)

let taskId: string | undefined

try {
taskId = await api.startNewTask({
configuration: {
mode: "ask",
autoApprovalEnabled: true,
enableCheckpoints: false,
},
text: SCHED_STANDALONE_PROMPT,
})

// Wait until the task pauses at the follow-up question.
await waitFor(() => asks[taskId!]?.some(({ type, ask }) => type === "ask" && ask === "followup") ?? false)

// Cancel it — the task becomes interrupted.
await api.cancelCurrentTask()

await waitFor(
() => asks[taskId!]?.some(({ type, ask }) => type === "ask" && ask === "resume_task") ?? false,
)

// Resume via scheduler path (createTaskWithHistoryItem).
const askCountBeforeResume = asks[taskId!]?.length ?? 0
await api.resumeTask(taskId!)

await waitFor(() =>
(asks[taskId!] ?? [])
.slice(askCountBeforeResume)
.some(({ type, ask }) => type === "ask" && ask === "resume_task"),
)

// Sending the answer both acknowledges the resume_task ask and answers the pending
// follow-up question from the original task, completing the task.
const completedTaskId = await waitUntilCompleted({
api,
start: async () => {
await api.sendMessage(SCHED_STANDALONE_FOLLOWUP_ANSWER)
return taskId!
},
})

assert.strictEqual(completedTaskId, taskId, "The resumed standalone task should complete")
assert.strictEqual(
says[taskId!]?.find(({ say }) => say === "completion_result")?.text?.trim(),
SCHED_STANDALONE_FOLLOWUP_ANSWER,
"Task should complete with the follow-up answer as result",
)
} finally {
api.off(RooCodeEventName.Message, messageHandler)
while (api.getCurrentTaskStack().length > 0) {
await api.clearCurrentTask()
}
await sleep(500)
}
})
})
Loading
Loading