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
105 changes: 101 additions & 4 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,100 @@ jobs:
VERSION=$(node -p 'require("./apps/vscode-e2e/package.json").devDependencies["@types/vscode"]')
echo "version=$VERSION" >> $GITHUB_OUTPUT

- name: Cache VS Code test binary
- name: Restore VS Code test binary cache
if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
id: vscode-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
apps/vscode-e2e/.vscode-test/
key: vscode-test-${{ runner.os }}-${{ steps.vscode-ver.outputs.version }}-v1
# Fall back to the most recent stale binary so a version bump or cache
# eviction doesn't force a download when the VS Code CDN is unreachable.
restore-keys: |
vscode-test-${{ runner.os }}-

- name: Probe VS Code download endpoints
if: (github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true') && steps.vscode-cache.outputs.cache-hit != 'true'
id: vscode-probe
# The version-resolution API and the archive CDN are different hosts. The
# archive URL 302-redirects to the CDN, so a HEAD probe that follows the
# redirect covers both. The fallback engages when either is unreachable.
run: |
API_OK=false
CDN_OK=false
if curl -sf --max-time 10 https://update.code.visualstudio.com/api/releases/stable > /dev/null; then
API_OK=true
fi
if curl -sfIL --max-time 20 -o /dev/null "https://update.code.visualstudio.com/${{ steps.vscode-ver.outputs.version }}/linux-x64/stable"; then
CDN_OK=true
fi
if [ "$API_OK" = "true" ] && [ "$CDN_OK" = "true" ]; then
echo "reachable=true" >> "$GITHUB_OUTPUT"
else
echo "reachable=false" >> "$GITHUB_OUTPUT"
echo "::warning::VS Code download endpoints unreachable (api=$API_OK, cdn=$CDN_OK); will try the stale cached binary"
fi

# @vscode/test-electron only skips its live version-resolution request when the
# requested version already exists in .vscode-test/. On an exact-key miss it calls
# the update API before its download retry loop, so an unreachable CDN fails the
# job before any test runs. When the endpoints are down, point the runner at the
# stale binary restored above (runTest.ts honors VSCODE_VERSION) so the suite
# still runs.
- name: Fall back to stale VS Code binary when CDN is unreachable
if: (github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true') && steps.vscode-cache.outputs.cache-hit != 'true' && steps.vscode-probe.outputs.reachable == 'false'
id: vscode-fallback
run: |
STALE=$(ls -d apps/vscode-e2e/.vscode-test/vscode-linux-x64-* 2>/dev/null | sort -V | tail -n 1 || true)
if [ -n "$STALE" ]; then
echo "VSCODE_VERSION=${STALE##*-}" >> "$GITHUB_ENV"
echo "used=true" >> "$GITHUB_OUTPUT"
echo "VS Code download endpoints are unreachable; falling back to cached VS Code ${STALE##*-}"
else
echo "::warning::VS Code download endpoints are unreachable and no cached binary is available; the download step will retry but may fail"
fi

# Retry only the binary download: version resolution and the archive fetch are
# the network-fragile parts. Genuine test failures should fail fast, so the
# test step itself is deliberately not retried.
- name: Download VS Code test binary
if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true'
id: vscode-download
run: |
cd apps/vscode-e2e
for attempt in 1 2 3; do
if node -e "const { downloadAndUnzipVSCode } = require('@vscode/test-electron'); const version = process.env.VSCODE_VERSION || require('./package.json').devDependencies['@types/vscode']; downloadAndUnzipVSCode(version).catch((err) => { console.error(err); process.exit(1); });"; then
exit 0
fi
echo "VS Code download attempt ${attempt} failed"
if [ "$attempt" -lt 3 ]; then
sleep 15
fi
done
exit 1

# restore-keys can bring back older binaries alongside the one just
# downloaded; keep only the effective version so each saved cache entry
# stays at one binary instead of growing with every version bump.
- name: Prune stale VS Code binaries
if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true'
run: |
EFFECTIVE="${VSCODE_VERSION:-${{ steps.vscode-ver.outputs.version }}}"
for dir in apps/vscode-e2e/.vscode-test/vscode-linux-x64-*; do
[ -e "$dir" ] || continue
if [ "$(basename "$dir")" != "vscode-linux-x64-$EFFECTIVE" ]; then
echo "Pruning stale VS Code binary $(basename "$dir")"
rm -rf "$dir"
fi
done

# Skip the save when the stale-binary fallback ran: the pinned-version key
# must not be populated with an older binary.
- name: Save VS Code test binary cache
if: (github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true') && steps.vscode-cache.outputs.cache-hit != 'true' && steps.vscode-fallback.outputs.used != 'true'
continue-on-error: true
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
path: |
apps/vscode-e2e/.vscode-test/
Expand All @@ -59,12 +150,18 @@ jobs:
if: github.event_name != 'pull_request' || steps.e2e-marker.outputs.cache-hit != 'true'
run: xvfb-run -a pnpm --filter @roo-code/vscode-e2e test:ci:mock

- name: Explain skipped mocked E2E pass marker
if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success' && steps.vscode-fallback.outputs.used == 'true'
run: echo "Skipping mocked E2E pass marker because tests ran against a stale cached VS Code binary (VS Code download endpoints unreachable)."

- name: Write mocked E2E pass marker
if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success'
# Skip when the stale-binary fallback ran: a pass against an older VS Code
# must not mint a marker for the intended-version source hash.
if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success' && steps.vscode-fallback.outputs.used != 'true'
run: mkdir -p .cache/e2e-pass && date -u > .cache/e2e-pass/passed

- name: Save mocked E2E pass marker
if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success'
if: steps.e2e-marker.outputs.cache-hit != 'true' && steps.run-e2e.outcome == 'success' && steps.vscode-fallback.outputs.used != 'true'
continue-on-error: true
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
with:
Expand Down
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)
}
})
})
8 changes: 8 additions & 0 deletions packages/types/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./model": {
"types": "./src/model.ts",
"import": "./src/model.ts"
},
"./provider-identifiers": {
"types": "./src/provider-identifiers.ts",
"import": "./src/provider-identifiers.ts"
}
},
"scripts": {
Expand Down
Loading
Loading