Skip to content
Draft
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
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