diff --git a/apps/vscode-e2e/fixtures/read-file.json b/apps/vscode-e2e/fixtures/read-file.json index 9839348abd..813b18703d 100644 --- a/apps/vscode-e2e/fixtures/read-file.json +++ b/apps/vscode-e2e/fixtures/read-file.json @@ -109,6 +109,21 @@ } ] } + }, + { + "match": { + "sequenceIndex": 0, + "userMessage": "READ_FILE_MULTI_ROOT_REPRO" + }, + "response": { + "toolCalls": [ + { + "name": "read_file", + "arguments": "{\"path\":\"secondary-root-read-file.txt\",\"mode\":\"slice\",\"offset\":1,\"limit\":50,\"indentation\":{\"anchor_line\":1,\"max_levels\":0,\"include_siblings\":false,\"include_header\":true,\"max_lines\":50}}", + "id": "call_read_file_multi_root_secondary_001" + } + ] + } } ] } diff --git a/apps/vscode-e2e/src/fixtures/read-file.ts b/apps/vscode-e2e/src/fixtures/read-file.ts index dd9bb1c10b..7c55d6cbcb 100644 --- a/apps/vscode-e2e/src/fixtures/read-file.ts +++ b/apps/vscode-e2e/src/fixtures/read-file.ts @@ -71,6 +71,12 @@ export function addReadFileResultFixtures(mock: InstanceType) { result: "The file [`large-read-file.txt`](large-read-file.txt) contains 100 lines, each following the pattern: `Line N: This is a test line with some content`, where `N` is the line number (from 1 to 100). The structure is consistent throughout the file, with only the line number changing on each line.", id: "call_read_file_large_002", }, + { + toolCallId: "call_read_file_multi_root_secondary_001", + expected: ["File: secondary-root-read-file.txt", "SECONDARY_ROOT_MARKER_204"], + result: "The read_file tool successfully read `secondary-root-read-file.txt` from the secondary workspace root. Its contents include `SECONDARY_ROOT_MARKER_204`.", + id: "call_read_file_multi_root_secondary_002", + }, ] for (const fixture of fixtures) { diff --git a/apps/vscode-e2e/src/runTest.ts b/apps/vscode-e2e/src/runTest.ts index b2550559b1..ba5b24fb49 100644 --- a/apps/vscode-e2e/src/runTest.ts +++ b/apps/vscode-e2e/src/runTest.ts @@ -26,11 +26,25 @@ function isDeepSeekTargetedRun(testFile?: string, testGrep?: string) { return testGrep?.toLowerCase().includes("deepseek") ?? false } +function isMultiRootTargetedRun(testFile?: string, testGrep?: string) { + if (testFile?.toLowerCase().includes("multi-root-read-file-content")) { + return true + } + + if (!testFile && !testGrep) { + // The default mocked CI path runs the whole suite, which includes the multi-root repro. + return true + } + + return testGrep?.toLowerCase().includes("multi-root") ?? false +} + async function main() { const isRecord = process.env.AIMOCK_RECORD === "true" const testGrep = getCliFlagValue("--grep") || process.env.TEST_GREP const testFile = getCliFlagValue("--file") || process.env.TEST_FILE const isDeepSeekTest = isDeepSeekTargetedRun(testFile, testGrep) + const isMultiRootTest = isMultiRootTargetedRun(testFile, testGrep) if (isRecord && isDeepSeekTest && !process.env.DEEPSEEK_API_KEY) { throw new Error("AIMOCK_RECORD=true requires DEEPSEEK_API_KEY to record DeepSeek fixtures") @@ -58,11 +72,28 @@ async function main() { const extensionTestsPath = path.resolve(__dirname, "./suite/index") let testWorkspace: string | undefined + let secondaryWorkspace: string | undefined + let multiRootWorkspaceFile: string | undefined try { // Create a temporary workspace folder for tests before installing fixtures that // need workspace-specific paths. testWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-workspace-")) + if (isMultiRootTest) { + secondaryWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "roo-test-secondary-workspace-")) + multiRootWorkspaceFile = path.join(os.tmpdir(), `roo-test-workspace-${Date.now()}.code-workspace`) + await fs.writeFile( + multiRootWorkspaceFile, + JSON.stringify( + { + folders: [{ path: testWorkspace }, { path: secondaryWorkspace }], + }, + null, + 2, + ), + "utf8", + ) + } if (useMock) { const fixturesDir = path.resolve(__dirname, "../fixtures") @@ -134,7 +165,7 @@ async function main() { await runTests({ extensionDevelopmentPath, extensionTestsPath, - launchArgs: [testWorkspace], + launchArgs: [multiRootWorkspaceFile ?? testWorkspace], extensionTestsEnv, version: process.env.VSCODE_VERSION || "1.101.2", }) @@ -142,6 +173,12 @@ async function main() { console.error("Failed to run tests", error) process.exitCode = 1 } finally { + if (multiRootWorkspaceFile) { + await fs.rm(multiRootWorkspaceFile, { force: true }) + } + if (secondaryWorkspace) { + await fs.rm(secondaryWorkspace, { recursive: true, force: true }) + } if (testWorkspace) { await fs.rm(testWorkspace, { recursive: true, force: true }) } diff --git a/apps/vscode-e2e/src/suite/multi-root-read-file-content.test.ts b/apps/vscode-e2e/src/suite/multi-root-read-file-content.test.ts new file mode 100644 index 0000000000..3da2752d4f --- /dev/null +++ b/apps/vscode-e2e/src/suite/multi-root-read-file-content.test.ts @@ -0,0 +1,96 @@ +import * as assert from "assert" +import * as fs from "fs/promises" +import * as path from "path" +import * as vscode from "vscode" + +import { RooCodeEventName, type ClineMessage } from "@roo-code/types" + +import { setDefaultSuiteTimeout } from "./test-utils" +import { waitFor } from "./utils" + +suite("Multi-root readFileContent repro", function () { + setDefaultSuiteTimeout(this) + + test("should read a file that exists only in the secondary workspace root", async () => { + await waitFor(() => (vscode.workspace.workspaceFolders?.length ?? 0) >= 2, { + timeout: 60_000, + interval: 250, + }) + + const primaryWorkspace = vscode.workspace.workspaceFolders?.[0] + assert.ok(primaryWorkspace, "Expected a primary workspace folder") + const secondaryWorkspace = vscode.workspace.workspaceFolders?.[1] + assert.ok(secondaryWorkspace, "Expected a secondary workspace folder") + + const primaryRoot = primaryWorkspace.uri.fsPath + const secondaryRoot = secondaryWorkspace.uri.fsPath + const secondaryFileName = "secondary-root-read-file.txt" + const expectedContent = "SECONDARY_ROOT_MARKER_204\n" + const secondaryFilePath = path.join(secondaryRoot, secondaryFileName) + + await fs.writeFile(secondaryFilePath, expectedContent, "utf8") + + const api = globalThis.api + const messages: ClineMessage[] = [] + const messageHandler = ({ message }: { message: ClineMessage }) => { + if (message.partial !== true) { + messages.push(message) + } + } + api.on(RooCodeEventName.Message, messageHandler) + + let taskCompleted = false + let taskId = "" + const taskCompletedHandler = (id: string) => { + if (id === taskId) { + taskCompleted = true + } + } + api.on(RooCodeEventName.TaskCompleted, taskCompletedHandler) + + try { + taskId = await api.startNewTask({ + configuration: { + mode: "code", + autoApprovalEnabled: true, + alwaysAllowReadOnly: true, + alwaysAllowReadOnlyOutsideWorkspace: true, + }, + text: + `READ_FILE_MULTI_ROOT_REPRO: Use only the read_file tool to read "${secondaryFileName}". ` + + `The file exists in the current VS Code workspace, but only inside the secondary workspace root. ` + + `After the read attempt, explain exactly what happened.`, + }) + + await waitFor(() => taskCompleted, { timeout: 60_000, interval: 250 }) + + assert.ok( + vscode.workspace.workspaceFolders?.some((folder) => folder.uri.fsPath === secondaryRoot), + `Expected secondary root ${secondaryRoot} to remain part of the workspace during the repro`, + ) + + const completionMessage = messages.find( + (message) => + message.type === "say" && + (message.say === "completion_result" || message.say === "text") && + message.text?.includes("SECONDARY_ROOT_MARKER_204"), + ) + + assert.ok( + completionMessage, + `Expected the task to read the secondary-root file. Primary root was ${primaryRoot}, secondary root was ${secondaryRoot}, secondary file was ${secondaryFilePath}, and messages were ${JSON.stringify(messages, null, 2)}.`, + ) + } finally { + api.off(RooCodeEventName.Message, messageHandler) + api.off(RooCodeEventName.TaskCompleted, taskCompletedHandler) + + try { + await api.cancelCurrentTask() + } catch { + // Ignore cleanup races if the task already ended. + } + + await fs.rm(secondaryFilePath, { force: true }) + } + }) +})